diff --git a/bin/lemon.js b/bin/lemon.js index 9184851..55cdfcf 100755 --- a/bin/lemon.js +++ b/bin/lemon.js @@ -3,7 +3,7 @@ const { parseArgs } = require('util'); const http = require('http'); -const API_BASE = 'http://localhost:3010/api'; +const API_BASE = process.env.LEMON_API_BASE || 'http://localhost:3010/api'; function request(method, path, data) { return new Promise((resolve, reject) => { @@ -13,6 +13,12 @@ function request(method, path, data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(dataStr); } + // agent-chat.js 挂了 requireAuthOrInternal(38 号文档),这个 CLI 跑在 agent 自己的 + // claude CLI 子进程里,没有登录 cookie;LEMON_INTERNAL_TOKEN 是服务端进程 spawn 子进程时 + // 顺带传下来的(server/middleware/jwt-auth.js),带上就能过 internal 校验。 + if (process.env.LEMON_INTERNAL_TOKEN) { + headers['x-internal-token'] = process.env.LEMON_INTERNAL_TOKEN; + } const req = http.request(API_BASE + path, { method, headers diff --git a/server/middleware/jwt-auth.js b/server/middleware/jwt-auth.js index 49877e5..a23c08a 100644 --- a/server/middleware/jwt-auth.js +++ b/server/middleware/jwt-auth.js @@ -184,12 +184,20 @@ function extractUserFromToken(token) { * company.js:82/506、task-settle.js:452 这三处 `fetch('http://127.0.0.1:.../api/agent/...')` * 拿不到任何用户 cookie,会被 401 挡住——38 号文档 Phase 1)。 * - * 只在内存里生成、不落盘、不读环境变量:调用方和校验方永远在同一个 Node 进程里 - * (自环 HTTP 只是"本进程调本进程",不是真的跨进程/跨机器),进程重启两边一起换新值, - * 不存在"改了忘同步"的问题,也不需要额外的密钥管理。 + * 只在内存里生成、不落盘:调用方和校验方永远在同一台机器的进程树里,进程重启两边一起 + * 换新值,不存在"改了忘同步"的问题,也不需要额外的密钥管理。 + * + * 写进 process.env 是为了让 `bin/lemon.js` 也能用上这个 token——它是 agent 用来 + * `task report`/`task dispatch` 自我汇报的 CLI,跑在 claude CLI 子进程(乃至更深的 Bash + * 子进程)里,跟本进程不共享内存,没法直接 import 这个常量;但 spawn 默认把 process.env + * 整份传给子进程(ClaudeCliProtocol.js/ProcessLauncher.js 都是 `{...process.env}`), + * 写这一行就等于免费传给了所有后代进程,不用改任何 launcher 代码。之前只顾了「同进程内 + * require 拿常量」这一种自环调用,漏了「agent 子进程里跑 CLI 工具」这一种,就是这次 + * bug 的成因(commit b7b5096 之后 bin/lemon.js 的 task report/dispatch 全部 401)。 */ const INTERNAL_TOKEN = require('crypto').randomBytes(24).toString('hex'); const INTERNAL_TOKEN_HEADER = 'x-internal-token'; +process.env.LEMON_INTERNAL_TOKEN = INTERNAL_TOKEN; /** * Middleware: 放行本进程自环调用(带对的 INTERNAL_TOKEN),其余情况退回 requireAuth。 diff --git a/server/tests/unit/bin-lemon-internal-token.test.js b/server/tests/unit/bin-lemon-internal-token.test.js new file mode 100644 index 0000000..f29a4c3 --- /dev/null +++ b/server/tests/unit/bin-lemon-internal-token.test.js @@ -0,0 +1,51 @@ +/** + * bin/lemon.js — commit b7b5096(全站鉴权)之后,agent-chat.js 挂了 requireAuthOrInternal, + * 这个 CLI 跑在 agent 自己的 claude CLI 子进程里,没有登录 cookie,只能靠父进程通过 + * process.env 传下来的 LEMON_INTERNAL_TOKEN 过 internal 校验(server/middleware/jwt-auth.js)。 + * + * 用一个假的 HTTP 服务器代替真实后端,只验证 bin/lemon.js 发请求时带没带这个 header。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'http'; +import { execFile } from 'child_process'; +import path from 'path'; + +const LEMON_BIN = path.resolve(__dirname, '../../../bin/lemon.js'); + +let server, port, lastHeaders; + +function runLemon(args, env) { + return new Promise((resolve) => { + execFile('node', [LEMON_BIN, ...args], { env }, () => resolve()); + }); +} + +describe('bin/lemon.js — LEMON_INTERNAL_TOKEN 透传', () => { + beforeAll(async () => { + server = http.createServer((req, res) => { + lastHeaders = req.headers; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + // 用随机端口,避免跟本机真实跑着的 3010 dev server 撞车(LEMON_API_BASE 让 bin/lemon.js 指过来) + await new Promise((resolve) => server.listen(0, resolve)); + port = server.address().port; + }); + + afterAll(() => new Promise((resolve) => server.close(resolve))); + + it('设了 LEMON_INTERNAL_TOKEN 时,请求头带上 x-internal-token', async () => { + await runLemon( + ['task', 'report', '--taskId', 't1', '--from', 'a', '--to', 'b'], + { ...process.env, LEMON_API_BASE: `http://localhost:${port}/api`, LEMON_INTERNAL_TOKEN: 'test-token-abc' } + ); + expect(lastHeaders['x-internal-token']).toBe('test-token-abc'); + }); + + it('没设 LEMON_INTERNAL_TOKEN 时,不带这个 header(不伪造身份)', async () => { + const env = { ...process.env, LEMON_API_BASE: `http://localhost:${port}/api` }; + delete env.LEMON_INTERNAL_TOKEN; + await runLemon(['task', 'report', '--taskId', 't1', '--from', 'a', '--to', 'b'], env); + expect(lastHeaders['x-internal-token']).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/jwt-auth.test.js b/server/tests/unit/jwt-auth.test.js index ec67a53..4682a78 100644 --- a/server/tests/unit/jwt-auth.test.js +++ b/server/tests/unit/jwt-auth.test.js @@ -40,4 +40,13 @@ describe('jwt-auth', () => { expect(jwtAuth.extractUserFromToken(null)).toBeNull(); }); }); + + // bin/lemon.js(agent 用来 task report/dispatch 自我汇报的 CLI)跑在 claude CLI 子进程 + // 里,不跟主进程共享内存,只能靠 process.env 拿到 INTERNAL_TOKEN——commit b7b5096 之后 + // 漏了这条路径,导致所有 agent 的 task report/dispatch 全部 401。 + describe('INTERNAL_TOKEN 透传给子进程(bin/lemon.js 依赖这个)', () => { + it('require 本模块时应把 INTERNAL_TOKEN 写进 process.env.LEMON_INTERNAL_TOKEN', () => { + expect(process.env.LEMON_INTERNAL_TOKEN).toBe(jwtAuth.INTERNAL_TOKEN); + }); + }); });