@yvhitxcel/opencode-remote 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -1
- package/dist/cli.js +120 -9
- package/dist/core/adapter.js +12 -0
- package/dist/core/agent-registry.js +77 -0
- package/dist/core/crypto.js +80 -0
- package/dist/core/git-push.js +38 -15
- package/dist/core/handler.js +293 -0
- package/dist/core/log.js +92 -0
- package/dist/core/lru.js +98 -0
- package/dist/core/qiniu.js +2 -2
- package/dist/core/retry.js +46 -0
- package/dist/core/router.js +18 -6
- package/dist/core/state.js +190 -0
- package/dist/core/stats.js +115 -0
- package/dist/feishu/adapter.js +0 -1
- package/dist/feishu/bot.js +4 -2
- package/dist/feishu/commands.js +2 -2
- package/dist/feishu/handler.js +8 -177
- package/dist/opencode/client.js +78 -56
- package/dist/patch_spawn.js +1 -0
- package/dist/plugins/agents/claude-code/index.js +59 -51
- package/dist/plugins/agents/codex/index.js +32 -6
- package/dist/plugins/agents/copilot/index.js +32 -6
- package/dist/plugins/agents/opencode/index.js +36 -14
- package/dist/telegram/adapter.js +19 -3
- package/dist/weixin/adapter.js +37 -15
- package/dist/weixin/api.js +38 -17
- package/dist/weixin/bot.js +58 -23
- package/dist/weixin/commands.js +134 -8
- package/dist/weixin/handler.js +12 -274
- package/dist/weixin/user-adapter-map.js +11 -0
- package/package.json +5 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenCode Remote Control Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -156,6 +156,59 @@ A: 需要一台电脑运行 bot,手机上通过 IM 控制。OpenCode 也运行
|
|
|
156
156
|
**Q: 如何更新?**
|
|
157
157
|
A: `npm update -g @yvhitxcel/opencode-remote`
|
|
158
158
|
|
|
159
|
+
## 全局命令
|
|
160
|
+
|
|
161
|
+
安装后使用 `opencode-remote` 命令(不是 `remote-control`)。
|
|
162
|
+
|
|
163
|
+
### 命令原理
|
|
164
|
+
|
|
165
|
+
`package.json` 的 `bin` 字段声明了全局命令:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
"bin": {
|
|
169
|
+
"opencode-remote": "bin/opencode-remote.js",
|
|
170
|
+
"opencode-weixin": "bin/opencode-weixin.js"
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`npm install -g` 时,npm 在全局目录(Windows: `%APPDATA%\npm\`)创建三个包装脚本:
|
|
175
|
+
|
|
176
|
+
| 文件 | 作用 |
|
|
177
|
+
|------|------|
|
|
178
|
+
| `opencode-remote.cmd` | CMD 入口 |
|
|
179
|
+
| `opencode-remote.ps1` | PowerShell 入口 |
|
|
180
|
+
| `opencode-remote` | Unix Shell 入口 |
|
|
181
|
+
|
|
182
|
+
包装脚本调用 `node <pkg>/dist/cli.js <args>`。`cli.js` 内置了自动重启机制:
|
|
183
|
+
- 首次启动以父进程模式运行,`spawn` 子进程执行实际逻辑
|
|
184
|
+
- 子进程退出码 `200` 时父进程自动重启
|
|
185
|
+
|
|
186
|
+
### 多 Bot 实例
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
opencode-remote weixin # 启动微信
|
|
190
|
+
opencode-remote weixin --id bot1 # 多账号:第一个
|
|
191
|
+
opencode-remote weixin --id bot2 # 多账号:第二个
|
|
192
|
+
opencode-remote telegram # 启动 Telegram
|
|
193
|
+
opencode-remote feishu # 启动飞书
|
|
194
|
+
opencode-remote # 启动所有已配置的 Bot
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 别名
|
|
198
|
+
|
|
199
|
+
如果习惯 `remote-control` 这个命令名,创建别名文件:
|
|
200
|
+
|
|
201
|
+
**PowerShell** (`$PROFILE`):
|
|
202
|
+
```powershell
|
|
203
|
+
Set-Alias -Name remote-control -Value opencode-remote
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**CMD** (`remote-control.cmd`):
|
|
207
|
+
```bat
|
|
208
|
+
@echo off
|
|
209
|
+
opencode-remote %*
|
|
210
|
+
```
|
|
211
|
+
|
|
159
212
|
## 系统要求
|
|
160
213
|
|
|
161
214
|
- Node.js >= 18.0.0
|
|
@@ -164,6 +217,22 @@ A: `npm update -g @yvhitxcel/opencode-remote`
|
|
|
164
217
|
|
|
165
218
|
本项目基于 [opencode-remote-control](https://github.com/ceociocto/opencode-remote-control) 开发。
|
|
166
219
|
|
|
220
|
+
## 文档
|
|
221
|
+
|
|
222
|
+
- [架构总览](docs/ARCHITECTURE.md) — 进程模型、模块图、消息流、错误传播理念
|
|
223
|
+
- [故障排查](docs/TROUBLESHOOTING.md) — 常见问题诊断与解决
|
|
224
|
+
- [配置说明](docs/CONFIG.md) — 环境变量、路径、超时矩阵
|
|
225
|
+
- [错误处理规范](docs/ERROR_HANDLING.md) — 错误处理契约,新代码必须遵循
|
|
226
|
+
- [快速开始](QUICKSTART.md) — 入门指南
|
|
227
|
+
|
|
228
|
+
## 质量保障
|
|
229
|
+
|
|
230
|
+
- ✅ TypeScript 类型检查 (`npm run typecheck`)
|
|
231
|
+
- ✅ 32 项自动化测试 (`npm test`)
|
|
232
|
+
- ✅ 多平台 CI(GitHub Actions: Ubuntu + Windows × Node 20/22)
|
|
233
|
+
- ✅ 容器化部署 (`docker compose up`)
|
|
234
|
+
- ✅ MIT 许可证
|
|
235
|
+
|
|
167
236
|
## 许可证
|
|
168
237
|
|
|
169
|
-
MIT License
|
|
238
|
+
MIT License — 详见 [LICENSE](LICENSE) 文件
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// OpenCode Remote Control - CLI entry point
|
|
3
|
-
|
|
4
|
-
import {
|
|
3
|
+
// @ts-nocheck — process.env spread has type issues with strict @types/node
|
|
4
|
+
import { watch, existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
5
7
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { spawn } from 'child_process';
|
|
8
|
+
import { spawn, execSync } from 'child_process';
|
|
9
|
+
import { createServer } from 'http';
|
|
7
10
|
import { setGlobalProxy } from './opencode/client.js';
|
|
8
11
|
import { printBanner, VERSION, runConfig, runConfigTimeout } from './core/setup.js';
|
|
9
12
|
import { runStart, runTelegramOnly, runFeishuOnly, runWeixinOnly, runAgentsCommand } from './bot-runner.js';
|
|
@@ -76,11 +79,79 @@ Examples:
|
|
|
76
79
|
// Main CLI
|
|
77
80
|
// 父进程管理:如果不是子进程,则启动父进程模式
|
|
78
81
|
if (process.env.OPENCODE_CHILD !== '1') {
|
|
82
|
+
process.on('unhandledRejection', (reason) => { console.error('[parent] Unhandled Rejection:', reason); });
|
|
83
|
+
|
|
84
|
+
// PID 文件锁:确保只有一个父进程实例
|
|
85
|
+
const PID_FILE = join(homedir(), '.opencode-remote', 'parent.pid');
|
|
86
|
+
try {
|
|
87
|
+
if (existsSync(PID_FILE)) {
|
|
88
|
+
const oldPid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
89
|
+
if (oldPid && oldPid !== process.pid) {
|
|
90
|
+
try { process.kill(oldPid, 'SIGTERM'); } catch { console.debug('[pid] old process already dead'); }
|
|
91
|
+
try { execSync(`taskkill /F /T /PID ${oldPid}`, { timeout: 2000 }); } catch { console.debug('[pid] taskkill failed'); }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (e) { console.debug('[pid] Failed to read PID file:', e.message); }
|
|
95
|
+
try { writeFileSync(PID_FILE, String(process.pid), 'utf8'); } catch (e) { console.debug('[pid] Failed to write PID file:', e.message); }
|
|
96
|
+
process.on('exit', () => { try { unlinkSync(PID_FILE); } catch {} });
|
|
97
|
+
|
|
98
|
+
// Health check HTTP server (for Docker healthcheck / monitoring)
|
|
99
|
+
function startHealthServer() {
|
|
100
|
+
const port = parseInt(process.env.HEALTH_PORT || '9090', 10);
|
|
101
|
+
if (isNaN(port) || port < 1 || port > 65535) return;
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
if (req.url !== '/health' && req.url !== '/') {
|
|
104
|
+
res.writeHead(404); res.end('Not found');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const childAlive = childProc && childProc.exitCode === null && childProc.killed === false;
|
|
108
|
+
const hbOk = Date.now() - lastHeartbeatTs < 120_000;
|
|
109
|
+
const healthy = childAlive && hbOk && !shuttingDown;
|
|
110
|
+
const status = healthy ? 200 : 503;
|
|
111
|
+
const body = JSON.stringify({
|
|
112
|
+
status: healthy ? 'ok' : 'degraded',
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
uptime: process.uptime(),
|
|
115
|
+
childAlive,
|
|
116
|
+
lastHeartbeatAgo: Date.now() - lastHeartbeatTs,
|
|
117
|
+
shuttingDown,
|
|
118
|
+
});
|
|
119
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(body);
|
|
121
|
+
});
|
|
122
|
+
server.listen(port, '127.0.0.1', () => {
|
|
123
|
+
console.log(`[health] HTTP health endpoint at http://127.0.0.1:${port}/health`);
|
|
124
|
+
});
|
|
125
|
+
server.unref();
|
|
126
|
+
}
|
|
127
|
+
|
|
79
128
|
let childProc = null;
|
|
80
129
|
let shuttingDown = false;
|
|
81
130
|
let isRestart = false;
|
|
131
|
+
let crashCount = 0;
|
|
132
|
+
let lastCrashTs = 0;
|
|
133
|
+
let lastSpawnTs = 0;
|
|
134
|
+
let lastHeartbeatTs = Date.now();
|
|
135
|
+
let heartbeatCheckTimer = null;
|
|
136
|
+
|
|
137
|
+
function cleanupPorts() {
|
|
138
|
+
for (const port of [4096, 4097, 4098]) {
|
|
139
|
+
try {
|
|
140
|
+
const out = execSync(`netstat -ano | findstr ":${port} "`, { timeout: 3000 });
|
|
141
|
+
for (const line of out.toString().trim().split('\n')) {
|
|
142
|
+
const parts = line.trim().split(/\s+/);
|
|
143
|
+
const pid = parts[parts.length - 1];
|
|
144
|
+
if (pid && pid !== '0') {
|
|
145
|
+
try { execSync(`taskkill /F /PID ${pid}`, { timeout: 2000 }); } catch { console.debug('[cleanup] kill failed (already dead?)', pid); }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch { console.debug('[cleanup] no process on port', port); }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
82
151
|
|
|
83
152
|
const spawnChild = (fromRestart = false) => {
|
|
153
|
+
cleanupPorts();
|
|
154
|
+
lastSpawnTs = Date.now();
|
|
84
155
|
if (shuttingDown) return;
|
|
85
156
|
if (childProc) {
|
|
86
157
|
try { childProc.kill('SIGTERM'); } catch {}
|
|
@@ -101,30 +172,68 @@ if (process.env.OPENCODE_CHILD !== '1') {
|
|
|
101
172
|
childProc.stderr.on('data', (d) => process.stderr.write(d));
|
|
102
173
|
|
|
103
174
|
childProc.on('close', (code) => {
|
|
104
|
-
|
|
175
|
+
const wasSignal = code === null;
|
|
176
|
+
console.log(`[parent] Child process closed with code: ${code}${wasSignal ? ' (signal)' : ''}`);
|
|
105
177
|
if (shuttingDown) {
|
|
106
178
|
console.log('[parent] Shutting down, not restarting');
|
|
107
179
|
return;
|
|
108
180
|
}
|
|
109
|
-
|
|
110
|
-
|
|
181
|
+
|
|
182
|
+
// 区分:200=主动重启请求, null=信号杀, 其他=异常退出
|
|
183
|
+
if (code === 200) {
|
|
184
|
+
// 主动 /restart
|
|
111
185
|
isRestart = true;
|
|
112
186
|
setTimeout(() => spawnChild(true), 1000);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 崩溃检测: 60 秒内连续多次崩溃 → 不再重启 (避免崩循环)
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastCrashTs < 60_000) {
|
|
193
|
+
crashCount++;
|
|
113
194
|
} else {
|
|
114
|
-
|
|
195
|
+
crashCount = 1;
|
|
196
|
+
}
|
|
197
|
+
lastCrashTs = now;
|
|
198
|
+
|
|
199
|
+
if (crashCount >= 5) {
|
|
200
|
+
console.error(`[parent] ${crashCount} crashes in 60s, giving up. Manual restart required.`);
|
|
201
|
+
process.exit(1);
|
|
115
202
|
}
|
|
203
|
+
|
|
204
|
+
// 退避重启: 1s, 2s, 4s, 8s
|
|
205
|
+
const backoff = Math.min(8000, 1000 * Math.pow(2, crashCount - 1));
|
|
206
|
+
console.log(`[parent] Bot ${wasSignal ? 'killed by signal' : `crashed (code ${code})`}, restarting in ${backoff}ms (crash #${crashCount})`);
|
|
207
|
+
isRestart = true;
|
|
208
|
+
setTimeout(() => spawnChild(true), backoff);
|
|
116
209
|
});
|
|
117
210
|
|
|
118
211
|
childProc.on('error', (err) => {
|
|
119
212
|
console.error('[parent] Child error:', err.message);
|
|
120
213
|
});
|
|
214
|
+
|
|
215
|
+
// IPC 心跳:子进程每 30s 发心跳,超过 120s 无心跳视为卡死
|
|
216
|
+
childProc.on('message', (msg) => {
|
|
217
|
+
if (msg?.type === 'heartbeat') lastHeartbeatTs = Date.now();
|
|
218
|
+
});
|
|
219
|
+
lastHeartbeatTs = Date.now();
|
|
220
|
+
clearInterval(heartbeatCheckTimer);
|
|
221
|
+
heartbeatCheckTimer = setInterval(() => {
|
|
222
|
+
if (shuttingDown) return;
|
|
223
|
+
if (Date.now() - lastHeartbeatTs > 120_000) {
|
|
224
|
+
console.error('[parent] No heartbeat for 120s, killing stuck child...');
|
|
225
|
+
isRestart = true;
|
|
226
|
+
try { childProc.kill('SIGKILL'); } catch {}
|
|
227
|
+
}
|
|
228
|
+
}, 30_000);
|
|
229
|
+
if (heartbeatCheckTimer.unref) heartbeatCheckTimer.unref();
|
|
121
230
|
};
|
|
122
231
|
|
|
123
|
-
// 文件监控 - 代码变化时自动重启
|
|
232
|
+
// 文件监控 - 代码变化时自动重启 (spawn 后 3s 静默, 避免重启循环)
|
|
124
233
|
const distDir = __dirname;
|
|
125
234
|
let debounceTimer = null;
|
|
126
235
|
watch(distDir, { recursive: true }, (eventType, filename) => {
|
|
127
|
-
if (filename && filename.endsWith('.js') && !shuttingDown) {
|
|
236
|
+
if (filename && filename.endsWith('.js') && !shuttingDown && Date.now() - lastSpawnTs > 3000) {
|
|
128
237
|
clearTimeout(debounceTimer);
|
|
129
238
|
debounceTimer = setTimeout(() => {
|
|
130
239
|
console.log(`\n📝 ${filename} changed, restarting...`);
|
|
@@ -136,6 +245,8 @@ if (process.env.OPENCODE_CHILD !== '1') {
|
|
|
136
245
|
}
|
|
137
246
|
});
|
|
138
247
|
|
|
248
|
+
startHealthServer();
|
|
249
|
+
|
|
139
250
|
process.on('SIGINT', () => {
|
|
140
251
|
if (shuttingDown) return;
|
|
141
252
|
shuttingDown = true;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// BotAdapter interface — all platform adapters must implement these methods
|
|
2
|
+
// reply(threadId, text) — send a text message to user
|
|
3
|
+
// sendTypingIndicator(threadId) — show typing indicator (start)
|
|
4
|
+
// sendTypingEnd?(threadId) — stop typing indicator [optional]
|
|
5
|
+
// updateMessage?(threadId, msgId, text) — edit a message [optional]
|
|
6
|
+
// deleteMessage?(threadId, msgId) — delete a message [optional]
|
|
7
|
+
// platform — string identifier ('weixin'|'feishu'|'telegram')
|
|
8
|
+
|
|
9
|
+
export {};
|
|
10
|
+
|
|
11
|
+
// This module only exists as the contract reference.
|
|
12
|
+
// Each platform adapter (weixin/adapter.js etc.) independently satisfies this shape.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Global registry of running agent/CLI processes
|
|
2
|
+
// /esc uses this to kill the actual subprocess, not just abort the SDK session
|
|
3
|
+
import { logger } from './log.js';
|
|
4
|
+
|
|
5
|
+
const _registry = new Map(); // threadId -> { process, agentName, killed, killTimer }
|
|
6
|
+
|
|
7
|
+
export function registerAgentProcess(threadId, proc, agentName) {
|
|
8
|
+
// 若已存在先杀掉旧的
|
|
9
|
+
const old = _registry.get(threadId);
|
|
10
|
+
if (old && !old.killed) {
|
|
11
|
+
try { old.process.kill('SIGKILL'); } catch {}
|
|
12
|
+
}
|
|
13
|
+
_registry.set(threadId, { process: proc, agentName, killed: false, killTimer: null });
|
|
14
|
+
logger.info('agent-process:registered', { threadId, agentName, pid: proc.pid });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unregisterAgentProcess(threadId) {
|
|
18
|
+
const e = _registry.get(threadId);
|
|
19
|
+
if (!e) return;
|
|
20
|
+
if (e.killTimer) clearTimeout(e.killTimer);
|
|
21
|
+
_registry.delete(threadId);
|
|
22
|
+
logger.info('agent-process:unregistered', { threadId });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kill the running agent process for a thread.
|
|
27
|
+
* @param {string} threadId
|
|
28
|
+
* @param {number} forceAfterMs - if process doesn't die, SIGKILL after this many ms
|
|
29
|
+
* @returns {{ killed: boolean, agentName: string|null }}
|
|
30
|
+
*/
|
|
31
|
+
export function killAgentProcess(threadId, forceAfterMs = 3000) {
|
|
32
|
+
const e = _registry.get(threadId);
|
|
33
|
+
if (!e) return { killed: false, agentName: null };
|
|
34
|
+
if (e.killed) return { killed: true, agentName: e.agentName };
|
|
35
|
+
e.killed = true;
|
|
36
|
+
try {
|
|
37
|
+
e.process.kill('SIGTERM');
|
|
38
|
+
logger.warn('agent-process:sigterm', { threadId, agentName: e.agentName, pid: e.process.pid });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
logger.error('agent-process:sigterm-failed', { threadId, error: err.message });
|
|
41
|
+
}
|
|
42
|
+
e.killTimer = setTimeout(() => {
|
|
43
|
+
try {
|
|
44
|
+
e.process.kill('SIGKILL');
|
|
45
|
+
logger.warn('agent-process:sigkill', { threadId, agentName: e.agentName });
|
|
46
|
+
} catch {}
|
|
47
|
+
}, forceAfterMs);
|
|
48
|
+
return { killed: true, agentName: e.agentName };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getAgentProcess(threadId) {
|
|
52
|
+
return _registry.get(threadId) || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listAgentProcesses() {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [tid, e] of _registry.entries()) {
|
|
58
|
+
out.push({ threadId: tid, agentName: e.agentName, pid: e.process.pid, killed: e.killed });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Kill ALL registered agent processes (used during graceful shutdown).
|
|
65
|
+
* Sends SIGTERM first, escalates to SIGKILL after 1s to avoid hanging on stuck children.
|
|
66
|
+
* @param {number} forceAfterMs
|
|
67
|
+
* @returns {Array<{ threadId: string, agentName: string, killed: boolean }>}
|
|
68
|
+
*/
|
|
69
|
+
export function killAllAgentProcesses(forceAfterMs = 1000) {
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const [tid, e] of _registry.entries()) {
|
|
72
|
+
if (e.killed) continue;
|
|
73
|
+
const r = killAgentProcess(tid, forceAfterMs);
|
|
74
|
+
results.push({ threadId: tid, agentName: r.agentName || e.agentName, killed: r.killed });
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// AES-256-GCM credential encryption
|
|
2
|
+
// Key derived from machine fingerprint + user-level salt
|
|
3
|
+
// Format: {iv, tag, data} all base64
|
|
4
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from 'crypto';
|
|
5
|
+
import { hostname, userInfo, homedir, platform } from 'os';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
const SALT_FILE = join(homedir(), '.opencode-remote', '.cred_salt');
|
|
10
|
+
|
|
11
|
+
function getMachineFingerprint() {
|
|
12
|
+
const parts = [
|
|
13
|
+
hostname(),
|
|
14
|
+
userInfo().username,
|
|
15
|
+
platform(),
|
|
16
|
+
homedir(),
|
|
17
|
+
process.env.COMPUTERNAME || '',
|
|
18
|
+
].join('|');
|
|
19
|
+
return createHash('sha256').update(parts).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOrCreateSalt() {
|
|
23
|
+
const dir = join(homedir(), '.opencode-remote');
|
|
24
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
25
|
+
if (existsSync(SALT_FILE)) {
|
|
26
|
+
return readFileSync(SALT_FILE);
|
|
27
|
+
}
|
|
28
|
+
const salt = randomBytes(32);
|
|
29
|
+
writeFileSync(SALT_FILE, salt, { mode: 0o600 });
|
|
30
|
+
return salt;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deriveKey() {
|
|
34
|
+
const fp = getMachineFingerprint();
|
|
35
|
+
const salt = getOrCreateSalt();
|
|
36
|
+
return scryptSync(fp, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encrypt a plain string with AES-256-GCM
|
|
41
|
+
* @param {string} plaintext
|
|
42
|
+
* @returns {string} JSON envelope {iv, tag, data} all base64
|
|
43
|
+
*/
|
|
44
|
+
export function encryptCredential(plaintext) {
|
|
45
|
+
if (plaintext == null) return null;
|
|
46
|
+
const key = deriveKey();
|
|
47
|
+
const iv = randomBytes(12);
|
|
48
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
49
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
50
|
+
const tag = cipher.getAuthTag();
|
|
51
|
+
return JSON.stringify({
|
|
52
|
+
v: 1,
|
|
53
|
+
iv: iv.toString('base64'),
|
|
54
|
+
tag: tag.toString('base64'),
|
|
55
|
+
data: enc.toString('base64'),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decryptCredential(envelope) {
|
|
60
|
+
if (envelope == null) return null;
|
|
61
|
+
// 兼容旧明文 (不是 JSON envelope)
|
|
62
|
+
if (typeof envelope === 'string' && !envelope.startsWith('{')) {
|
|
63
|
+
return envelope;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const obj = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
|
|
67
|
+
if (obj.v !== 1) return null;
|
|
68
|
+
const key = deriveKey();
|
|
69
|
+
const iv = Buffer.from(obj.iv, 'base64');
|
|
70
|
+
const tag = Buffer.from(obj.tag, 'base64');
|
|
71
|
+
const data = Buffer.from(obj.data, 'base64');
|
|
72
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
73
|
+
decipher.setAuthTag(tag);
|
|
74
|
+
const dec = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
75
|
+
return dec.toString('utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error('[crypto] decrypt failed:', e.message);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/core/git-push.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
@@ -55,27 +55,47 @@ function parseOriginUrl(url) {
|
|
|
55
55
|
return { auth, host, userRepo };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
// Whitelist validation for git refs (branch names, remote names). Git itself
|
|
59
|
+
// also validates these, but we add a defensive check so a malicious caller
|
|
60
|
+
// can't pass things like "main && rm -rf /" (even though execFileSync + args
|
|
61
|
+
// array would block shell injection on its own, this is defense in depth).
|
|
62
|
+
function isValidGitRef(s) {
|
|
63
|
+
return typeof s === 'string' && /^[a-zA-Z0-9._\/-]+$/.test(s) && s.length > 0 && s.length < 256;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ message?: string, branch?: string }} [opts]
|
|
68
|
+
*/
|
|
69
|
+
export function gitPush(opts) {
|
|
70
|
+
const { message, branch } = opts || {};
|
|
59
71
|
const cwd = process.cwd();
|
|
60
72
|
|
|
61
73
|
let currentBranch;
|
|
62
74
|
try {
|
|
63
|
-
currentBranch =
|
|
75
|
+
currentBranch = execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8' }).trim();
|
|
64
76
|
} catch (e) {
|
|
65
77
|
return { ok: false, error: '不在 git 仓库中' };
|
|
66
78
|
}
|
|
67
79
|
const targetBranch = branch || currentBranch;
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
// Defensive validation of branch name (caller-supplied)
|
|
82
|
+
if (branch && !isValidGitRef(branch)) {
|
|
83
|
+
return { ok: false, error: `非法 branch 名称: ${branch}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' }).trim();
|
|
70
87
|
if (status) {
|
|
71
|
-
|
|
88
|
+
execFileSync('git', ['add', '-A'], { cwd });
|
|
72
89
|
const msg = (message || `auto update ${new Date().toISOString().slice(0, 19)}`).replace(/"/g, '\\"');
|
|
73
|
-
|
|
90
|
+
// execFileSync with args array — msg is passed as a single argv element
|
|
91
|
+
// to git, never interpreted by shell. Even if msg contains `;` or `&`
|
|
92
|
+
// or shell metachars, git just stores it as the commit message.
|
|
93
|
+
execFileSync('git', ['commit', '-m', msg], { cwd, stdio: 'pipe' });
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
let originUrl;
|
|
77
97
|
try {
|
|
78
|
-
originUrl =
|
|
98
|
+
originUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf-8' }).trim();
|
|
79
99
|
} catch (e) {
|
|
80
100
|
return { ok: false, error: '没有找到 remote origin' };
|
|
81
101
|
}
|
|
@@ -89,7 +109,7 @@ export function gitPush({ message, branch } = {}) {
|
|
|
89
109
|
let pushAuth = parsed.auth;
|
|
90
110
|
if (originUrl.startsWith('https://') && !originUrl.includes('@')) {
|
|
91
111
|
try {
|
|
92
|
-
const ghToken =
|
|
112
|
+
const ghToken = execFileSync('gh', ['auth', 'token'], { encoding: 'utf-8' }).trim();
|
|
93
113
|
if (ghToken) {
|
|
94
114
|
pushAuth = `https://${ghToken}@`;
|
|
95
115
|
}
|
|
@@ -104,17 +124,20 @@ export function gitPush({ message, branch } = {}) {
|
|
|
104
124
|
const remoteName = `m_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
105
125
|
console.log(`[git-push] trying ${host}...`);
|
|
106
126
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
// All execFileSync calls below use args arrays — no shell interpolation.
|
|
128
|
+
// remoteName, pushUrl, targetBranch, branch are passed as argv elements,
|
|
129
|
+
// never interpreted by shell.
|
|
130
|
+
execFileSync('git', ['remote', 'add', remoteName, pushUrl], { cwd, stdio: 'pipe' });
|
|
131
|
+
execFileSync('git', ['push', remoteName, targetBranch, '--follow-tags'], { cwd, stdio: 'pipe', timeout: 30000 });
|
|
132
|
+
execFileSync('git', ['remote', 'remove', remoteName], { cwd, stdio: 'pipe' });
|
|
133
|
+
results.push({ host, ok: true, url: pushUrl });
|
|
111
134
|
return { ok: true, results, successUrl: pushUrl };
|
|
112
135
|
} catch (e) {
|
|
113
|
-
try {
|
|
136
|
+
try { execFileSync('git', ['remote', 'remove', remoteName], { cwd, stdio: 'pipe' }); } catch (_) {}
|
|
114
137
|
const msg = e.stderr?.toString()?.trim() || e.message || '';
|
|
115
|
-
results.push({ host, ok: false, error: msg.slice(0, 150) });
|
|
138
|
+
results.push({ host, ok: false, url: pushUrl, error: msg.slice(0, 150) });
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
return { ok: false, results };
|
|
120
|
-
}
|
|
143
|
+
}
|