识别 AI Coding Agent:解析 Cypress@packages/agent-info环境指纹检测的实现与扩展
【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress
@packages/agent-info是 Cypress 仓库中一个刻意保持"小而纯"的内部包,它只回答一个问题:当前这个 Cypress 进程是否由 AI 编程代理(AI coding agent)调用,以及具体是哪一个。它通过比对进程环境块中的特征变量(如CLAUDECODE、GEMINI_CLI、CURSOR_AGENT),返回一个来自封闭集合的固定名称(claude、codex、cursor等)。本文以 AGENTS.md 为主线,结合 lib/index.ts、测试用例与 CLI 消费端,完整讲解其设计动机、源码结构、检测规则、调用方式以及"如何新增一个 Agent"的操作指南,供需要在自有工具链中实现或扩展 Agent 指纹识别的开发者参考。
为什么要单独做一个"Agent 识别"包
随着 Claude Code、Codex、Cursor 等 AI 编程代理被越来越多人用来直接驱动命令行工具,Cypress 这类 CLI 工具需要回答两个问题:
- 当前进程是不是某个 Agent 派生的子进程?
- 如果是,它来自哪个 Agent?
Agent 自身会通过环境变量暴露身份——CLAUDECODE、GEMINI_CLI、CURSOR_AGENT、AI_AGENT等等。agent-info做的事情就是"对环境做指纹比对":把环境对象与一张标记变量表(detection table)做匹配,输出一个固定名称。
关键约束在于输出值会被上报离开用户机器(CLI 会将其作为遥测数据的一部分发送),所以返回值必须来自封闭集合,而不是环境里任意字符串的原样透传——这正是本包存在的核心价值。AGENTS.md 的第一段即明确了这一点:"It fingerprints the environment block against a table of marker variables and returns a fixed name from a closed set."
设计基石:纯函数、零依赖的 TypeScript
该包被刻意设计为纯净、无运行时依赖的 TypeScript:
- 不碰
fs、不碰http; - 无任何运行时依赖(
package.json的dependencies为空,仅含 devDependencies); - 不读取除"被传入的环境对象"之外的任何东西。
正因为如此,Node 侧的各个包以及cypressCLI 才能放心地把它打进 bundle,而不会引入新的运行时依赖。从 package.json 可以看到包入口为dist/index.js,构建产物仅含dist目录,类型声明由dist/index.d.ts提供。
整体架构:一个文件承载全部逻辑
AGENTS.md 的 Architecture 一节指出:整个包就是 lib/index.ts 一个文件,它包含三大部分:
| 组成部分 | 作用 |
|---|---|
AgentName联合类型 | 可返回的封闭名称集合 |
AGENTS检测表 | 名称 → 一组环境检查条件的映射 |
detectAgent()/isAgent() | 对外入口,扫描环境并给出结论 |
配套的还有 lib/spec/index.spec.ts 中的 Vitest 测试,以及面向使用者的 README.md(其同时被 CLAUDE.md 以@AGENTS.md方式引用,仓库内约定以 AGENTS 文档为单一事实来源)。
AgentName:封闭的名称集合
源码第 1–14 行定义了AgentName:
export type AgentName = | 'auggie' | 'claude' | 'codex' | 'cursor' | 'devin' | 'gemini' | 'goose' | 'junie' | 'kiro' | 'opencode' | 'other' | 'pi' | 'replit'detectAgent()只可能返回这些名称之一,或undefined。其中'other'是兜底值:当通用变量AI_AGENT存在但表内无法识别时返回,代表"确实有 Agent,但无法命名"。
AGENTS检测表:字符串标记与函数式检查
EnvCheck支持两种检查方式(源码第 16–48 行):
- 裸环境变量名(字符串):只要该变量存在且有值即为命中,如
['claude', ['CLAUDECODE', 'CLAUDE_CODE']]; envMatcher(key, regex)函数:变量"存在"不足以说明问题、还需校验取值格式时使用,例如pi需要检查PATH中是否含\.pi[\\/]agent模式、devin需要检查EDITOR是否以(^|[\\/])devin(\.exe)?$结尾、kiro需要在无 TTY 前提下检查TERM_PROGRAM是否匹配/kiro/。
整张表(含所有分支变量)如下:
| Agent | 环境标记 / 检查条件 |
|---|---|
claude | CLAUDECODE、CLAUDE_CODE |
replit | REPL_ID |
gemini | GEMINI_CLI |
codex | CODEX_SANDBOX、CODEX_THREAD_ID |
opencode | OPENCODE |
pi | envMatcher('PATH', /\.pi[\\/]agent/) |
auggie | AUGMENT_AGENT |
goose | GOOSE_PROVIDER |
junie | JUNIE_DATA、JUNIE_SHIM_PATH |
devin | envMatcher('EDITOR', /(^\|[\\/])devin(\.exe)?$/) |
cursor | CURSOR_AGENT |
kiro | envMatcher('TERM_PROGRAM', /kiro/, { noTTY: true }) |
表序是有意设计的:IDE(如 Cursor)排在最后,以确保"运行在 IDE 内部的 Agent 被识别为 Agent 而非 IDE"——因为同一环境里可能同时出现CURSOR_AGENT与CLAUDECODE,先命中claude行便立即返回,这由测试'reports the agent rather than the IDE hosting it'明确验证。
detectAgent()与isAgent():两个入口
export const detectAgent = (env: NodeJS.ProcessEnv = process.env): AgentName | undefined => { for (const [name, checks] of AGENTS) { for (const check of checks) { if (typeof check === 'string' ? env[check] : check(env)) { return name } } } return env.AI_AGENT ? fromAiAgent(env.AI_AGENT) : undefined } export const isAgent = (env: NodeJS.ProcessEnv = process.env): boolean => { return !!detectAgent(env) }逻辑非常直接:先顺序遍历AGENTS表(每个名称下依次尝试各组检查,字符串标记做存在性判断,函数标记执行匹配),全部未命中后再看通用的AI_AGENT变量。两入口均接受可选参数env,默认为process.env。
fromAiAgent():把自由文本"收窄"成固定名
AI_AGENT是自由格式且常带版本号——例如 Claude Code 会设置形如claude-code_2-1-221_agent的值。源码第 55–61 行先转小写,再用正则^${name}($|[^a-z0-9])从已知名称中寻找前缀命中:
const fromAiAgent = (value: string): AgentName => { const normalized = value.toLowerCase() // The name has to end where it ends, so a short one like `pi` cannot claim an // unrelated `pipecat`. return KNOWN_NAMES.find((name) => new RegExp(`^${name}($|[^a-z0-9])`).test(normalized)) ?? 'other' }这个边界正则防止了短名称误伤:pi不会"认领"不相关的pipecat(pipecat中pi之后是p,属于[a-z0-9],不满足$|[^a-z0-9])。该行为由测试'reports a value that only begins with a known name as other'覆盖。
API 使用方式
README.md 给出两个入口的完整说明,可直接在 Node/TS 环境中使用:
import { detectAgent } from '@packages/agent-info' detectAgent() // 在 Claude Code 下运行 → 'claude' detectAgent({ GEMINI_CLI: '1' }) // 'gemini' detectAgent({}) // undefined(未检测到任何 Agent)detectAgent(env?: NodeJS.ProcessEnv): AgentName | undefined:检查传入环境(默认process.env),返回命中的AgentName,无任何 Agent 时返回undefined;isAgent(env?: NodeJS.ProcessEnv): boolean:仅返回布尔值,便于快速分支判断:import { isAgent } from '@packages/agent-info' if (isAgent()) { // 运行在某个 Agent 之下,无论是否被具名识别 }
由于包自身是纯净的,你甚至可以传入任意手工构造的环境对象做单元测试或离线演练,而完全不需要真的启动一个 Agent。
CLI 中的实际消费点:遥测事件里的agent字段
agent-info的真实消费者是cypressCLI。在 cli/package.json 中它以 workspace 依赖"@packages/agent-info": "0.0.0-development"被引用;检测结果在 cli/lib/tap/events.ts 中进入上报事件 payload:
const payload = { command: trace.command, flags: trace.flags.slice(0, MAX_REPORTED_FLAGS), agent: detectAgent(), sessionId: identity?.sessionId ?? undefined, userId: identity?.userId ?? undefined, exitCode, errorCode: trace.errorCode, durationMs: Date.now() - trace.startedAt, }随后该 payload 通过 fetch POST 发送。这解释了 AGENTS.md 反复强调的一条铁律:只有固定名称允许离开机器(Only fixed names leave the machine)。AI_AGENT被收窄成已知名或'other'后才可能被上报,绝不会把未经审查的环境字符串原样透传出去——否则一次简单的环境探测就可能泄露用户环境里的敏感值。
扩展指南:如何新增一个 Agent
AGENTS.md 明确给出新增 Agent 的标准流程:三处改动,全部位于 lib/index.ts 及其 spec 中:
第 1 步:把名称加进AgentName联合类型(保持字母序)
export type AgentName = | 'auggie' // ... 在对应字母位置插入新名称,保持字母序 | 'myagent'第 2 步:在AGENTS表中新增一行
const AGENTS: readonly (readonly [AgentName, readonly EnvCheck[]])[] = [ // ... ['myagent', ['MY_AGENT_VAR']], ]选择判据:若某个变量"存在即足以证明"(例如该变量仅由对应 Agent 设置),直接用裸变量名;若变量在许多场景都会出现、仅靠存在性不足为凭,则用envMatcher(key, regex)校验取值。例如pi之所以用envMatcher('PATH', /\.pi[\\/]agent/),正是因为普通用户机器上也可能出现含.pi的路径;kiro用envMatcher('TERM_PROGRAM', /kiro/, { noTTY: true })则是因为需要叠加 TTY 门控。
第 3 步:在 lib/spec/index.spec.ts 的detects %s表格里新增一行
it.each([ // ... ['myagent', { MY_AGENT_VAR: '1' }], ])('detects %s', (name, env) => { expect(detectAgent(env)).toBe(name) expect(isAgent(env)).toBe(true) })每次检测逻辑的调整都伴随 spec 更新,保证表格与联合类型、测试三者同步演化。
工程细节与设计陷阱(Gotchas)
AGENTS.md 记录了若干经过真实权衡得出的工程约定,直接决定了实现的形态:
1. 表序即优先级:IDE 必须排在最后
IDE 环境变量(如CURSOR_AGENT)会先于 IDE 内部的 Agent 变量被扫描的风险,因此实现把 IDE 相关的行放在表尾。意图是:在 IDE 内运行的 Agent,应被上报为 Agent 本身,而非承载它的 IDE。测试detectAgent({ CURSOR_AGENT: '1', CLAUDECODE: '1' })期望返回'claude',正是这一约定的回归保障。
2. 宁可用"锚定的具体标记",也不用"子串匹配"
对路径类变量做正则时,若匹配可发生在任意位置,则可能误伤——某位用户的主目录恰好以某个 Agent 命名就会被错判。因此必须锚定在路径分隔符和字符串结尾上:pi的正则用\.pi[\\/]agent(要求.pi后紧跟分隔符与agent),devin的正则(^|[\\/])devin(\.exe)?$要求匹配到行尾。测试'does not match devin in the path of an unrelated editor'验证了/home/devin/.local/bin/vim不会被误判为 devin。同时两个正则都使用[\\/]兼容 Windows 反斜杠路径,对应 Windows 风格 PATH/EDITOR 的两个测试用例。
3. TTY 门控:检测默认偏向"人类"
某些环境变量同时被IDE 的集成终端和它的 CLI Agent设置。此时若该进程的 stdin 或 stdout 连着一个 TTY,基本可以断定有人在终端前操作,而不是 Agent 派生的子进程。因此envMatcher(..., { noTTY: true })会在任一端口为 TTY 时跳过该检查——两端都要查,因为重定向其一仍会留下另一个,典型如cypress run | tee log.txt(stdout 被管道占用的场景)。其背后的原则是:
把人类误标成 Agent,比漏掉一个 Agent 更糟糕。
测试套件通过操作process.stdout/process.stdin的isTTY描述符来验证:TERM_PROGRAM: 'kiro'在挂上 stdout TTY 或仅 stdin 为 TTY(输出被管道化)时都不再命中;但同时给出CLAUDECODE: '1'时仍会识别为claude,因为显式的强标记不受 TTY 门控影响。
4. 空值语义:变量存在但为空 = 未设置
源码第 30 行return value ? regex.test(value) : false,字符串分支也只对 truthy 值命中。测试'ignores an env var that is set but empty'({ CLAUDECODE: '' })确认空字符串不会触发检测——这是对"环境变量被设置成空串是常见 shell 状态"的防御。
构建、测试与运行环境约束
在仓库根目录下执行以下命令可构建与测试该包:
# 用 tsc 编译 TypeScript 到 dist/ yarn workspace @packages/agent-info build # 运行测试(Vitest) yarn workspace @packages/agent-info test补充说明:
build脚本实际为yarn clean && tsc(见 package.json),dist/由tsc生成,禁止手改(AGENTS.md 明确提示)。- 该包没有独立的
lint外的运行时脚本依赖,日常开发还可使用watch(tsc --watch)增量编译。 - 运行环境约束:由于该包被
cypressCLI 消费,而 CLI 运行在用户自己的 Node上,因此兼容范围受 cli/package.json 中engines.node约束(当前为^22.0.0 || ^24.0.0 || >=26.0.0)——这是一个比开发或打包 Node 版本更低的"下限"要求,意味着代码里不能使用超出该下限的 Node 新特性。这与包的"零运行时依赖 + 保守语法"取向一致,确保它无论被加载到哪一档受支持的 Node 都能正常工作。
测试覆盖一览
lib/spec/index.spec.ts 用 Vitest 系统化地验证了本包的全部行为契约:
| 测试主题 | 覆盖点 |
|---|---|
| 空环境 | detectAgent({})为undefined、isAgent({})为false |
detects %s参数化表格 | 14 组 Agent × 环境变量组合全部命中 |
| Windows 风格路径 | pi(PATH含反斜杠.pi\agent)与devin(devin.exe) |
| 空字符串 | 变量存在但为空不触发检测 |
| 优先级 | 同时出现CURSOR_AGENT与CLAUDECODE时返回claude(Agent 胜过 IDE) |
| 误判防御 | /home/devin/.local/bin/vim不命中devin |
| TTY 门控 | stdout 为 TTY 时不识别kiro;仅 stdin 为 TTY(输出被管道化)时同样不识别;显式标记(CLAUDECODE)不受影响 |
AI_AGENT | 带版本号值收窄为已知名;大小写不敏感;未知值返回other而非原样;pipecat不因前缀pi误判;表内显式变量优先于AI_AGENT |
| 默认参数 | 未传env时读取process.env(vi.stubEnv模拟) |
这些测试同时充当"检测表语义"的可执行文档——新增或调整 Agent 检测规则时,先改 spec、再改实现,是仓库推荐的开发顺序。
小结
@packages/agent-info以约 77 行源码回答了一个看似简单实则布满陷阱的问题:进程是否由 AI 编程代理启动、由谁启动。它的方法论可以提炼为可复用的四条经验:输出必须来自封闭集合以保护隐私上报;识别依据应优先使用锚定的具体标记而非宽泛子串;表序即优先级(具体 Agent 优先于承载它的 IDE);默认偏向人类(TTY 门控只在无交互终端时放行)。配合一次调用即完成的环境指纹比对与高度参数化的测试表,这个模式非常适合移植到任何需要"区分人与 Agent、并安全上报 Agent 身份"的 CLI 或开发工具中。若需在 Cypress 中扩展对某个新 Agent 的支持,只需按上文"三处改动"的流程,在 lib/index.ts 与 index.spec.ts 中同步更新联合类型、检测表和测试行即可。
【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考