cc-viewer 1.9.17 → 1.9.18

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/server/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
2
  import { createServer } from 'node:http';
3
+ import { Readable, Transform, pipeline } from 'node:stream';
3
4
  import { readFileSync, existsSync } from 'node:fs';
4
5
  import { join, basename } from 'node:path';
5
6
  import { homedir } from 'node:os';
@@ -11,7 +12,7 @@ import { getClaudeConfigDir } from '../findcc.js';
11
12
  import { isAnthropicApiPath, classifyProxyRole } from './lib/interceptor-core.js';
12
13
  import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
13
14
  import { liveSystemPromptEnabled } from './lib/system-prompt-live.js';
14
- import { executeRequest, extractModel } from './lib/proxy/proxy-retry.js';
15
+ import { executeRequest, extractModel, isStreamResponse } from './lib/proxy/proxy-retry.js';
15
16
  import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy/proxy-stats.js';
16
17
  import { reportSwallowed } from './lib/error-report.js';
17
18
  import { LOG_DIR } from '../findcc.js';
@@ -231,7 +232,6 @@ export function startProxy() {
231
232
  res.writeHead(response.status, responseHeaders);
232
233
 
233
234
  if (response.body) {
234
- const { Readable, pipeline } = await import('node:stream');
235
235
  // @ts-ignore
236
236
  const nodeStream = Readable.fromWeb(response.body);
237
237
  // 持久 error handler 兜底:防止 pipeline 清理后延迟到达的 error 事件导致进程崩溃
@@ -388,15 +388,59 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
388
388
  res.writeHead(finalStatus, responseHeaders);
389
389
 
390
390
  if (response.body) {
391
- const { Readable, pipeline } = await import('node:stream');
392
391
  // @ts-ignore
393
392
  const nodeStream = Readable.fromWeb(response.body);
394
393
  nodeStream.on('error', () => {});
395
- pipeline(nodeStream, res, (err) => {
396
- if (err && process.env.CCV_DEBUG) {
397
- console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
398
- }
399
- });
394
+ const streamIdleMs = retryConfig && retryConfig.mode === 'off'
395
+ ? (retryConfig.streamIdleTimeoutMs ?? 0)
396
+ : 0;
397
+ // Only gate SSE (streaming) responses: a non-stream body legitimately arrives as one
398
+ // whole payload after a long upstream think, and must not be truncated mid-wait.
399
+ if (streamIdleMs > 0 && isStreamResponse(response)) {
400
+ // Off-mode-only idle watchdog: retry modes already attach the equivalent
401
+ // guard on the Web-stream side upstream (applyStreamIdleWatchdog). That
402
+ // variant cannot run for off mode — its pipeThrough races the
403
+ // interceptor's getReader() body reconstruction (spurious `fetch failed`).
404
+ // This Node-side Transform sits after Readable.fromWeb, so it is immune
405
+ // to that race. The timer arms at construction, BEFORE the first chunk,
406
+ // so a stream that never sends its first byte is still bounded.
407
+ let watchdog = null;
408
+ let idleTimer = null;
409
+ const disarmIdle = () => {
410
+ if (idleTimer) {
411
+ clearTimeout(idleTimer);
412
+ idleTimer = null;
413
+ }
414
+ };
415
+ const armIdle = () => {
416
+ disarmIdle();
417
+ idleTimer = setTimeout(() => {
418
+ idleTimer = null;
419
+ // Log unconditionally: a destroyed stream surfaces downstream as an unexplained
420
+ // `fetch failed`; without this line there is no way to attribute it.
421
+ console.warn(`[CC-Viewer Proxy] off-mode stream idle timeout (${streamIdleMs}ms) — destroying upstream stream`);
422
+ watchdog.destroy(new Error(`proxy stream idle timeout (${streamIdleMs}ms)`));
423
+ }, streamIdleMs);
424
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
425
+ };
426
+ watchdog = new Transform({
427
+ transform(chunk, _enc, cb) { armIdle(); cb(null, chunk); },
428
+ flush(cb) { disarmIdle(); cb(); },
429
+ destroy(err, cb) { disarmIdle(); cb(err); },
430
+ });
431
+ armIdle();
432
+ pipeline(nodeStream, watchdog, res, (err) => {
433
+ if (err && process.env.CCV_DEBUG) {
434
+ console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
435
+ }
436
+ });
437
+ } else {
438
+ pipeline(nodeStream, res, (err) => {
439
+ if (err && process.env.CCV_DEBUG) {
440
+ console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
441
+ }
442
+ });
443
+ }
400
444
  } else if (response.text) {
401
445
  // 竞速失败兜底 response:用 text() 取 body
402
446
  const text = await response.text();
@@ -548,6 +548,29 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
548
548
  // encoding. Normalize only this mutation's containment comparisons.
549
549
  let writeRoot;
550
550
  const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
551
+ // isPathContained realpaths its target, so it returns false for a not-yet-existing
552
+ // file — which wrongly 403s every new-file save in target mode. For a missing leaf,
553
+ // realpath the nearest existing ancestor, re-append the descent, then prefix-compare
554
+ // against the (already-real) write root; an existing path keeps the strict realpath.
555
+ const isInsideWriteRootAllowNew = (path) => {
556
+ if (isInsideWriteRoot(path)) return true;
557
+ if (existsSync(path)) return false; // exists but resolves outside → strict deny
558
+ const root = writeRoot.endsWith(sep) ? writeRoot : writeRoot + sep;
559
+ let cursor = resolve(path, '..');
560
+ const descent = [basename(path)];
561
+ for (let depth = 0; depth < 32; depth++) {
562
+ if (existsSync(cursor)) {
563
+ let rebuilt;
564
+ try { rebuilt = join(realpathSync(cursor), ...descent); } catch { return false; }
565
+ return rebuilt === writeRoot || rebuilt.startsWith(root);
566
+ }
567
+ const parent = resolve(cursor, '..');
568
+ if (parent === cursor) return false;
569
+ descent.unshift(basename(cursor));
570
+ cursor = parent;
571
+ }
572
+ return false;
573
+ };
551
574
  const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
552
575
  let body = '';
553
576
  let overflow = false;
@@ -685,7 +708,7 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
685
708
  // exists OR is being created — otherwise a crafted relative path could write
686
709
  // outside the navigation root (isWriteAllowed only bounds by the allowlist).
687
710
  // Project mode keeps the legacy existing-file-only check.
688
- if (!editorSession && targetNavRoot && !isInsideWriteRoot(targetReal)) {
711
+ if (!editorSession && targetNavRoot && !isInsideWriteRootAllowNew(targetReal)) {
689
712
  res.writeHead(403, { 'Content-Type': 'application/json' });
690
713
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
691
714
  return;
package/server/server.js CHANGED
@@ -2216,11 +2216,10 @@ async function setupTerminalWebSocket(httpServer) {
2216
2216
  const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? (projectKeyForCwd(frame.cwd) || null) : null;
2217
2217
  return !fproj || fproj === viewProject; // no-cwd frame → replay; else must match
2218
2218
  };
2219
- const replaySdkPendingApprovals = ({ planOnly = false } = {}) => {
2219
+ const replaySdkPendingApprovals = () => {
2220
2220
  if (!_sdkGetPendingApprovals) return;
2221
2221
  for (const p of _sdkGetPendingApprovals() || []) {
2222
2222
  if (!p || !p.replay || !p.id) continue;
2223
- if (planOnly && p.kind !== 'plan') continue;
2224
2223
  const payload = { ...p.replay };
2225
2224
  if (!_sdkFrameForView(payload)) continue;
2226
2225
  // The generic replay filter is project-scoped. When an exact instance
@@ -2459,9 +2458,24 @@ async function setupTerminalWebSocket(httpServer) {
2459
2458
  }
2460
2459
  }
2461
2460
  // View changes use attach rather than a new connection: restore that view's
2462
- // pending approvals too, so a plan cleared on switch-away can be answered
2463
- // after switching back instead of waiting for the server timeout.
2464
- replaySdkPendingApprovals({ planOnly: true });
2461
+ // pending approvals too, so a plan/ask/perm cleared on switch-away can be
2462
+ // answered after switching back instead of waiting for the server timeout.
2463
+ // (Was plan-only; ask/perm were orphaned into a timeout deny on re-attach.
2464
+ // The replay helpers already filter by the just-stamped view project+instance,
2465
+ // and the frontend dedupes by id, so a full replay never double-opens a modal.)
2466
+ replaySdkPendingApprovals();
2467
+ // Re-announce the just-viewed workspace's turn state so the spinner / Stop
2468
+ // button reflects it immediately (otherwise a long-running turn shows idle
2469
+ // until the next state change or heartbeat).
2470
+ if (_sdkGetSessionStateSnapshot) {
2471
+ const stateAnchor = ws._ccvViewInstance
2472
+ ? { instanceKey: ws._ccvViewInstance }
2473
+ : (ws._ccvViewProject ? { project: ws._ccvViewProject } : null);
2474
+ const stateSnap = stateAnchor ? _sdkGetSessionStateSnapshot(stateAnchor) : _sdkGetSessionStateSnapshot();
2475
+ if (stateSnap && (!ws._ccvViewInstance || stateSnap.instanceKey === ws._ccvViewInstance)) {
2476
+ try { ws.send(JSON.stringify(stateSnap)); } catch (err) { reportSwallowed('sdk.session-state-replay', err); }
2477
+ }
2478
+ }
2465
2479
  replayQueueSnapshot();
2466
2480
  return;
2467
2481
  }
@@ -2879,7 +2893,11 @@ async function setupTerminalWebSocket(httpServer) {
2879
2893
  if (_sdkSendUserMessage && msg.text) {
2880
2894
  const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2881
2895
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2882
- console.error('[SDK] sendUserMessage error:', err.message);
2896
+ reportSwallowed('server.sdk-send', err);
2897
+ // Directed back to the initiator ONLY (a global broadcast would show every
2898
+ // viewer a send failure for a message only one of them typed).
2899
+ try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
2900
+ catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
2883
2901
  });
2884
2902
  }
2885
2903
  } else if (msg.type === 'sdk-interrupt') {
@@ -2897,6 +2915,25 @@ async function setupTerminalWebSocket(httpServer) {
2897
2915
  // 切非运行态并等 4s 兜底才翻回,输出仍在流 —— 打日志让这类错配可观测。
2898
2916
  console.warn('[SDK] sdk-interrupt ignored: isSdkMode=%s, handler=%s', isSdkMode, !!_sdkInterruptTurn);
2899
2917
  }
2918
+ } else if (msg.type === 'sdk-retry-turn') {
2919
+ // Manual [retry] of the last failed (retryable) turn: pushes a synthetic
2920
+ // continue instruction instead of resending the user's original message.
2921
+ // Refusals go back ONLY to the initiating ws (a global broadcast would show
2922
+ // every viewer a reject for an action only one of them took).
2923
+ if (isSdkMode && _sdkRetryTurn) {
2924
+ const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2925
+ _sdkRetryTurn(typeof msg.turnId === 'string' ? msg.turnId : null, anchor).then((r) => {
2926
+ if (!r || r.ok) return;
2927
+ // Echo the view stamps so a user who switched views before the reply
2928
+ // lands doesn't see the rejection (and its sdkRetry clear) in the new view.
2929
+ try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: r.reason || 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
2930
+ catch (err) { reportSwallowed('server.sdk-retry-reject', err); }
2931
+ }).catch((err) => {
2932
+ reportSwallowed('server.sdk-retry-turn', err);
2933
+ try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
2934
+ catch (e2) { reportSwallowed('server.sdk-retry-reject', e2); }
2935
+ });
2936
+ }
2900
2937
  } else if (msg.type === 'queue-message') {
2901
2938
  // Chat-composer busy-queue enqueue (terminal-hidden PTY mode). These queue-* types
2902
2939
  // are deliberately NOT in the SDK no-op gate above: each branch routes by mode.
@@ -2907,7 +2944,9 @@ async function setupTerminalWebSocket(httpServer) {
2907
2944
  if (_sdkSendUserMessage) {
2908
2945
  const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2909
2946
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2910
- console.error('[SDK] queue-message relay error:', err.message);
2947
+ reportSwallowed('server.sdk-queue-relay', err);
2948
+ try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
2949
+ catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
2911
2950
  });
2912
2951
  }
2913
2952
  } else {
@@ -3007,7 +3046,7 @@ async function setupTerminalWebSocket(httpServer) {
3007
3046
  _armRedrawRetries();
3008
3047
  }
3009
3048
  }
3010
- } catch {}
3049
+ } catch (err) { reportSwallowed('server.ws-dispatch', err); }
3011
3050
  });
3012
3051
 
3013
3052
  ws.on('close', () => {
@@ -3805,6 +3844,10 @@ export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }
3805
3844
  let _sdkInterruptTurn = null;
3806
3845
  export function setSdkInterruptTurn(fn) { _sdkInterruptTurn = fn; }
3807
3846
 
3847
+ /** Reference to sdk-manager's retryInterruptedTurn (set by cli.js after import). */
3848
+ let _sdkRetryTurn = null;
3849
+ export function setSdkRetryTurn(fn) { _sdkRetryTurn = fn; }
3850
+
3808
3851
  /** Reference to sdk-manager's getPendingApprovals (set by cli.js after import). */
3809
3852
  let _sdkGetPendingApprovals = null;
3810
3853
  export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }