Composio CLI 无头 Agent 登录契约解析:composio login --agent端到端测试如何验证无人值守鉴权
【免费下载链接】composioComposio powers 1000+ toolkits, tool search, context management, authentication, and a sandboxed workbench to help you build AI agents that turn intent into action.项目地址: https://gitcode.com/GitHub_Trending/co/composio
本文基于仓库中ts/e2e-tests/cli/agent-signin/README.md及其配套测试ts/e2e-tests/cli/agent-signin/e2e.test.ts展开,讲解 Composio CLI 针对"无人值守 Agent"设计的三条登录契约:composio login --agent如何把一个全新环境带到已认证状态、管道里运行composio login时为何绝不自动注册账号、以及一份已存储的 READY 状态agent.json如何让普通composio login在 headless 环境下静默复用完成登录。读完后你将理解该 e2e 测试的断言设计、mock 服务端实现,以及 CLI 源码中对应流程(login.cmd.ts、agents.ts)的落地细节。
一、契约背景:为什么需要专门的 Agent 签入 e2e
Composio CLI 的默认登录是"人在浏览器里点一下"的 OAuth 流程,但 CI 容器、Agent 运行环境里没有人。README(标题为CLI agent sign-in e2e (PRDE-1138))明确给出这个 e2e 要断言的三条"headless agent onboarding contract":
composio login --agent能把一个无人的 Agent 从零带到已认证 CLI。服务端agents.composio.dev通过环境变量COMPOSIO_AGENTS_BASE_URL指向 mock 服务,登录完成后再跟一条composio whoami验证身份确实落盘生效。- 管道里裸跑
composio login时只打印 OAuth URL 和轮询指令,并且主动提示无值守 Agent 应该走composio login --agent;关键是 guardrail:绝不为管道里的人类自动创建账号(原文表述:"humans in pipes are not signed up")。 - 已存储的 READY 状态 Agent 身份(
agent.json)让普通 headlesscomposio login可以复用身份无人值守地完成登录,同样不触发注册。
README 最后强调:所有网络面都由 mock-agents-server.ts 模拟,"no real accounts are created"——整个测试不碰真实账号。下面按"测试环境 → 三个场景 → 源码实现"逐层拆解。
二、测试环境与 mock 服务端:全部网络面本地可控
2.1 测试入口与运行方式
e2e 用例位于 e2e.test.ts,使用bun:test与仓库自研的e2e()测试脚手架(来自@e2e-tests/utils,声明于 package.json):
# 在 ts/e2e-tests/cli/agent-signin/ 目录下 bun test e2e.test.ts用例只针对 CLI 的current版本运行(versions: { cli: ['current'] }),意味着契约跟随最新构建验证。
2.2 三个独立 mock 实例与统一环境变量
beforeAll里启动了三台独立的 mock 服务,分别对应三个场景,互不污染:
signupServer = await startMockAgentsServer(); // 场景一:无值守注册 guardrailServer = await startMockAgentsServer(); // 场景二:headless 人类登录 reuseServer = await startMockAgentsServer(); // 场景三:复用已存身份三条命令统一通过envPrefix注入环境变量(e2e.test.ts#L22-L27):
const envPrefix = (server: MockAgentsServer): string => [ `COMPOSIO_BASE_URL=${server.dockerBaseUrl}`, `COMPOSIO_AGENTS_BASE_URL=${server.dockerBaseUrl}`, `COMPOSIO_CACHE_DIR=${CACHE_DIR}`, ].join(' ');COMPOSIO_AGENTS_BASE_URL把 Agent 面(POST /api/signup、GET /api/whoami)指向 mock,对应源码 agents.ts#L87-L90 中APP_CONFIG.AGENTS_BASE_URL覆盖DEFAULT_AGENTS_BASE_URL(https://agents.composio.dev)的逻辑;COMPOSIO_BASE_URL把 Composio 平台面(浏览器登录的create-session等)一并 mock 掉;COMPOSIO_CACHE_DIR=/tmp/composio-agent-signin隔离凭据缓存目录;dockerBaseUrl形如http://host.docker.internal:<port>,说明测试在 Docker 容器内执行 CLI,通过该主机名回连宿主机上的 mock 服务。
2.3 mock 端点清单与身份 fixture
mock-agents-server.ts 用node:http实现,只做两件事:记录每个请求(requests.push(\${method} ${url.pathname}`)`,供断言"注册是否被触发")和按路由返回固定响应。它 mock 的路由有四条(mock-agents-server.ts#L44-L85):
| 方法 + 路径 | 角色 | 响应 |
|---|---|---|
POST /api/signup | agents.composio.dev 面,注册新 Agent | 返回AGENT_IDENTITY |
GET /api/whoami | 校验 Agent key | 请求头必须是Bearer <composio_agent_key>,否则 401 |
POST /api/v3.1/cli/create-session | 默认登录路径的 Apollo 面 | 返回 10 分钟过期的 pending session(id/code/expiresAt/status) |
POST /api/v3/cli/analytics | 分析上报 | 204 空体 |
未 mock 的路由统一返回 404——注释说明这些 404 恰好能"exercise their degrade paths"(演练各流程的降级路径),即测试同时覆盖了部分接口不可用时的容错。
核心 fixture 是一份写死的 READY 身份(mock-agents-server.ts#L10-L21):
const AGENT_IDENTITY = { status: 'READY', slug: 'e2e-agent', email: 'e2e-agent@agent.composio.ai', composio_agent_key: 'cak_e2e_agent', composio: { member_id: 'mem_e2e_agent', org_id: 'org_e2e_agent', project_id: 'proj_e2e_agent', user_api_key: 'uak_e2e_agent', }, } as const;这份结构就是源码里AgentIdentitySchema 的样例(agents.ts#L20-L40):顶层是 Agent 账号属性(status、slug、email、composio_agent_key),内嵌composio对象携带真正用于调 API 的凭据(user_api_key、org_id等)。"READY" 的判定 =status归一化后为READY且composio.user_api_key与composio.org_id非空(见 agents.ts#L406-L409),这正是后两个场景能成立的前提。
三、场景一:composio login --agent无值守引导(从 0 到已认证)
3.1 测试命令与断言
beforeAll中执行的命令(e2e.test.ts#L46-L48):
COMPOSIO_BASE_URL=... COMPOSIO_AGENTS_BASE_URL=... COMPOSIO_CACHE_DIR=/tmp/composio-agent-signin \ composio login --agent --no-skill-install && composio whoami--no-skill-install关闭登录成功后的 Claude Code skill 安装步骤,保证输出干净。随后四组断言逐条验证 README 第一条契约:
it('exits successfully', () => { expect(unattendedSignup.exitCode).toBe(0); }); it('signs up via agents.composio.dev', () => { expect(signupServer.requests).toContain('POST /api/signup'); }); it('prints the agent login summary on stdout', () => { expect(unattendedSignup.stdout).toContain('"account_type":"agent"'); expect(unattendedSignup.stdout).toContain('"logged_in":true'); expect(unattendedSignup.stdout).toContain('"status":"READY"'); }); it('leaves the CLI authenticated as the agent (whoami)', () => { const whoami = lastJsonLine(unattendedSignup.stdout); expect(whoami.account_type).toBe('agent'); });注意最后一条:whoami是&&拼接在登录命令之后的独立进程,它从 stdout 的最后一行 JSON解析身份——这验证的不是"登录命令自己声称成功",而是"凭据真的写进了缓存目录、且下一条命令能以此身份工作"。
3.2 源码侧:--agent的调用链
在 login.cmd.ts 中,--agent是一个布尔选项(login.cmd.ts#L78-L81):
const agentOpt = Options.boolean('agent').pipe( Options.withDefault(false), Options.withDescription('Sign up or log in using a Composio agent identity') );选项互斥被显式校验:--agent不能与--no-browser/--no-wait/--key/--user-api-key组合(login.cmd.ts#L915-L921),因为它们是互斥的登录路径。命中--agent分支后的主流程(login.cmd.ts#L963-L974):
if (agent) { return yield* handleAgentAuthError( Effect.gen(function* () { yield* ensureAgentSignupAllowed; const identity = yield* getOrSignupReadyAgent(); yield* completeAgentLogin(identity); if (!noSkillInstall && canPrompt) { yield* installSkillSafe({ channel: inferSkillReleaseChannel(APP_VERSION) }); } }) ); }三个关键步骤在 agents.ts 中:
ensureAgentSignupAllowed(agents.ts#L318-L327):如果当前 CLI 已以普通人类账号登录(存在 API key 且该 key 不属于任何已存 Agent 身份),直接报错并给出composio logout后的切换步骤——防止--agent在人类会话上"偷换"身份。getOrSignupReadyAgent()(agents.ts#L414-L431):先读本地agent.json,若存在则用composio_agent_key调GET /api/whoami刷新;刷新结果status === 'READY'直接复用。否则走signupAgent()——POST /api/signup(agents.ts#L267-L276)——并把返回身份原子写入缓存目录下的agent.json。这就是场景一断言POST /api/signup出现的来源:全新环境没有本地身份,必然注册。completeAgentLogin(identity)(login.cmd.ts#L305-L312):调loginWithAgentIdentity用composio.user_api_key+composio.org_id完成真正的 CLI 登录(agents.ts#L433-L446),然后输出测试断言的那段 JSON:
yield* ui.log.success(`Logged in as Composio agent ${summary.email ?? summary.slug ?? ''}`); yield* ui.output(JSON.stringify({ ...summary, logged_in: true }));其中summary = safeAgentSummary(identity)(agents.ts#L154-L164),字段固定为account_type: 'agent'、status、slug、email、org_id、project_id、member_id、claimed_by、claimed_at——与测试断言的"account_type":"agent"、"status":"READY"一一对应。
四、场景二:管道里的composio login——guardrail 验证
4.1 测试命令与断言
第二条命令只有一行(e2e.test.ts#L50):
composio login # 在容器/管道环境中运行,stdin 非 TTYe2e 框架在 Docker 容器中执行 CLI,canPrompt能力探测为 false,于是进入非交互路径。断言(e2e.test.ts#L89-L106)分三层:
it('exits successfully with instructions instead of hanging', () => { expect(headlessHumanLogin.exitCode).toBe(0); expect(headlessHumanLogin.stdout).toContain('Open this URL in your browser to log in:'); expect(headlessHumanLogin.stdout).toContain('composio login --poll'); }); it('offers the unattended agent path', () => { expect(headlessHumanLogin.stdout).toContain('For unattended agents'); expect(headlessHumanLogin.stdout).toContain('composio login --agent'); }); it('never auto-signs-up an account', () => { expect(guardrailServer.requests.filter(request => request.includes('/api/signup'))).toEqual([]); });三条断言恰好对应 README 的第二条契约:打印 OAuth URL + 轮询指令、推荐--agent路径、零注册请求。最后一条是整个 guardrail 的核心——通过检查 mock 端requests数组中没有任何/api/signup,从服务端视角证明"管道里的人类没被自动签入"。
4.2 源码侧:非交互输出与"只复用、不注册"
当终端不可交互(effectiveNoWait为真),browserLogin走纯打印分支(login.cmd.ts#L703-L721),先createSession拿到 mock 的 pending 会话,再把指令文本交给ui.output。指令文本由formatNonInteractiveLoginInstructions生成(login.cmd.ts#L195-L208),测试断言的四段字样全部出自这里:
Open this URL in your browser to log in: <loginUrl> Then run this command to complete login: composio login --poll hint: For agents: Show the URL above to the user to click, then run the command above. ... hint: For unattended agents: If no human is available to open the URL, run `composio login --agent` instead — it signs the CLI in with a Composio agent account (creating one if needed) without a browser. Never use it when a human is present to log in with their own account.这段 hint 本身就是一份写给 Agent 的"操作说明书":明确告知轮询命令用缓存的 login key、最长轮询 10 分钟、不要询问用户是否轮询——与--poll的常量定义(LOGIN_POLL_TIMEOUT_SECONDS = 10 * 60,login.cmd.ts#L84-L87)一致。
guardrail 的真正实现在login命令末尾的复用检查(login.cmd.ts#L1009-L1027):
// Reuse-only by design: headless login may complete with an existing // agent identity but must never auto-create one for a human in a pipe. const requestedExplicitLoginFlow = noBrowser || noWait; if (!canPrompt && !requestedExplicitLoginFlow) { const storedAgent = yield* getStoredReadyAgent; if (Option.isSome(storedAgent)) { const identity = storedAgent.value; const activeApiKey = ctx.data.apiKey; if ( Option.isNone(activeApiKey) || isAgentIdentityForApiKey(identity, activeApiKey.value) ) { return yield* handleAgentAuthError(completeAgentLogin(identity)); } } }注释把设计意图写得很直白:"Reuse-only by design... must never auto-create one for a human in a pipe"。注意分支条件:只有无 TTY 且未显式要求--no-browser/--no-wait输出契约时才尝试复用——后两者承诺了确定性的输出格式(打印 URL、会话 JSON),静默复用 Agent 会破坏该契约。场景二中没有任何已存agent.json,getStoredReadyAgent返回Option.none,流程落到browserLogin的非交互打印分支,于是:退出码 0、打印 URL 与--poll指令、且POST /api/signup请求数为零。
五、场景三:READY 的agent.json让普通composio login无人值守完成
5.1 测试命令与断言
第三条命令先落盘身份、再跑登录(e2e.test.ts#L52-L59):
mkdir -p /tmp/composio-agent-signin printf '%s' '<reuseServer.agentIdentity 的 JSON>' > /tmp/composio-agent-signin/agent.json composio login && composio whoami注意这里写的是裸composio login(不带--agent),写入的内容就是 mock 服务导出的reuseServer.agentIdentity(即第二节 2.3 的 fixture)。断言(e2e.test.ts#L108-L124):
it('completes login unattended by reusing the stored identity', () => { expect(storedIdentityLogin.exitCode).toBe(0); expect(storedIdentityLogin.stdout).toContain('"logged_in":true'); expect(storedIdentityLogin.stdout).not.toContain('Open this URL in your browser'); }); it('refreshes the identity instead of signing up', () => { expect(reuseServer.requests).toContain('GET /api/whoami'); expect(reuseServer.requests.filter(request => request.includes('/api/signup'))).toEqual([]); }); it('leaves the CLI authenticated as the agent (whoami)', () => { const whoami = lastJsonLine(storedIdentityLogin.stdout); expect(whoami.account_type).toBe('agent'); });三条断言合起来精确刻画 README 第三条契约:无 URL 提示、退出 0(静默完成);先GET /api/whoami刷新而非POST /api/signup注册;whoami确认最终身份仍是 agent 账号。
5.2 源码侧:getStoredReadyAgent的刷新语义
该场景走的是第四节末尾的复用分支,核心是 agents.ts#L387-L412 的getStoredReadyAgent。注释称其为 "Reuse-only counterpart ofgetOrSignupReadyAgent"——它刷新并返回已存的 READY 身份,从不注册新 Agent。两个实现细节值得注意:
1. 401/403 与网络失败的区分处理:
const remote = yield* fetchAgentWhoami(agentKey).pipe(Effect.either); // An auth rejection means the API examined and refused this key — the stored // identity is revoked, not unreachable. Only transport-shaped failures may // fall back to the on-disk identity. if (Either.isLeft(remote) && isAgentKeyRejection(remote.left)) { return Option.none<AgentIdentity>(); }isAgentKeyRejection只认 401/403(agents.ts#L380-L381):服务器明确拒绝了 key,说明身份已被吊销,不可回退到磁盘身份;而 DNS 超时之类的传输层故障则可回退使用本地副本(identity = ... : stored.value)。这个语义保证了吊销的 Agent 不会在离线环境下"诈尸"。mock 服务的whoami路由正是用Bearer ${composio_agent_key}校验(mock-agents-server.ts#L55-L64),使该分支在测试中真实可触发。
2. READY 门槛:刷新(或回退)后的身份必须满足status === 'READY' && composio.user_api_key && composio.org_id才会被返回(agents.ts#L406-L411)。fixture 恰好是 READY 且凭据齐全,所以场景三通过。
身份随后由completeAgentLogin落盘为 CLI 凭据并输出logged_in: true的 JSON 摘要——与场景一共用同一出口,但注册请求数为零,这就是"refresh the identity instead of signing up"断言的实现依据。
六、本地运行这份 e2e 与相关路径速查
- 测试目录:ts/e2e-tests/cli/agent-signin/,脚本
test:e2e: bun test e2e.test.ts(package.json);依赖 e2e 通用脚手架@e2e-tests/utils(workspace 内部包)。 - mock 服务可直接由
bun ts/packages/cli/scripts/mock-agents-server.ts风格调用(startMockAgentsServer(),支持host/port参数,port: 0自动分配随机端口并返回hostBaseUrl与dockerBaseUrl两个回连地址,mock-agents-server.ts#L36-L43)。 - CLI 命令面:
--agent选项定义与互斥校验在 login.cmd.ts#L78-L81 与 login.cmd.ts#L915-L921;headless 复用 guardrail 在 login.cmd.ts#L1009-L1027。 - Agent 身份服务层:身份 Schema 与
agent.json文件位置(缓存目录下,AGENT_CONFIG_FILE_NAME)见 agents.ts#L13-L40;注册/刷新/登录入口signupAgent/fetchAgentWhoami/loginWithAgentIdentity见 agents.ts#L267-L286 与 agents.ts#L433-L453。
七、小结:三条契约、一套可复制的验证模式
这份 e2e 的价值不在"跑通了登录",而在于用服务端请求日志 + stdout 契约把三个易被破坏的行为钉死:
| 场景 | 命令 | 关键断言 | 对应实现 |
|---|---|---|---|
| 无值守引导 | composio login --agent && whoami | 出现POST /api/signup;输出"logged_in":true;whoami 为 agent | login.cmd.ts#L963-L974、agents.ts#L414-L431 |
| 管道人类登录 | composio login(非 TTY) | 打印 URL +--poll指令;推荐--agent;/api/signup请求数为 0 | login.cmd.ts#L195-L208、login.cmd.ts#L1009-L1027 |
| 复用已存身份 | 写agent.json后composio login && whoami | 无 URL 提示;GET /api/whoami出现且无 signup;whoami 为 agent | agents.ts#L387-L412 |
对 Agent 开发者而言,可落地的结论是:CI 或无人值守环境应显式使用composio login --agent完成首登(其输出是可解析的 JSON 摘要);已有 READY 身份时,裸composio login会静默复用;而把composio login交给人类在管道里执行,只会得到一段包含 OAuth URL 与composio login --poll的说明文本,不会产生任何账号——这三条边界正是 README 所定义、并由 e2e.test.ts 持续守护的契约。
【免费下载链接】composioComposio powers 1000+ toolkits, tool search, context management, authentication, and a sandboxed workbench to help you build AI agents that turn intent into action.项目地址: https://gitcode.com/GitHub_Trending/co/composio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考