claude-code-remote-remake/relay-injection.md

448 lines
14 KiB
Markdown
Raw Normal View History

# TaskPing — 邮件回复写回 Claude Code CLI执行手册
> 目标读者:能执行脚本的 AI/自动化代理
> 运行环境Linux/macOS建议Node.js ≥ 18Claude Code 已安装并可在终端运行 `claude`
---
## 1. 背景与目标
**项目背景**:用户在本机通过 Claude CodeCLI进行编码。当前已实现
- 通过 hooks 在任务结束/等待下一步时**发出邮件提醒**
- 用户在手机上**回复邮件**
- 本机有一个常驻“中继/Relay”进程轮询收件箱能够拿到该回复。
**当前卡点****把邮件正文写回到对应的 Claude Code CLI 会话,并回车执行**。
**本文档目的**:为 AI/自动化代理提供两种可执行的落地方案:
1) **子进程/PTY 模式**:用 `node-pty` 驱动一个 Claude Code 子进程,直接向伪终端写入并回车;
2) **tmux 模式**:让 Claude Code 运行在指定 `tmux` pane收到邮件后用 `tmux send-keys` 注入文本并回车。
两种方案都要求:
- 能**路由**到正确的 Claude 会话;
- 能**清洗**邮件正文,仅保留要注入的命令;
- 有**安全**与**幂等**控制;
- 有**可观测性**(日志/Tracing
---
## 2. 全局约束与安全要求
- **会话识别**:优先按 `In-Reply-To``Message-ID` 匹配;回退使用 `Subject` 中的 token形如 `[TaskPing #ABC123]`)。
- **白名单**:仅允许来自配置的发件人域/邮箱(通过 SPF/DKIM/DMARC 验证后再放行)。
- **正文提取**:只取**最新回复**去除历史引用、签名、HTML 标签、图片占位。支持三种输入:
- 纯文本一行命令;
- 代码块 ``` 包起来的命令(优先级最高);
- 主题/首行以 `CMD:` 前缀标识。
- **幂等**:用 `Message-ID``gmailThreadId` 去重(重复投递不再次注入)。
- **限流**:同一会话每分钟最多 1 条;单条长度上限(例如 8KB
- **日志**:记录 token、会话、pane/pty id、注入摘要前 120 字符),屏蔽隐私。
---
## 3. 公共依赖与配置
### 3.1 依赖Node.js
```bash
npm i imapflow mailparser node-pty pino dotenv execa
# tmux 方案需要系统安装 tmuxmacOS: brew install tmuxDebian/Ubuntu: apt-get install tmux
```
### 3.2 环境变量(`.env`
```bash
# 邮件接收
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_SECURE=true
IMAP_USER=bot@example.com
IMAP_PASS=********
# 邮件路由安全
ALLOWED_SENDERS=jessy@example.com,panda@company.com
# 路由与会话存储JSON 文件路径)
SESSION_MAP_PATH=/var/lib/taskping/session-map.json
# 模式选择pty 或 tmux
INJECTION_MODE=pty
# tmux 方案可选:默认 session/pane 名称前缀
TMUX_SESSION_PREFIX=taskping
```
### 3.3 会话映射(`SESSION_MAP_PATH`
结构示例:
```json
{
"ABC123": {
"type": "pty",
"ptyId": "b4c1...",
"createdAt": 1732672100,
"expiresAt": 1732679300,
"messageId": "<CA+abc@mail.example.com>",
"imapUid": 12345
},
"XYZ789": {
"type": "tmux",
"session": "taskping-XYZ789",
"pane": "taskping-XYZ789.0",
"createdAt": 1732672200,
"expiresAt": 1732679400,
"messageId": "<CA+xyz@mail.example.com>"
}
}
```
> 注:会话映射由**发送提醒时**创建(包含 token 与目标 CLI 会话信息)。
---
## 4. 方案一:子进程/PTY 模式(推荐)
### 4.1 适用场景
- 由中继程序**直接管理** Claude Code 生命周期;
- 需要最稳定的注入通道(不依赖额外终端复用器)。
### 4.2 实现思路
-`node-pty` `spawn('claude', [...])` 启动 CLI
- 将返回的 `pty` 与生成的 token 绑定,写入 `SESSION_MAP_PATH`
- 收到邮件 → 路由 token → 清洗正文 → `pty.write(cmd + '\r')`
- 监控 `pty.onData`,必要时截取摘要回传提醒(可选)。
### 4.3 关键代码骨架(`relay-pty.js`
```js
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import pino from 'pino';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { spawn as spawnPty } from 'node-pty';
import dotenv from 'dotenv';
dotenv.config();
const log = pino({ level: 'info' });
const SESS_PATH = process.env.SESSION_MAP_PATH;
function loadSessions() {
if (!existsSync(SESS_PATH)) return {};
return JSON.parse(readFileSync(SESS_PATH, 'utf8'));
}
function saveSessions(map) {
writeFileSync(SESS_PATH, JSON.stringify(map, null, 2));
}
function normalizeAllowlist() {
return (process.env.ALLOWED_SENDERS || '')
.split(',')
.map(s => s.trim().toLowerCase())
.filter(Boolean);
}
const ALLOW = new Set(normalizeAllowlist());
function isAllowed(addressObj) {
const list = []
.concat(addressObj?.value || [])
.map(a => (a.address || '').toLowerCase());
return list.some(addr => ALLOW.has(addr));
}
function extractTokenFromSubject(subject = '') {
const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/);
return m ? m[1] : null;
}
function stripReply(text = '') {
// 优先识别 ``` 块
const codeBlock = text.match(/```[\s\S]*?```/);
if (codeBlock) {
return codeBlock[0].replace(/```/g, '').trim();
}
// 取首行或以 CMD: 前缀
const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i);
const candidate = (cmdPrefix ? cmdPrefix[1] : firstLine).trim();
// 去除引用与签名
return candidate
.replace(/^>.*$/gm, '')
.replace(/^--\s+[\s\S]*$/m, '')
.slice(0, 8192) // 长度限制
.trim();
}
async function handleMailMessage(source) {
const parsed = await simpleParser(source);
if (!isAllowed(parsed.from)) {
log.warn({ from: parsed.from?.text }, 'sender not allowed');
return;
}
const subject = parsed.subject || '';
const token = extractTokenFromSubject(subject);
if (!token) {
log.warn({ subject }, 'no token in subject');
return;
}
const sessions = loadSessions();
const sess = sessions[token];
if (!sess || sess.expiresAt * 1000 < Date.now()) {
log.warn({ token }, 'session not found or expired');
return;
}
if (sess.type !== 'pty' || !sess.ptyId) {
log.error({ token }, 'session is not pty type');
return;
}
const cmd = stripReply(parsed.text || parsed.html || '');
if (!cmd) {
log.warn({ token }, 'empty command after strip');
return;
}
// 取得 pty 实例:这里演示为“按需重建/保持单例”两种策略之一
const pty = getOrRestorePty(sess);
log.info({ token, cmd: cmd.slice(0, 120) }, 'inject command');
pty.write(cmd + '\r');
}
const PTY_POOL = new Map();
function getOrRestorePty(sess) {
if (PTY_POOL.has(sess.ptyId)) return PTY_POOL.get(sess.ptyId);
// 如果需要重建,会话应当保存启动参数;这里演示简化为新起一个 claude
const shell = 'claude'; // 或绝对路径
const pty = spawnPty(shell, [], {
name: 'xterm-color',
cols: 120,
rows: 32
});
PTY_POOL.set(sess.ptyId, pty);
pty.onData(d => process.stdout.write(d)); // 可替换为日志/回传
pty.onExit(() => PTY_POOL.delete(sess.ptyId));
return pty;
}
async function startImap() {
const client = new ImapFlow({
host: process.env.IMAP_HOST,
port: Number(process.env.IMAP_PORT || 993),
secure: process.env.IMAP_SECURE === 'true',
auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }
});
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
for await (const msg of client.monitor()) {
if (msg.type === 'exists') {
const { uid } = msg;
const { source } = await client.download('INBOX', uid);
const chunks = [];
for await (const c of source) chunks.push(c);
await handleMailMessage(Buffer.concat(chunks));
}
}
} finally {
lock.release();
}
}
if (process.env.INJECTION_MODE === 'pty') {
startImap().catch(err => {
console.error(err);
process.exit(1);
});
}
```
> 说明:生产化时,建议在**发送提醒**一侧创建会话并持久化 `ptyId` 与参数;这里演示了“按需复原”的简化路径。
### 4.4 启动
```bash
node relay-pty.js
```
### 4.5 验收清单
- [ ] 未授权邮箱无法触发注入;
- [ ] 合法邮件写入后Claude Code 能立即进入下一步;
- [ ] 重复转发的同一邮件不会二次注入;
- [ ] 过期会话拒绝注入;
- [ ] 大段/HTML/带签名的邮件能正确抽取命令。
---
## 5. 方案二tmux 模式(简单稳妥)
### 5.1 适用场景
- 你已经在 `tmux` 里运行 Claude Code
- 希望由外部进程向**指定 pane** 注入按键,不改变现有启动流程。
### 5.2 实现思路
- 为每个 token 创建/记录一个 `tmux` session 与 pane`session = taskping-<token>`
- Claude Code 在该 pane 里运行;
- 收到邮件 → 定位到 pane → `tmux send-keys -t <pane> "<cmd>" Enter`
### 5.3 管理脚本(`tmux-utils.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
SESSION="$1" # 例如 taskping-ABC123
CMD="${2:-}" # 可选:一次性注入命令
if ! tmux has-session -t "${SESSION}" 2>/dev/null; then
tmux new-session -d -s "${SESSION}"
tmux rename-window -t "${SESSION}:0" "claude"
tmux send-keys -t "${SESSION}:0" "claude" C-m
sleep 0.5
fi
if [ -n "${CMD}" ]; then
tmux send-keys -t "${SESSION}:0" "${CMD}" C-m
fi
# 输出 pane 目标名,供上层程序记录
echo "${SESSION}.0"
```
### 5.4 注入程序(`relay-tmux.js`
```js
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import pino from 'pino';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import dotenv from 'dotenv';
import { execa } from 'execa';
dotenv.config();
const log = pino({ level: 'info' });
const SESS_PATH = process.env.SESSION_MAP_PATH;
function loadSessions() {
if (!existsSync(SESS_PATH)) return {};
return JSON.parse(readFileSync(SESS_PATH, 'utf8'));
}
function extractTokenFromSubject(subject = '') {
const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/);
return m ? m[1] : null;
}
function stripReply(text = '') {
const codeBlock = text.match(/```[\s\S]*?```/);
if (codeBlock) return codeBlock[0].replace(/```/g, '').trim();
const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i);
return (cmdPrefix ? cmdPrefix[1] : firstLine).trim().slice(0, 8192);
}
async function injectToTmux(sessionName, cmd) {
const utils = new URL('./tmux-utils.sh', import.meta.url).pathname;
const { stdout } = await execa('bash', [utils, sessionName, cmd], { stdio: 'pipe' });
return stdout.trim(); // 返回 pane 目标名
}
async function startImap() {
const client = new ImapFlow({
host: process.env.IMAP_HOST,
port: Number(process.env.IMAP_PORT || 993),
secure: process.env.IMAP_SECURE === 'true',
auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }
});
await client.connect();
await client.mailboxOpen('INBOX');
for await (const msg of client.monitor()) {
if (msg.type !== 'exists') continue;
const { uid } = msg;
const { source } = await client.download('INBOX', uid);
const chunks = [];
for await (const c of source) chunks.push(c);
const parsed = await simpleParser(Buffer.concat(chunks));
const token = extractTokenFromSubject(parsed.subject || '');
if (!token) continue;
const cmd = stripReply(parsed.text || parsed.html || '');
if (!cmd) continue;
const sessionName = `${process.env.TMUX_SESSION_PREFIX || 'taskping'}-${token}`;
const pane = await injectToTmux(sessionName, cmd);
console.log(`[inject] ${sessionName} -> ${pane} :: ${cmd.slice(0, 120)}`);
}
}
if (process.env.INJECTION_MODE === 'tmux') {
startImap().catch(err => {
console.error(err);
process.exit(1);
});
}
```
### 5.5 启动
```bash
chmod +x tmux-utils.sh
INJECTION_MODE=tmux node relay-tmux.js
```
### 5.6 验收清单
- [ ] `tmux` 中能看到 Claude 进程始终在运行;
- [ ] 邮件回复注入后,当前 pane 光标位置正确、命令无截断;
- [ ] 会话名与 token 一一对应,过期会话自动清理;
- [ ] `tmux` 重启或 SSH 断连后自动恢复。
---
## 6. 选择建议与权衡
| 维度 | 子进程/PTY | tmux |
|---|---|---|
| 复杂度 | 中 | 低 |
| 稳定性 | 高(直连伪终端) | 高(依赖 tmux 自愈) |
| 多会话并发 | 简单(多 PTY | 简单(多 pane/session |
| 恢复/重连 | 需自管 | `tmux` 自带 |
| 可移植性 | 受 pty/Windows 影响 | Linux/macOS 友好 |
| 外部可观测 | 需自行实现 | `tmux capture-pane` 可用 |
**建议**:桌面或服务器长期常驻,优先 **PTY 模式**;已广泛使用 `tmux` 的团队,直接 **tmux 模式**
---
## 7. 测试用例AI 执行)
1. **路由正确性**:构造两个 tokenA、B分别回复不同命令验证注入不串台。
2. **HTML 邮件**:从 iOS/Android 客户端发送富文本,确认能抽取纯文本命令。
3. **代码块优先**:正文包含代码块和普通文本时,优先选用代码块内容。
4. **幂等**:同一封邮件重复投递三次,仅执行一次。
5. **过期处理**:将 `expiresAt` 回退到过去,验证拒绝注入。
6. **白名单**:非授权邮箱发送,日志报警且不注入。
7. **超长命令**:超过 8KB 截断并拒绝,返回提示。
---
## 8. 运行与守护
- 建议用 `pm2`/`systemd` 管理 `relay-pty.js``relay-tmux.js`
- 每 20 分钟向 IMAP 发送 NOOPImapFlow monitor 已处理),断线自动重连;
- 定时清理过期会话与孤儿 `tmux` session / PTY。
---
## 9. 故障排查
- **注入后无响应**检查会话路由是否匹配token、pane/pty确认 Claude 是否在**等待输入**的状态。
- **乱码/截断**:调大 pty 列数tmux 保证 pane 有焦点且无行编辑冲突。
- **重复执行**:确认以 `Message-ID` 去重;避免多客户端造成“已读后又转发”。
- **权限问题**`tmux` 由同一用户启动Node 进程需与 `tmux` 同一用户。
---
## 10. 清单AI 请按序执行)
1. 安装依赖与 `tmux`(如需)。
2. 写入 `.env`IMAP/白名单/模式)。
3. 准备并加载 `SESSION_MAP_PATH`(由发送提醒侧写入)。
4. 选择 **INJECTION_MODE=pty****tmux**,启动对应 `relay-*.js`
5. 发送带 `[TaskPing #TOKEN]` 的提醒邮件,收到后**回复命令**进行回归测试。
6. 通过测试清单后,接入 hooks开启常驻与告警监控。