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

448 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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开启常驻与告警监控。