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.
Files changed (3) hide show
  1. package/README.md +51 -3
  2. package/package.json +1 -1
  3. 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. App 连接
62
+ ### 3. 安全认证
63
63
 
64
- 安装 Android App 后,在设置页输入服务器地址连接。根据你的网络环境,有以下三种方式:
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
- ### 4. 编译 Android App
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
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(c => c.readyState === WebSocket.OPEN);
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.readyState === WebSocket.OPEN) {
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
- wss.on('connection', (ws, req) => {
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 = setTimeout(() => {
623
- if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
624
- ws._resumeHandled = true;
625
- sendReplay(ws, null);
626
- }, LEGACY_REPLAY_DELAY_MS);
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
  }