claude-remote 0.3.0 → 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 +180 -17
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.0",
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;
@@ -129,6 +185,7 @@ let tailCatchingUp = false; // true while reading historical transcript content
129
185
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
130
186
  const LEGACY_REPLAY_DELAY_MS = 1500;
131
187
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
188
+ const IMAGE_UPLOAD_DIR = path.join(CLAUDE_HOME, 'remote-uploads');
132
189
  let turnStateVersion = 0;
133
190
  let turnState = {
134
191
  phase: 'idle',
@@ -161,6 +218,10 @@ function wsLabel(ws) {
161
218
  return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
162
219
  }
163
220
 
221
+ function isAuthenticatedClient(ws) {
222
+ return !!ws && ws.readyState === WebSocket.OPEN && !!ws._authenticated;
223
+ }
224
+
164
225
  function sendWs(ws, msg, context = '') {
165
226
  if (!ws || ws.readyState !== WebSocket.OPEN) return false;
166
227
  ws.send(JSON.stringify(msg));
@@ -399,7 +460,7 @@ const server = http.createServer((req, res) => {
399
460
  }
400
461
 
401
462
  // No WebUI clients → fall back to terminal prompt
402
- const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
463
+ const clients = [...wss.clients].filter(isAuthenticatedClient);
403
464
  if (clients.length === 0) {
404
465
  res.writeHead(200, { 'Content-Type': 'application/json' });
405
466
  res.end(JSON.stringify({ decision: 'ask' }));
@@ -505,7 +566,7 @@ function broadcast(msg) {
505
566
  const raw = JSON.stringify(msg);
506
567
  const recipients = [];
507
568
  for (const ws of wss.clients) {
508
- if (ws.readyState === WebSocket.OPEN) {
569
+ if (isAuthenticatedClient(ws)) {
509
570
  ws.send(raw);
510
571
  recipients.push(wsLabel(ws));
511
572
  }
@@ -566,14 +627,18 @@ function cleanupImageUpload(uploadId) {
566
627
 
567
628
  function cleanupClientUploads(ws) {
568
629
  for (const [uploadId, upload] of pendingImageUploads) {
569
- if (upload.owner === ws) cleanupImageUpload(uploadId);
630
+ if (upload.owner === ws && !upload.submitted) cleanupImageUpload(uploadId);
570
631
  }
571
632
  }
572
633
 
573
634
  function createTempImageFile(buffer, mediaType, uploadId) {
574
- const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
635
+ const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
636
+ const tmpDir = isLinux
637
+ ? IMAGE_UPLOAD_DIR
638
+ : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
575
639
  const type = String(mediaType || 'image/png').toLowerCase();
576
640
  const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
641
+ fs.mkdirSync(tmpDir, { recursive: true });
577
642
  const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
578
643
  fs.writeFileSync(tmpFile, buffer);
579
644
  return tmpFile;
@@ -588,11 +653,7 @@ setInterval(() => {
588
653
  }
589
654
  }, 60 * 1000).unref();
590
655
 
591
- wss.on('connection', (ws, req) => {
592
- ws._bridgeId = ++nextWsId;
593
- ws._clientInstanceId = '';
594
- log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')}`);
595
-
656
+ function sendInitialMessages(ws) {
596
657
  sendWs(ws, {
597
658
  type: 'status',
598
659
  status: claudeProc ? 'running' : 'starting',
@@ -610,20 +671,86 @@ wss.on('connection', (ws, req) => {
610
671
  lastSeq: latestEventSeq(),
611
672
  }, 'initial');
612
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
+ }
613
701
 
614
702
  // New clients should explicitly request a resume window. Keep a delayed
615
703
  // full replay fallback so older clients still work.
616
704
  ws._resumeHandled = false;
617
- ws._legacyReplayTimer = setTimeout(() => {
618
- if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
619
- ws._resumeHandled = true;
620
- sendReplay(ws, null);
621
- }, 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
+ }
622
713
 
623
714
  ws.on('message', async (raw) => {
624
715
  let msg;
625
716
  try { msg = JSON.parse(raw); } catch { return; }
626
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
+
627
754
  switch (msg.type) {
628
755
  case 'hello':
629
756
  ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
@@ -860,13 +987,16 @@ wss.on('connection', (ws, req) => {
860
987
  break;
861
988
  }
862
989
  try {
990
+ const shouldCleanupAfterSubmit = process.platform === 'win32' || process.platform === 'darwin';
863
991
  handlePreparedImageUpload({
864
992
  tmpFile: upload.tmpFile,
865
993
  mediaType: upload.mediaType,
866
994
  text: msg.text || '',
867
995
  logLabel: upload.name || uploadId,
868
- onCleanup: () => cleanupImageUpload(uploadId),
996
+ onCleanup: shouldCleanupAfterSubmit ? () => cleanupImageUpload(uploadId) : null,
869
997
  });
998
+ upload.submitted = true;
999
+ upload.updatedAt = Date.now();
870
1000
  setTurnState('running', { reason: 'image_submit' });
871
1001
  sendUploadStatus(ws, uploadId, 'submitted');
872
1002
  } catch (err) {
@@ -932,6 +1062,10 @@ wss.on('connection', (ws, req) => {
932
1062
  });
933
1063
 
934
1064
  ws.on('close', () => {
1065
+ if (ws._authTimer) {
1066
+ clearTimeout(ws._authTimer);
1067
+ ws._authTimer = null;
1068
+ }
935
1069
  if (ws._legacyReplayTimer) {
936
1070
  clearTimeout(ws._legacyReplayTimer);
937
1071
  ws._legacyReplayTimer = null;
@@ -1493,11 +1627,27 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1493
1627
 
1494
1628
  const isWin = process.platform === 'win32';
1495
1629
  const isMac = process.platform === 'darwin';
1630
+ const isLinux = !isWin && !isMac;
1496
1631
 
1497
1632
  try {
1498
1633
  const stat = fs.statSync(tmpFile);
1499
1634
  log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1500
1635
 
1636
+ if (isLinux) {
1637
+ const quotedPath = JSON.stringify(tmpFile);
1638
+ const trimmedText = (text || '').trim();
1639
+ const prompt = trimmedText
1640
+ ? `Please analyze the image at this path: ${quotedPath}\n\nUser note:\n${trimmedText}`
1641
+ : `Analyze this image: ${quotedPath}`;
1642
+
1643
+ claudeProc.write(prompt);
1644
+ setTimeout(() => {
1645
+ if (claudeProc) claudeProc.write('\r');
1646
+ log(`Sent Linux image path prompt: ${tmpFile}`);
1647
+ }, 150);
1648
+ return;
1649
+ }
1650
+
1501
1651
  if (isWin) {
1502
1652
  const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
1503
1653
  execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
@@ -1563,6 +1713,11 @@ function handleImageUpload(msg) {
1563
1713
  mediaType: msg.mediaType,
1564
1714
  text: msg.text || '',
1565
1715
  });
1716
+ if (process.platform !== 'win32' && process.platform !== 'darwin') {
1717
+ setTimeout(() => {
1718
+ try { fs.unlinkSync(tmpFile); } catch {}
1719
+ }, IMAGE_UPLOAD_TTL_MS).unref();
1720
+ }
1566
1721
  setTurnState('running', { reason: 'legacy_image_upload' });
1567
1722
  } catch (err) {
1568
1723
  log(`Image upload error: ${err.message}`);
@@ -1684,6 +1839,14 @@ server.listen(PORT, '0.0.0.0', () => {
1684
1839
  CWD: ${CWD}
1685
1840
  Log: ${LOG_FILE}
1686
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
+ }
1687
1850
  if (CLAUDE_EXTRA_ARGS.length > 0) {
1688
1851
  banner += ` Args: claude ${CLAUDE_EXTRA_ARGS.join(' ')}\n`;
1689
1852
  }