claude-remote 0.3.1 → 0.4.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/README.md +51 -3
- package/package.json +1 -1
- package/server.js +148 -14
package/README.md
CHANGED
|
@@ -59,9 +59,56 @@ claude-remote /path/to/project --resume abc123
|
|
|
59
59
|
PORT=8080 claude-remote
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
### 3.
|
|
62
|
+
### 3. 安全认证
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
`claude-remote` 内置 Token 鉴权机制,防止未授权的客户端控制你的 Claude Code。
|
|
65
|
+
|
|
66
|
+
#### 工作原理
|
|
67
|
+
|
|
68
|
+
1. **首次启动时自动生成 Token** —— 桥接器启动后会自动生成一个随机 Token,持久化到 `~/.claude-remote-token` 文件,并在启动 banner 中显示
|
|
69
|
+
2. **客户端必须提供 Token** —— App 在添加/编辑服务器时填入 Token,连接时通过 `hello` 握手消息发送给服务器
|
|
70
|
+
3. **服务器验证 Token** —— 使用 `crypto.timingSafeEqual` 安全比对,防止时序攻击。验证通过后才发送会话数据
|
|
71
|
+
4. **认证超时** —— 连接后 5 秒内未完成认证将被自动断开(close code 4002)
|
|
72
|
+
5. **认证失败** —— Token 不匹配时连接立即关闭(close code 4001),App 会弹出编辑对话框提示修正
|
|
73
|
+
|
|
74
|
+
#### Token 来源优先级
|
|
75
|
+
|
|
76
|
+
| 优先级 | 来源 | 说明 |
|
|
77
|
+
|--------|------|------|
|
|
78
|
+
| 1 | `--token <value>` | CLI 参数,最高优先级 |
|
|
79
|
+
| 2 | `CLAUDE_REMOTE_TOKEN` 环境变量 | 适合 CI/脚本场景 |
|
|
80
|
+
| 3 | `~/.claude-remote-token` 文件 | 自动生成并持久化,首次启动时创建 |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 使用自定义 Token
|
|
84
|
+
claude-remote --token my-secret-token
|
|
85
|
+
|
|
86
|
+
# 通过环境变量
|
|
87
|
+
CLAUDE_REMOTE_TOKEN=my-secret-token claude-remote
|
|
88
|
+
|
|
89
|
+
# 默认行为:自动生成并保存到 ~/.claude-remote-token
|
|
90
|
+
claude-remote
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### 禁用认证
|
|
94
|
+
|
|
95
|
+
> ⚠️ 仅在安全网络环境下使用,如本地开发或 VPN 内网。
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# CLI 参数
|
|
99
|
+
claude-remote --no-auth
|
|
100
|
+
|
|
101
|
+
# 环境变量
|
|
102
|
+
NO_AUTH=1 claude-remote
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### App 端配置
|
|
106
|
+
|
|
107
|
+
在连接中心添加或编辑服务器时,填入 Token 字段即可。Token 输入框支持密码显示/隐藏切换。认证失败时 App 会自动弹出编辑对话框,方便修正 Token。
|
|
108
|
+
|
|
109
|
+
### 4. App 连接
|
|
110
|
+
|
|
111
|
+
安装 Android App 后,在连接中心添加服务器地址连接。根据你的网络环境,有以下三种方式:
|
|
65
112
|
|
|
66
113
|
#### 方式一:局域网直连
|
|
67
114
|
|
|
@@ -107,7 +154,7 @@ wss://xxx.trycloudflare.com
|
|
|
107
154
|
|
|
108
155
|
> 适合在外网或移动网络下远程控制,支持 HTTPS/WSS 加密。
|
|
109
156
|
|
|
110
|
-
###
|
|
157
|
+
### 5. 编译 Android App
|
|
111
158
|
|
|
112
159
|
```bash
|
|
113
160
|
cd app
|
|
@@ -143,6 +190,7 @@ npx tauri android dev
|
|
|
143
190
|
| 工具调用追踪 | 折叠式 step group,shimmer 加载动效 |
|
|
144
191
|
| 权限队列 | 批量审批,计数器显示剩余数量 |
|
|
145
192
|
| 模式切换 | 四种模式一键切换,彩色状态标识 |
|
|
193
|
+
| Token 鉴权 | 自动生成 Token,timingSafeEqual 安全比对,防止未授权访问 |
|
|
146
194
|
| 对话压缩 | /compact 带 spinner 遮罩,压缩完自动恢复 |
|
|
147
195
|
| 斜杠命令菜单 | 输入 `/` 弹出命令面板 |
|
|
148
196
|
| 模型切换 | 底部面板选择,toast 即时反馈 |
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const pty = require('node-pty');
|
|
6
6
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
const { execSync } = require('child_process');
|
|
8
9
|
|
|
9
10
|
// --- CLI argument parsing ---
|
|
@@ -26,6 +27,8 @@ const BLOCKED_FLAGS = new Set([
|
|
|
26
27
|
'--help', '-h', // exits immediately
|
|
27
28
|
'--init-only', // exits immediately
|
|
28
29
|
'--maintenance', // exits immediately
|
|
30
|
+
'--token', // bridge-only: auth token
|
|
31
|
+
'--no-auth', // bridge-only: disable auth
|
|
29
32
|
]);
|
|
30
33
|
|
|
31
34
|
// Flags that consume the next argument as a value
|
|
@@ -42,6 +45,7 @@ const FLAGS_WITH_VALUE = new Set([
|
|
|
42
45
|
'--output-format', '--input-format', '--json-schema',
|
|
43
46
|
'--max-budget-usd', '--max-turns', '--fallback-model',
|
|
44
47
|
'--permission-prompt-tool',
|
|
48
|
+
'--token', // bridge-only: auth token
|
|
45
49
|
]);
|
|
46
50
|
|
|
47
51
|
function parseCliArgs(argv) {
|
|
@@ -49,6 +53,8 @@ function parseCliArgs(argv) {
|
|
|
49
53
|
let cwd = null;
|
|
50
54
|
const claudeArgs = [];
|
|
51
55
|
const blocked = [];
|
|
56
|
+
let token = null;
|
|
57
|
+
let noAuth = false;
|
|
52
58
|
|
|
53
59
|
let i = 0;
|
|
54
60
|
while (i < rawArgs.length) {
|
|
@@ -75,6 +81,25 @@ function parseCliArgs(argv) {
|
|
|
75
81
|
const eqIdx = arg.indexOf('=');
|
|
76
82
|
const flagName = eqIdx > 0 ? arg.substring(0, eqIdx) : arg;
|
|
77
83
|
|
|
84
|
+
// Intercept bridge-only flags
|
|
85
|
+
if (flagName === '--token') {
|
|
86
|
+
if (eqIdx > 0) {
|
|
87
|
+
token = arg.substring(eqIdx + 1);
|
|
88
|
+
} else if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
|
|
89
|
+
i++;
|
|
90
|
+
token = rawArgs[i];
|
|
91
|
+
} else {
|
|
92
|
+
token = '';
|
|
93
|
+
}
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (flagName === '--no-auth') {
|
|
98
|
+
noAuth = true;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
if (BLOCKED_FLAGS.has(flagName)) {
|
|
79
104
|
blocked.push(flagName);
|
|
80
105
|
if (eqIdx > 0) {
|
|
@@ -96,10 +121,36 @@ function parseCliArgs(argv) {
|
|
|
96
121
|
i++;
|
|
97
122
|
}
|
|
98
123
|
|
|
99
|
-
return { cwd: cwd || process.cwd(), claudeArgs, blocked };
|
|
124
|
+
return { cwd: cwd || process.cwd(), claudeArgs, blocked, token, noAuth };
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs } = parseCliArgs(process.argv);
|
|
127
|
+
const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs, token: _cliToken, noAuth: _cliNoAuth } = parseCliArgs(process.argv);
|
|
128
|
+
|
|
129
|
+
// --- Auth token resolution ---
|
|
130
|
+
const AUTH_TOKEN_ENV_VAR = 'CLAUDE_REMOTE_TOKEN';
|
|
131
|
+
const LEGACY_AUTH_TOKEN_ENV_VAR = 'TOKEN';
|
|
132
|
+
const AUTH_DISABLED = _cliNoAuth || process.env.NO_AUTH === '1';
|
|
133
|
+
const TOKEN_FILE = path.join(os.homedir(), '.claude-remote-token');
|
|
134
|
+
const UNUSED_LEGACY_TOKEN_ENV = !!process.env[LEGACY_AUTH_TOKEN_ENV_VAR] && !process.env[AUTH_TOKEN_ENV_VAR];
|
|
135
|
+
|
|
136
|
+
function resolveAuthToken() {
|
|
137
|
+
if (AUTH_DISABLED) return null;
|
|
138
|
+
// 1. CLI --token
|
|
139
|
+
if (_cliToken) return _cliToken;
|
|
140
|
+
// 2. Namespaced env
|
|
141
|
+
if (process.env[AUTH_TOKEN_ENV_VAR]) return process.env[AUTH_TOKEN_ENV_VAR];
|
|
142
|
+
// 3. Persisted file
|
|
143
|
+
try {
|
|
144
|
+
const saved = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
145
|
+
if (saved) return saved;
|
|
146
|
+
} catch {}
|
|
147
|
+
// 4. Generate and persist
|
|
148
|
+
const generated = crypto.randomBytes(24).toString('base64url');
|
|
149
|
+
try { fs.writeFileSync(TOKEN_FILE, generated + '\n', { mode: 0o600 }); } catch {}
|
|
150
|
+
return generated;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const AUTH_TOKEN = resolveAuthToken();
|
|
103
154
|
|
|
104
155
|
// --- Config ---
|
|
105
156
|
const PORT = parseInt(process.env.PORT || '3100', 10);
|
|
@@ -107,6 +158,11 @@ let CWD = _parsedCwd;
|
|
|
107
158
|
const CLAUDE_HOME = path.join(os.homedir(), '.claude');
|
|
108
159
|
const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
|
|
109
160
|
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
|
161
|
+
const AUTH_HELLO_TIMEOUT_MS = 5000;
|
|
162
|
+
const WS_CLOSE_AUTH_FAILED = 4001;
|
|
163
|
+
const WS_CLOSE_AUTH_TIMEOUT = 4002;
|
|
164
|
+
const WS_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
|
|
165
|
+
const WS_CLOSE_REASON_AUTH_TIMEOUT = 'auth_timeout';
|
|
110
166
|
|
|
111
167
|
// --- State ---
|
|
112
168
|
let claudeProc = null;
|
|
@@ -162,6 +218,10 @@ function wsLabel(ws) {
|
|
|
162
218
|
return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
|
|
163
219
|
}
|
|
164
220
|
|
|
221
|
+
function isAuthenticatedClient(ws) {
|
|
222
|
+
return !!ws && ws.readyState === WebSocket.OPEN && !!ws._authenticated;
|
|
223
|
+
}
|
|
224
|
+
|
|
165
225
|
function sendWs(ws, msg, context = '') {
|
|
166
226
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
167
227
|
ws.send(JSON.stringify(msg));
|
|
@@ -400,7 +460,7 @@ const server = http.createServer((req, res) => {
|
|
|
400
460
|
}
|
|
401
461
|
|
|
402
462
|
// No WebUI clients → fall back to terminal prompt
|
|
403
|
-
const clients = [...wss.clients].filter(
|
|
463
|
+
const clients = [...wss.clients].filter(isAuthenticatedClient);
|
|
404
464
|
if (clients.length === 0) {
|
|
405
465
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
406
466
|
res.end(JSON.stringify({ decision: 'ask' }));
|
|
@@ -506,7 +566,7 @@ function broadcast(msg) {
|
|
|
506
566
|
const raw = JSON.stringify(msg);
|
|
507
567
|
const recipients = [];
|
|
508
568
|
for (const ws of wss.clients) {
|
|
509
|
-
if (ws
|
|
569
|
+
if (isAuthenticatedClient(ws)) {
|
|
510
570
|
ws.send(raw);
|
|
511
571
|
recipients.push(wsLabel(ws));
|
|
512
572
|
}
|
|
@@ -593,11 +653,7 @@ setInterval(() => {
|
|
|
593
653
|
}
|
|
594
654
|
}, 60 * 1000).unref();
|
|
595
655
|
|
|
596
|
-
|
|
597
|
-
ws._bridgeId = ++nextWsId;
|
|
598
|
-
ws._clientInstanceId = '';
|
|
599
|
-
log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')}`);
|
|
600
|
-
|
|
656
|
+
function sendInitialMessages(ws) {
|
|
601
657
|
sendWs(ws, {
|
|
602
658
|
type: 'status',
|
|
603
659
|
status: claudeProc ? 'running' : 'starting',
|
|
@@ -615,20 +671,86 @@ wss.on('connection', (ws, req) => {
|
|
|
615
671
|
lastSeq: latestEventSeq(),
|
|
616
672
|
}, 'initial');
|
|
617
673
|
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function sendAuthOk(ws) {
|
|
677
|
+
sendWs(ws, {
|
|
678
|
+
type: 'auth_ok',
|
|
679
|
+
authRequired: !AUTH_DISABLED,
|
|
680
|
+
}, 'auth_ok');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
wss.on('connection', (ws, req) => {
|
|
684
|
+
ws._bridgeId = ++nextWsId;
|
|
685
|
+
ws._clientInstanceId = '';
|
|
686
|
+
ws._authenticated = AUTH_DISABLED; // skip auth if disabled
|
|
687
|
+
ws._authTimer = null;
|
|
688
|
+
log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')} authRequired=${!AUTH_DISABLED}`);
|
|
689
|
+
|
|
690
|
+
// If auth is disabled, send initial messages immediately
|
|
691
|
+
if (AUTH_DISABLED) {
|
|
692
|
+
sendAuthOk(ws);
|
|
693
|
+
sendInitialMessages(ws);
|
|
694
|
+
} else {
|
|
695
|
+
ws._authTimer = setTimeout(() => {
|
|
696
|
+
if (ws.readyState !== WebSocket.OPEN || ws._authenticated) return;
|
|
697
|
+
log(`Auth timeout for ${wsLabel(ws)}`);
|
|
698
|
+
ws.close(WS_CLOSE_AUTH_TIMEOUT, WS_CLOSE_REASON_AUTH_TIMEOUT);
|
|
699
|
+
}, AUTH_HELLO_TIMEOUT_MS);
|
|
700
|
+
}
|
|
618
701
|
|
|
619
702
|
// New clients should explicitly request a resume window. Keep a delayed
|
|
620
703
|
// full replay fallback so older clients still work.
|
|
621
704
|
ws._resumeHandled = false;
|
|
622
|
-
ws._legacyReplayTimer =
|
|
623
|
-
|
|
624
|
-
ws.
|
|
625
|
-
|
|
626
|
-
|
|
705
|
+
ws._legacyReplayTimer = null;
|
|
706
|
+
if (AUTH_DISABLED) {
|
|
707
|
+
ws._legacyReplayTimer = setTimeout(() => {
|
|
708
|
+
if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
|
|
709
|
+
ws._resumeHandled = true;
|
|
710
|
+
sendReplay(ws, null);
|
|
711
|
+
}, LEGACY_REPLAY_DELAY_MS);
|
|
712
|
+
}
|
|
627
713
|
|
|
628
714
|
ws.on('message', async (raw) => {
|
|
629
715
|
let msg;
|
|
630
716
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
631
717
|
|
|
718
|
+
// --- Authentication gate ---
|
|
719
|
+
if (!ws._authenticated) {
|
|
720
|
+
if (msg.type !== 'hello') return; // ignore non-hello when not authenticated
|
|
721
|
+
ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
|
|
722
|
+
log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
|
|
723
|
+
|
|
724
|
+
const clientToken = String(msg.token || '');
|
|
725
|
+
if (!AUTH_TOKEN || !clientToken) {
|
|
726
|
+
log(`Auth failed for ${wsLabel(ws)}: missing token`);
|
|
727
|
+
ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const a = Buffer.from(AUTH_TOKEN, 'utf8');
|
|
731
|
+
const b = Buffer.from(clientToken, 'utf8');
|
|
732
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
733
|
+
log(`Auth failed for ${wsLabel(ws)}: invalid token`);
|
|
734
|
+
ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
ws._authenticated = true;
|
|
738
|
+
if (ws._authTimer) {
|
|
739
|
+
clearTimeout(ws._authTimer);
|
|
740
|
+
ws._authTimer = null;
|
|
741
|
+
}
|
|
742
|
+
log(`Auth OK for ${wsLabel(ws)}`);
|
|
743
|
+
|
|
744
|
+
sendAuthOk(ws);
|
|
745
|
+
sendInitialMessages(ws);
|
|
746
|
+
ws._legacyReplayTimer = setTimeout(() => {
|
|
747
|
+
if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
|
|
748
|
+
ws._resumeHandled = true;
|
|
749
|
+
sendReplay(ws, null);
|
|
750
|
+
}, LEGACY_REPLAY_DELAY_MS);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
632
754
|
switch (msg.type) {
|
|
633
755
|
case 'hello':
|
|
634
756
|
ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
|
|
@@ -940,6 +1062,10 @@ wss.on('connection', (ws, req) => {
|
|
|
940
1062
|
});
|
|
941
1063
|
|
|
942
1064
|
ws.on('close', () => {
|
|
1065
|
+
if (ws._authTimer) {
|
|
1066
|
+
clearTimeout(ws._authTimer);
|
|
1067
|
+
ws._authTimer = null;
|
|
1068
|
+
}
|
|
943
1069
|
if (ws._legacyReplayTimer) {
|
|
944
1070
|
clearTimeout(ws._legacyReplayTimer);
|
|
945
1071
|
ws._legacyReplayTimer = null;
|
|
@@ -1713,6 +1839,14 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
1713
1839
|
CWD: ${CWD}
|
|
1714
1840
|
Log: ${LOG_FILE}
|
|
1715
1841
|
`;
|
|
1842
|
+
if (AUTH_DISABLED) {
|
|
1843
|
+
banner += ` Auth: DISABLED (no authentication)\n`;
|
|
1844
|
+
} else {
|
|
1845
|
+
banner += ` Token: ${AUTH_TOKEN}\n`;
|
|
1846
|
+
}
|
|
1847
|
+
if (UNUSED_LEGACY_TOKEN_ENV) {
|
|
1848
|
+
banner += ` Note: Ignoring legacy ${LEGACY_AUTH_TOKEN_ENV_VAR}; use ${AUTH_TOKEN_ENV_VAR} instead\n`;
|
|
1849
|
+
}
|
|
1716
1850
|
if (CLAUDE_EXTRA_ARGS.length > 0) {
|
|
1717
1851
|
banner += ` Args: claude ${CLAUDE_EXTRA_ARGS.join(' ')}\n`;
|
|
1718
1852
|
}
|