claude-cac 1.4.0-beta.4 → 1.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 (4) hide show
  1. package/README.md +2 -2
  2. package/cac +23 -1
  3. package/package.json +1 -1
  4. package/relay.js +135 -71
package/README.md CHANGED
@@ -202,7 +202,7 @@ cac docker port 6287 # 端口转发
202
202
  - **首次登录**:启动 `claude` 后,输入 `/login` 完成 OAuth 授权
203
203
  - **安全验证**:随时运行 `cac env check` 确认隐私保护状态,也可以 `which claude` 确认使用的是 cac 托管的 claude
204
204
  - **自动安全检查**:每次启动 Claude Code 会话时,cac 会快速检查环境。如有异常会终止会话,不会发送任何数据
205
- - **网络稳定性**:流量严格走代理——代理断开时流量完全停止,不会回退直连
205
+ - **网络稳定性**:流量严格走代理——代理断开时流量完全停止,不会回退直连。内置心跳检测和自动恢复,断线后无需手动重启
206
206
  - **IPv6**:建议系统级关闭,防止真实地址泄露
207
207
 
208
208
  ---
@@ -377,7 +377,7 @@ Proxy formats: `ip:port:user:pass` (SOCKS5), `ss://...`, `vmess://...`, `vless:/
377
377
  - **First login**: Run `claude`, then type `/login`. Health check is automatically bypassed.
378
378
  - **Verify your setup**: Run `cac env check` anytime. Use `which claude` to confirm you're using the cac-managed wrapper.
379
379
  - **Automatic safety checks**: Every new Claude Code session runs a quick cac check. If anything is wrong, the session is terminated before any data is sent.
380
- - **Network resilience**: Traffic is strictly routed through your proxy. If the proxy drops, traffic stops entirely — no fallback to direct connection.
380
+ - **Network resilience**: Traffic is strictly routed through your proxy. If the proxy drops, traffic stops entirely — no fallback to direct connection. Built-in heartbeat detection and auto-recovery — no manual restart needed after disconnections.
381
381
  - **IPv6**: Recommend disabling system-wide to prevent real address exposure.
382
382
 
383
383
  ---
package/cac CHANGED
@@ -11,7 +11,7 @@ VERSIONS_DIR="$CAC_DIR/versions"
11
11
  # ── utils: colors, read/write, UUID, proxy parsing ───────────────────────
12
12
 
13
13
  # shellcheck disable=SC2034 # used in build-concatenated cac script
14
- CAC_VERSION="1.4.0-beta.4"
14
+ CAC_VERSION="1.4.0"
15
15
 
16
16
  _read() { [[ -f "$1" ]] && tr -d '[:space:]' < "$1" || echo "${2:-}"; }
17
17
  _die() { printf '%b\n' "$(_red "error:") $*" >&2; exit 1; }
@@ -1321,6 +1321,8 @@ fi
1321
1321
 
1322
1322
  # cleanup function
1323
1323
  _cleanup_all() {
1324
+ # stop watchdog
1325
+ [[ -n "${_watchdog_pid:-}" ]] && kill "$_watchdog_pid" 2>/dev/null || true
1324
1326
  # cleanup relay
1325
1327
  if [[ "$_relay_active" == "true" ]] && [[ -f "$CAC_DIR/relay.pid" ]]; then
1326
1328
  local _p; _p=$(cat "$CAC_DIR/relay.pid" 2>/dev/null) || true
@@ -1331,6 +1333,26 @@ _cleanup_all() {
1331
1333
 
1332
1334
  trap _cleanup_all EXIT INT TERM
1333
1335
 
1336
+ # ── Relay watchdog: auto-restart relay if it crashes ──
1337
+ if [[ "$_relay_active" == "true" ]]; then
1338
+ (while true; do
1339
+ sleep 10
1340
+ [[ -f "$_relay_pid_file" ]] || break # pid file gone = intentional shutdown
1341
+ _wpid=$(tr -d '[:space:]' < "$_relay_pid_file" 2>/dev/null) || break
1342
+ if ! kill -0 "$_wpid" 2>/dev/null; then
1343
+ echo "[cac] relay crashed, restarting..." >&2
1344
+ node "$_relay_js" "$_rport" "$PROXY" "$_relay_pid_file" </dev/null >>"$CAC_DIR/relay.log" 2>&1 &
1345
+ disown
1346
+ for _wi in {1..30}; do
1347
+ (echo >/dev/tcp/127.0.0.1/$_rport) 2>/dev/null && break
1348
+ sleep 0.1
1349
+ done
1350
+ fi
1351
+ done) &
1352
+ _watchdog_pid=$!
1353
+ disown
1354
+ fi
1355
+
1334
1356
  # ── Concurrent session check ──
1335
1357
  _max_sessions=10
1336
1358
  [[ -f "$CAC_DIR/max_sessions" ]] && _ms=$(tr -d '[:space:]' < "$CAC_DIR/max_sessions") && [[ -n "$_ms" ]] && _max_sessions="$_ms"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-cac",
3
- "version": "1.4.0-beta.4",
3
+ "version": "1.4.0",
4
4
  "description": "Isolate, protect, and manage your Claude Code — versions, environments, identity, and proxy.",
5
5
  "bin": {
6
6
  "cac": "cac"
package/relay.js CHANGED
@@ -5,42 +5,81 @@
5
5
  // Listens on 127.0.0.1:<port> as an HTTP proxy, forwards upstream via:
6
6
  // - HTTP CONNECT (for http:// upstream)
7
7
  // - SOCKS5 (for socks5:// upstream)
8
+ //
9
+ // Safety: fail-closed design — if relay dies, HTTPS_PROXY points to dead port,
10
+ // connections refuse (no IP leak). Watchdog in wrapper auto-restarts relay.
8
11
  'use strict';
9
12
 
10
- const net = require('net');
11
- const url = require('url');
13
+ var net = require('net');
14
+ var fs = require('fs');
12
15
 
13
16
  // ── Parse CLI args ──────────────────────────────────────────────
14
17
 
15
- const listenPort = parseInt(process.argv[2], 10);
16
- const upstreamUrl = process.argv[3];
17
- const pidFile = process.argv[4];
18
+ var listenPort = parseInt(process.argv[2], 10);
19
+ var upstreamUrl = process.argv[3];
20
+ var pidFile = process.argv[4];
18
21
 
19
22
  if (!listenPort || !upstreamUrl) {
20
23
  process.stderr.write('Usage: node relay.js <port> <upstream_proxy_url> [pid_file]\n');
21
24
  process.exit(1);
22
25
  }
23
26
 
24
- const upstream = new URL(upstreamUrl);
25
- const upstreamHost = upstream.hostname;
26
- const upstreamPort = parseInt(upstream.port, 10);
27
- const upstreamUser = decodeURIComponent(upstream.username || '');
28
- const upstreamPass = decodeURIComponent(upstream.password || '');
29
- const isSocks5 = upstream.protocol === 'socks5:';
27
+ var upstream = new URL(upstreamUrl);
28
+ var upstreamHost = upstream.hostname;
29
+ var upstreamPort = parseInt(upstream.port, 10);
30
+ var upstreamUser = decodeURIComponent(upstream.username || '');
31
+ var upstreamPass = decodeURIComponent(upstream.password || '');
32
+ var isSocks5 = upstream.protocol === 'socks5:';
30
33
 
31
34
  function log(msg) { process.stderr.write('[cac-relay] ' + msg + '\n'); }
32
35
 
36
+ // ── Global error handlers (never crash from unhandled errors) ───
37
+
38
+ process.on('uncaughtException', function(err) {
39
+ log('uncaught exception: ' + (err && err.message || err));
40
+ });
41
+ process.on('unhandledRejection', function(reason) {
42
+ log('unhandled rejection: ' + (reason && reason.message || reason));
43
+ });
44
+
45
+ // ── Upstream heartbeat ──────────────────────────────────────────
46
+
47
+ var _upstreamHealthy = true;
48
+ var HEARTBEAT_INTERVAL = 30000; // 30s
49
+ var HEARTBEAT_TIMEOUT = 5000; // 5s connect timeout
50
+
51
+ function heartbeat() {
52
+ var sock = net.connect({ port: upstreamPort, host: upstreamHost, timeout: HEARTBEAT_TIMEOUT });
53
+ sock.on('connect', function() {
54
+ if (!_upstreamHealthy) log('upstream recovered: ' + upstreamHost + ':' + upstreamPort);
55
+ _upstreamHealthy = true;
56
+ sock.destroy();
57
+ });
58
+ sock.on('error', function() {
59
+ if (_upstreamHealthy) log('upstream unreachable: ' + upstreamHost + ':' + upstreamPort);
60
+ _upstreamHealthy = false;
61
+ sock.destroy();
62
+ });
63
+ sock.on('timeout', function() {
64
+ if (_upstreamHealthy) log('upstream timeout: ' + upstreamHost + ':' + upstreamPort);
65
+ _upstreamHealthy = false;
66
+ sock.destroy();
67
+ });
68
+ }
69
+
70
+ var _heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
71
+
33
72
  // ── SOCKS5 handshake ────────────────────────────────────────────
34
73
 
35
74
  function socks5Connect(targetHost, targetPort, cb) {
36
- const sock = net.connect(upstreamPort, upstreamHost, () => {
37
- const hasAuth = upstreamUser && upstreamPass;
75
+ var sock = net.connect(upstreamPort, upstreamHost, function() {
76
+ var hasAuth = upstreamUser && upstreamPass;
38
77
 
39
78
  // Greeting: version=5, nmethods=1, method=(0x02 if auth, 0x00 if none)
40
79
  sock.write(Buffer.from([0x05, 0x01, hasAuth ? 0x02 : 0x00]));
41
80
 
42
- let state = 'greeting';
43
- let buf = Buffer.alloc(0);
81
+ var state = 'greeting';
82
+ var buf = Buffer.alloc(0);
44
83
 
45
84
  sock.on('data', onData);
46
85
 
@@ -48,14 +87,14 @@ function socks5Connect(targetHost, targetPort, cb) {
48
87
  buf = Buffer.concat([buf, chunk]);
49
88
  if (state === 'greeting') {
50
89
  if (buf.length < 2) return;
51
- const method = buf[1];
90
+ var method = buf[1];
52
91
  buf = buf.slice(2);
53
92
 
54
93
  if (method === 0x02 && hasAuth) {
55
94
  // Sub-negotiation: version=1, ulen, username, plen, password
56
- const uBuf = Buffer.from(upstreamUser);
57
- const pBuf = Buffer.from(upstreamPass);
58
- const authReq = Buffer.alloc(3 + uBuf.length + pBuf.length);
95
+ var uBuf = Buffer.from(upstreamUser);
96
+ var pBuf = Buffer.from(upstreamPass);
97
+ var authReq = Buffer.alloc(3 + uBuf.length + pBuf.length);
59
98
  authReq[0] = 0x01;
60
99
  authReq[1] = uBuf.length;
61
100
  uBuf.copy(authReq, 2);
@@ -86,16 +125,16 @@ function socks5Connect(targetHost, targetPort, cb) {
86
125
  return;
87
126
  }
88
127
  // Parse variable-length address to consume the full reply
89
- const atyp = buf[3];
90
- let addrLen;
128
+ var atyp = buf[3];
129
+ var addrLen;
91
130
  if (atyp === 0x01) addrLen = 4; // IPv4
92
131
  else if (atyp === 0x04) addrLen = 16; // IPv6
93
132
  else if (atyp === 0x03) addrLen = 1 + (buf[4] || 0); // Domain
94
133
  else addrLen = 0;
95
- const totalLen = 4 + addrLen + 2; // header + addr + port
134
+ var totalLen = 4 + addrLen + 2; // header + addr + port
96
135
  if (buf.length < totalLen) return;
97
136
 
98
- const remaining = buf.slice(totalLen);
137
+ var remaining = buf.slice(totalLen);
99
138
  sock.removeListener('data', onData);
100
139
  cb(null, sock, remaining);
101
140
  }
@@ -103,8 +142,8 @@ function socks5Connect(targetHost, targetPort, cb) {
103
142
 
104
143
  function sendConnectRequest() {
105
144
  // CONNECT request: ver=5, cmd=1(connect), rsv=0, atyp=3(domain)
106
- const hostBuf = Buffer.from(targetHost);
107
- const req = Buffer.alloc(5 + hostBuf.length + 2);
145
+ var hostBuf = Buffer.from(targetHost);
146
+ var req = Buffer.alloc(5 + hostBuf.length + 2);
108
147
  req[0] = 0x05; // version
109
148
  req[1] = 0x01; // connect
110
149
  req[2] = 0x00; // reserved
@@ -117,31 +156,31 @@ function socks5Connect(targetHost, targetPort, cb) {
117
156
  }
118
157
  });
119
158
 
120
- sock.on('error', (err) => cb(err));
159
+ sock.on('error', function(err) { cb(err); });
121
160
  }
122
161
 
123
162
  // ── HTTP CONNECT upstream ───────────────────────────────────────
124
163
 
125
164
  function httpConnect(targetHost, targetPort, cb) {
126
- const sock = net.connect(upstreamPort, upstreamHost, () => {
127
- let connectReq = 'CONNECT ' + targetHost + ':' + targetPort + ' HTTP/1.1\r\n' +
165
+ var sock = net.connect(upstreamPort, upstreamHost, function() {
166
+ var connectReq = 'CONNECT ' + targetHost + ':' + targetPort + ' HTTP/1.1\r\n' +
128
167
  'Host: ' + targetHost + ':' + targetPort + '\r\n';
129
168
  if (upstreamUser) {
130
- const cred = Buffer.from(upstreamUser + ':' + upstreamPass).toString('base64');
169
+ var cred = Buffer.from(upstreamUser + ':' + upstreamPass).toString('base64');
131
170
  connectReq += 'Proxy-Authorization: Basic ' + cred + '\r\n';
132
171
  }
133
172
  connectReq += '\r\n';
134
173
  sock.write(connectReq);
135
174
 
136
- let buf = Buffer.alloc(0);
175
+ var buf = Buffer.alloc(0);
137
176
  sock.on('data', function onData(chunk) {
138
177
  buf = Buffer.concat([buf, chunk]);
139
- const idx = buf.indexOf('\r\n\r\n');
178
+ var idx = buf.indexOf('\r\n\r\n');
140
179
  if (idx === -1) return;
141
180
 
142
- const statusLine = buf.slice(0, buf.indexOf('\r\n')).toString();
143
- const statusCode = parseInt(statusLine.split(' ')[1], 10);
144
- const remaining = buf.slice(idx + 4);
181
+ var statusLine = buf.slice(0, buf.indexOf('\r\n')).toString();
182
+ var statusCode = parseInt(statusLine.split(' ')[1], 10);
183
+ var remaining = buf.slice(idx + 4);
145
184
 
146
185
  sock.removeListener('data', onData);
147
186
 
@@ -154,7 +193,7 @@ function httpConnect(targetHost, targetPort, cb) {
154
193
  });
155
194
  });
156
195
 
157
- sock.on('error', (err) => cb(err));
196
+ sock.on('error', function(err) { cb(err); });
158
197
  }
159
198
 
160
199
  // ── Connect to upstream (protocol dispatch) ─────────────────────
@@ -169,33 +208,36 @@ function connectUpstream(targetHost, targetPort, cb) {
169
208
 
170
209
  // ── Local HTTP proxy server ─────────────────────────────────────
171
210
 
172
- const MAX_CONNECTIONS = 128;
173
- let activeConnections = 0;
211
+ var MAX_CONNECTIONS = 128;
212
+ var IDLE_TIMEOUT = 1800000; // 30 min — streaming responses can be very long
213
+ var activeConnections = 0;
174
214
 
175
- const server = net.createServer({ pauseOnConnect: true }, (clientSock) => {
215
+ var server = net.createServer({ pauseOnConnect: true }, function(clientSock) {
176
216
  if (activeConnections >= MAX_CONNECTIONS) {
177
217
  clientSock.destroy();
178
218
  return;
179
219
  }
180
220
  activeConnections++;
181
- clientSock.on('close', () => { activeConnections--; });
221
+ clientSock.on('close', function() { activeConnections--; });
182
222
 
183
- clientSock.setTimeout(120000, () => clientSock.destroy());
223
+ // Idle timeout: only kill truly idle sockets, not active streaming ones
224
+ clientSock.setTimeout(IDLE_TIMEOUT, function() { clientSock.destroy(); });
225
+ clientSock.on('error', function() {}); // per-connection error: don't crash
184
226
  clientSock.resume();
185
227
 
186
- let headerBuf = '';
228
+ var headerBuf = '';
187
229
  clientSock.on('data', function onHeader(chunk) {
188
230
  headerBuf += chunk.toString();
189
- const idx = headerBuf.indexOf('\r\n');
231
+ var idx = headerBuf.indexOf('\r\n');
190
232
  if (idx === -1) return;
191
233
 
192
234
  clientSock.removeListener('data', onHeader);
193
235
 
194
- const firstLine = headerBuf.substring(0, idx);
195
- const rest = headerBuf.substring(idx + 2);
236
+ var firstLine = headerBuf.substring(0, idx);
237
+ var rest = headerBuf.substring(idx + 2);
196
238
 
197
239
  // CONNECT host:port HTTP/1.1
198
- const match = firstLine.match(/^CONNECT\s+([^\s:]+):(\d+)\s+HTTP/i);
240
+ var match = firstLine.match(/^CONNECT\s+([^\s:]+):(\d+)\s+HTTP/i);
199
241
  if (match) {
200
242
  handleConnect(clientSock, match[1], parseInt(match[2], 10), rest);
201
243
  } else {
@@ -207,37 +249,42 @@ const server = net.createServer({ pauseOnConnect: true }, (clientSock) => {
207
249
 
208
250
  function handleConnect(clientSock, targetHost, targetPort, headerRest) {
209
251
  // Consume remaining headers until \r\n\r\n
210
- // Keep as Buffer to avoid corrupting binary data (e.g. TLS ClientHello)
211
- // that may arrive in the same chunk as the last header bytes.
212
- let restBuf = Buffer.from(headerRest);
213
- const consumeHeaders = () => {
214
- const endIdx = restBuf.indexOf('\r\n\r\n');
252
+ var restBuf = Buffer.from(headerRest);
253
+ var consumeHeaders = function() {
254
+ var endIdx = restBuf.indexOf('\r\n\r\n');
215
255
  if (endIdx !== -1) {
216
- const trailing = restBuf.slice(endIdx + 4);
256
+ var trailing = restBuf.slice(endIdx + 4);
217
257
  doConnect(trailing);
218
258
  return;
219
259
  }
220
- clientSock.once('data', (chunk) => {
260
+ clientSock.once('data', function(chunk) {
221
261
  restBuf = Buffer.concat([restBuf, chunk]);
222
262
  consumeHeaders();
223
263
  });
224
264
  };
225
265
 
226
266
  function doConnect(trailingData) {
227
- connectUpstream(targetHost, targetPort, (err, upstreamSock, upstreamExtra) => {
267
+ connectUpstream(targetHost, targetPort, function(err, upstreamSock, upstreamExtra) {
228
268
  if (err) {
229
- clientSock.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
269
+ try { clientSock.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch(_) {}
230
270
  clientSock.destroy();
231
271
  return;
232
272
  }
233
273
  clientSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
234
274
 
275
+ // Reset idle timeout on data activity (keeps streaming alive)
276
+ upstreamSock.on('data', function() {
277
+ try { clientSock.setTimeout(IDLE_TIMEOUT); } catch(_) {}
278
+ });
279
+ clientSock.on('data', function() {
280
+ try { upstreamSock.setTimeout(IDLE_TIMEOUT); } catch(_) {}
281
+ });
282
+
235
283
  // Pipe bidirectionally
236
284
  clientSock.pipe(upstreamSock);
237
285
  upstreamSock.pipe(clientSock);
238
286
 
239
287
  // Send any extra data that came in after handshake
240
- // upstreamExtra is data from the target server; write it to the client.
241
288
  if (upstreamExtra && upstreamExtra.length > 0) {
242
289
  clientSock.write(upstreamExtra);
243
290
  }
@@ -245,8 +292,12 @@ function handleConnect(clientSock, targetHost, targetPort, headerRest) {
245
292
  upstreamSock.write(trailingData);
246
293
  }
247
294
 
248
- clientSock.on('error', () => upstreamSock.destroy());
249
- upstreamSock.on('error', () => clientSock.destroy());
295
+ // Per-connection errors: destroy peer, don't crash relay
296
+ clientSock.on('error', function() { upstreamSock.destroy(); });
297
+ upstreamSock.on('error', function() { clientSock.destroy(); });
298
+
299
+ // Upstream idle timeout
300
+ upstreamSock.setTimeout(IDLE_TIMEOUT, function() { upstreamSock.destroy(); });
250
301
  });
251
302
  }
252
303
 
@@ -255,24 +306,22 @@ function handleConnect(clientSock, targetHost, targetPort, headerRest) {
255
306
 
256
307
  function handlePlainHttp(clientSock, firstLine, headerRest) {
257
308
  // For plain HTTP requests, forward directly to upstream proxy
258
- const sock = net.connect(upstreamPort, upstreamHost, () => {
259
- let authHeader = '';
309
+ var sock = net.connect(upstreamPort, upstreamHost, function() {
310
+ var authHeader = '';
260
311
  if (upstreamUser) {
261
- const cred = Buffer.from(upstreamUser + ':' + upstreamPass).toString('base64');
312
+ var cred = Buffer.from(upstreamUser + ':' + upstreamPass).toString('base64');
262
313
  authHeader = 'Proxy-Authorization: Basic ' + cred + '\r\n';
263
314
  }
264
315
  sock.write(firstLine + '\r\n' + authHeader + headerRest);
265
316
  clientSock.pipe(sock);
266
317
  sock.pipe(clientSock);
267
318
  });
268
- sock.on('error', () => clientSock.destroy());
269
- clientSock.on('error', () => sock.destroy());
319
+ sock.on('error', function() { clientSock.destroy(); });
320
+ clientSock.on('error', function() { sock.destroy(); });
270
321
  }
271
322
 
272
323
  // ── Lifecycle ───────────────────────────────────────────────────
273
324
 
274
- const fs = require('fs');
275
-
276
325
  function writePid() {
277
326
  if (pidFile) {
278
327
  try { fs.writeFileSync(pidFile, String(process.pid)); } catch (_) {}
@@ -280,6 +329,7 @@ function writePid() {
280
329
  }
281
330
 
282
331
  function cleanup() {
332
+ clearInterval(_heartbeatTimer);
283
333
  if (pidFile) {
284
334
  try { fs.unlinkSync(pidFile); } catch (_) {}
285
335
  }
@@ -290,13 +340,27 @@ function cleanup() {
290
340
  process.on('SIGTERM', cleanup);
291
341
  process.on('SIGINT', cleanup);
292
342
 
293
- server.listen(listenPort, '127.0.0.1', () => {
294
- writePid();
295
- log('listening on 127.0.0.1:' + listenPort + ' → ' + upstreamHost + ':' + upstreamPort +
296
- (isSocks5 ? ' (socks5)' : ' (http)'));
297
- });
343
+ // ── Server start with self-restart on transient errors ──────────
298
344
 
299
- server.on('error', (err) => {
345
+ function startServer() {
346
+ server.listen(listenPort, '127.0.0.1', function() {
347
+ writePid();
348
+ log('listening on 127.0.0.1:' + listenPort + ' \u2192 ' + upstreamHost + ':' + upstreamPort +
349
+ (isSocks5 ? ' (socks5)' : ' (http)'));
350
+ });
351
+ }
352
+
353
+ server.on('error', function(err) {
300
354
  log('server error: ' + err.message);
301
- process.exit(1);
355
+ if (err.code === 'EADDRINUSE') {
356
+ // Port taken — fatal, let watchdog restart us on a new port
357
+ process.exit(1);
358
+ }
359
+ // Transient error — try to restart after 1s
360
+ setTimeout(function() {
361
+ try { server.close(); } catch(_) {}
362
+ startServer();
363
+ }, 1000);
302
364
  });
365
+
366
+ startServer();