commandmate 0.2.13 → 0.3.1

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 (157) hide show
  1. package/.env.example +21 -2
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/app-build-manifest.json +31 -23
  4. package/.next/app-path-routes-manifest.json +1 -1
  5. package/.next/build-manifest.json +7 -7
  6. package/.next/cache/.tsbuildinfo +1 -1
  7. package/.next/cache/config.json +3 -3
  8. package/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/.next/cache/webpack/client-production/1.pack +0 -0
  10. package/.next/cache/webpack/client-production/2.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack +0 -0
  12. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  13. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  14. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  15. package/.next/cache/webpack/server-production/0.pack +0 -0
  16. package/.next/cache/webpack/server-production/index.pack +0 -0
  17. package/.next/next-server.js.nft.json +1 -1
  18. package/.next/prerender-manifest.json +1 -1
  19. package/.next/react-loadable-manifest.json +2 -2
  20. package/.next/required-server-files.json +1 -1
  21. package/.next/routes-manifest.json +1 -1
  22. package/.next/server/app/_not-found/page.js +1 -1
  23. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  24. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/app/update-check/route.js +1 -1
  26. package/.next/server/app/api/auth/login/route.js +1 -0
  27. package/.next/server/app/api/auth/login/route.js.nft.json +1 -0
  28. package/.next/server/app/api/auth/logout/route.js +1 -0
  29. package/.next/server/app/api/auth/logout/route.js.nft.json +1 -0
  30. package/.next/server/app/api/auth/status/route.js +1 -0
  31. package/.next/server/app/api/auth/status/route.js.nft.json +1 -0
  32. package/.next/server/app/api/hooks/claude-done/route.js +1 -1
  33. package/.next/server/app/api/hooks/claude-done/route.js.nft.json +1 -1
  34. package/.next/server/app/api/repositories/route.js +2 -2
  35. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  36. package/.next/server/app/api/slash-commands/route.js +1 -1
  37. package/.next/server/app/api/slash-commands/route.js.nft.json +1 -1
  38. package/.next/server/app/api/slash-commands.body +1 -1
  39. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  41. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  43. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  44. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  45. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  46. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  48. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  50. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  51. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  52. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  53. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  54. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  55. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  56. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js.nft.json +1 -1
  58. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  59. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  60. package/.next/server/app/api/worktrees/route.js +1 -1
  61. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  62. package/.next/server/app/login/page.js +1 -0
  63. package/.next/server/app/login/page.js.nft.json +1 -0
  64. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  65. package/.next/server/app/page.js +2 -2
  66. package/.next/server/app/page.js.nft.json +1 -1
  67. package/.next/server/app/page_client-reference-manifest.js +1 -1
  68. package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
  69. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  70. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app/worktrees/[id]/page.js +2 -2
  72. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  73. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  74. package/.next/server/app/worktrees/[id]/terminal/page.js +1 -1
  75. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  76. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app-paths-manifest.json +11 -7
  78. package/.next/server/chunks/3013.js +1 -0
  79. package/.next/server/chunks/3074.js +1 -0
  80. package/.next/server/chunks/{1287.js → 3294.js} +2 -2
  81. package/.next/server/chunks/3860.js +1 -1
  82. package/.next/server/chunks/4893.js +2 -2
  83. package/.next/server/chunks/539.js +35 -0
  84. package/.next/server/chunks/5795.js +1 -0
  85. package/.next/server/chunks/7536.js +1 -1
  86. package/.next/server/chunks/7566.js +19 -0
  87. package/.next/server/chunks/8693.js +1 -1
  88. package/.next/server/edge-runtime-webpack.js +2 -0
  89. package/.next/server/edge-runtime-webpack.js.map +1 -0
  90. package/.next/server/functions-config-manifest.json +1 -1
  91. package/.next/server/middleware-build-manifest.js +1 -1
  92. package/.next/server/middleware-manifest.json +28 -2
  93. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  94. package/.next/server/pages/500.html +1 -1
  95. package/.next/server/server-reference-manifest.json +1 -1
  96. package/.next/server/src/middleware.js +14 -0
  97. package/.next/server/src/middleware.js.map +1 -0
  98. package/.next/static/chunks/{2626.2125083a1ff3b80a.js → 6163.f672451d4575decf.js} +1 -1
  99. package/.next/static/chunks/{656.d72f25ce819bd77e.js → 656.5e2de0173f5a06bd.js} +1 -1
  100. package/.next/static/chunks/8091-925542bdfc843dce.js +1 -0
  101. package/.next/static/chunks/8528-4d554d3b94d4cf9b.js +1 -0
  102. package/.next/static/chunks/app/{layout-07755491d5d57242.js → layout-9110f9a5e41c6bf4.js} +1 -1
  103. package/.next/static/chunks/app/login/page-2d42204ba87cd136.js +1 -0
  104. package/.next/static/chunks/app/page-238b5a70d8c101e9.js +1 -0
  105. package/.next/static/chunks/app/worktrees/[id]/page-a556551ce5c69dec.js +1 -0
  106. package/.next/static/chunks/main-db79434ee4a6c931.js +1 -0
  107. package/.next/static/chunks/webpack-3c0ee3ce5b546818.js +1 -0
  108. package/.next/static/css/b9ea6a4fad17dc32.css +3 -0
  109. package/.next/trace +5 -5
  110. package/.next/types/app/api/auth/login/route.ts +343 -0
  111. package/.next/types/app/api/auth/logout/route.ts +343 -0
  112. package/.next/types/app/api/auth/status/route.ts +343 -0
  113. package/.next/types/app/login/page.ts +79 -0
  114. package/README.md +6 -1
  115. package/dist/cli/commands/init.d.ts.map +1 -1
  116. package/dist/cli/commands/init.js +2 -0
  117. package/dist/cli/commands/start.d.ts +2 -0
  118. package/dist/cli/commands/start.d.ts.map +1 -1
  119. package/dist/cli/commands/start.js +159 -14
  120. package/dist/cli/commands/status.d.ts.map +1 -1
  121. package/dist/cli/commands/status.js +4 -0
  122. package/dist/cli/config/security-messages.d.ts +3 -1
  123. package/dist/cli/config/security-messages.d.ts.map +1 -1
  124. package/dist/cli/config/security-messages.js +6 -2
  125. package/dist/cli/index.js +17 -0
  126. package/dist/cli/types/index.d.ts +17 -0
  127. package/dist/cli/types/index.d.ts.map +1 -1
  128. package/dist/cli/utils/daemon.d.ts.map +1 -1
  129. package/dist/cli/utils/daemon.js +16 -3
  130. package/dist/config/auth-config.d.ts +43 -0
  131. package/dist/config/auth-config.d.ts.map +1 -0
  132. package/dist/config/auth-config.js +112 -0
  133. package/dist/lib/auth.d.ts +104 -0
  134. package/dist/lib/auth.d.ts.map +1 -0
  135. package/dist/lib/auth.js +250 -0
  136. package/dist/server/server.js +123 -12
  137. package/dist/server/src/config/auth-config.js +112 -0
  138. package/dist/server/src/lib/auth.js +250 -0
  139. package/dist/server/src/lib/auto-yes-manager.js +180 -96
  140. package/dist/server/src/lib/claude-session.js +31 -6
  141. package/dist/server/src/lib/cli-patterns.js +1 -1
  142. package/dist/server/src/lib/ip-restriction.js +241 -0
  143. package/dist/server/src/lib/response-poller.js +3 -2
  144. package/dist/server/src/lib/ws-server.js +63 -33
  145. package/dist/server/src/types/slash-commands.js +1 -0
  146. package/package.json +2 -2
  147. package/.next/server/chunks/9238.js +0 -35
  148. package/.next/server/chunks/9367.js +0 -19
  149. package/.next/static/chunks/5970-2e18108d0cabd8af.js +0 -1
  150. package/.next/static/chunks/816-af44cb865b0c980e.js +0 -1
  151. package/.next/static/chunks/app/page-a6593b9640df66a6.js +0 -1
  152. package/.next/static/chunks/app/worktrees/[id]/page-d9a7913679eccfd9.js +0 -1
  153. package/.next/static/chunks/main-f00f82f1cf18dd99.js +0 -1
  154. package/.next/static/chunks/webpack-e6531fcf859d9451.js +0 -1
  155. package/.next/static/css/897ffb669f47c97b.css +0 -3
  156. /package/.next/static/{oUEq-Bd47xtkJcFDOI6rr → hmAjbCPjxX_C0Os7rphI1}/_buildManifest.js +0 -0
  157. /package/.next/static/{oUEq-Bd47xtkJcFDOI6rr → hmAjbCPjxX_C0Os7rphI1}/_ssgManifest.js +0 -0
@@ -136,6 +136,17 @@ exports.CLAUDE_PROMPT_POLL_INTERVAL = 200;
136
136
  * 40 is an empirical threshold with safety margin.
137
137
  */
138
138
  const MAX_SHELL_PROMPT_LENGTH = 40;
139
+ /**
140
+ * Number of tail lines used for error pattern detection in isSessionHealthy()
141
+ *
142
+ * Error patterns are only searched within the last N lines of pane output,
143
+ * not the entire buffer. This prevents false negatives where historical
144
+ * (already recovered) errors in the scrollback trigger unhealthy detection.
145
+ *
146
+ * 10 lines provides sufficient window to catch recent errors while ignoring
147
+ * historical ones that have scrolled up.
148
+ */
149
+ const HEALTH_CHECK_ERROR_TAIL_LINES = 10;
139
150
  /**
140
151
  * Cached Claude CLI path
141
152
  */
@@ -265,21 +276,31 @@ async function isSessionHealthy(sessionName) {
265
276
  if (trimmed === '') {
266
277
  return { healthy: false, reason: 'empty output' };
267
278
  }
268
- // S2-F010: Error pattern detection (HealthCheckResult format)
279
+ // Active state detection: check for Claude prompt BEFORE error patterns.
280
+ // This prevents false negatives where historical (recovered) errors in
281
+ // the pane scrollback cause a currently-active session to be marked unhealthy.
282
+ if (cli_patterns_1.CLAUDE_PROMPT_PATTERN.test(trimmed)) {
283
+ return { healthy: true };
284
+ }
285
+ // S2-F010: Error pattern detection - limited to tail lines only.
286
+ // Only the last HEALTH_CHECK_ERROR_TAIL_LINES lines are searched, so
287
+ // historical errors that have scrolled up do not trigger false negatives.
288
+ const allLines = trimmed.split('\n').filter(line => line.trim() !== '');
289
+ const tailLines = allLines.slice(-HEALTH_CHECK_ERROR_TAIL_LINES);
290
+ const tailText = tailLines.join('\n');
269
291
  // MF-001: Check error patterns from cli-patterns.ts (SRP - pattern management centralized)
270
292
  for (const pattern of cli_patterns_1.CLAUDE_SESSION_ERROR_PATTERNS) {
271
- if (trimmed.includes(pattern)) {
293
+ if (tailText.includes(pattern)) {
272
294
  return { healthy: false, reason: `error pattern: ${pattern}` };
273
295
  }
274
296
  }
275
297
  for (const regex of cli_patterns_1.CLAUDE_SESSION_ERROR_REGEX_PATTERNS) {
276
- if (regex.test(trimmed)) {
298
+ if (regex.test(tailText)) {
277
299
  return { healthy: false, reason: `error pattern: ${regex.source}` };
278
300
  }
279
301
  }
280
302
  // S2-F002: Extract last line after empty line filtering
281
- const lines = trimmed.split('\n').filter(line => line.trim() !== '');
282
- const lastLine = lines[lines.length - 1]?.trim() ?? '';
303
+ const lastLine = allLines[allLines.length - 1]?.trim() ?? '';
283
304
  // F006: Line length check BEFORE SHELL_PROMPT_ENDINGS check (early return)
284
305
  if (lastLine.length >= MAX_SHELL_PROMPT_LENGTH) {
285
306
  // Long lines are not shell prompts -> treat as healthy (early return)
@@ -415,7 +436,11 @@ async function isClaudeRunning(worktreeId) {
415
436
  // MF-S3-001: Verify session health to avoid reporting broken sessions as running
416
437
  // S2-F001: await + extract .healthy to maintain boolean return type
417
438
  const result = await isSessionHealthy(sessionName);
418
- return result.healthy;
439
+ if (!result.healthy) {
440
+ console.warn(`[isClaudeRunning] Session ${sessionName} unhealthy: ${result.reason}`);
441
+ return false;
442
+ }
443
+ return true;
419
444
  }
420
445
  /**
421
446
  * Get Claude session state
@@ -268,7 +268,7 @@ exports.CLAUDE_SESSION_ERROR_PATTERNS = [
268
268
  * SEC-SF-004: See CLAUDE_SESSION_ERROR_PATTERNS JSDoc for pattern maintenance process.
269
269
  */
270
270
  exports.CLAUDE_SESSION_ERROR_REGEX_PATTERNS = [
271
- /Error:.*Claude/,
271
+ /^Error:.*Claude Code/,
272
272
  ];
273
273
  function buildDetectPromptOptions(cliToolId) {
274
274
  if (cliToolId === 'claude') {
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ /**
3
+ * IP Restriction Module (Edge Runtime Compatible)
4
+ * Issue #332: Access control by IP address/CIDR range
5
+ *
6
+ * CONSTRAINT: This module must be Edge Runtime compatible.
7
+ * Do NOT import Node.js-specific modules (net, dns, os, fs, etc.).
8
+ *
9
+ * [S3-006] CLI build compatibility constraint:
10
+ * ip-restriction.ts is NOT directly imported from src/cli/.
11
+ * CLI sets CM_ALLOWED_IPS via process.env only; IP restriction logic
12
+ * runs server-side (middleware.ts / ws-server.ts).
13
+ *
14
+ * [S2-004] Difference from auth.ts:
15
+ * auth.ts silently disables on invalid hash (storedTokenHash = undefined).
16
+ * ip-restriction.ts uses fail-fast (throw) on invalid CIDR because
17
+ * silently ignoring security config errors would create a security hole.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.normalizeIp = normalizeIp;
21
+ exports.parseAllowedIps = parseAllowedIps;
22
+ exports.isIpAllowed = isIpAllowed;
23
+ exports.getClientIp = getClientIp;
24
+ exports.getAllowedRanges = getAllowedRanges;
25
+ exports.isIpRestrictionEnabled = isIpRestrictionEnabled;
26
+ // --- Internal constants (unexported) [S1-002] ---
27
+ // Integrated into this module; no external references needed (YAGNI).
28
+ const IPV4_MAPPED_IPV6_PREFIX = '::ffff:';
29
+ const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
30
+ const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
31
+ const MAX_IPV4_PREFIX_LENGTH = 32;
32
+ /** [S4-002] DoS prevention: upper limit on CIDR entry count */
33
+ const MAX_ALLOWED_IP_ENTRIES = 256;
34
+ /** [S4-005] Input validation: max length per entry ('255.255.255.255/32' = 18 chars) */
35
+ const MAX_CIDR_ENTRY_LENGTH = 18;
36
+ // --- Module-scope initialization [S1-003] ---
37
+ // Following auth.ts storedTokenHash pattern: read env once at module load.
38
+ // Placed before functions that depend on these values for clarity.
39
+ const allowedIpsEnv = process.env.CM_ALLOWED_IPS?.trim() || '';
40
+ // [S4-006] CM_TRUST_PROXY value validation:
41
+ // 'true' is the only value that enables proxy trust. Other non-empty values
42
+ // (e.g., 'TRUE', '1', 'yes') fall back to safe default (no proxy trust),
43
+ // and a warning is emitted to help operators detect configuration mistakes.
44
+ const trustProxyEnv = process.env.CM_TRUST_PROXY?.trim() || '';
45
+ if (trustProxyEnv !== '' && trustProxyEnv !== 'true' && trustProxyEnv !== 'false') {
46
+ console.warn(`[IP-RESTRICTION] CM_TRUST_PROXY has unexpected value: "${trustProxyEnv}". ` +
47
+ 'Only "true" (lowercase) enables proxy trust.');
48
+ }
49
+ /** Whether CM_TRUST_PROXY is strictly 'true' */
50
+ const trustProxy = trustProxyEnv === 'true';
51
+ // --- Pure functions ---
52
+ /**
53
+ * Parse an IPv4 address string into a 32-bit unsigned integer.
54
+ * Returns null if the format is invalid or any octet is out of range.
55
+ */
56
+ function ipToInt(ip) {
57
+ const match = ip.match(IPV4_PATTERN);
58
+ if (!match)
59
+ return null;
60
+ const octets = [
61
+ parseInt(match[1], 10),
62
+ parseInt(match[2], 10),
63
+ parseInt(match[3], 10),
64
+ parseInt(match[4], 10),
65
+ ];
66
+ for (const octet of octets) {
67
+ if (octet < 0 || octet > 255)
68
+ return null;
69
+ }
70
+ // Use unsigned right shift (>>> 0) to ensure unsigned 32-bit integer
71
+ return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
72
+ }
73
+ /**
74
+ * IPv4-mapped IPv6 address (::ffff:x.x.x.x) normalization.
75
+ * Returns the IPv4 portion if mapped, otherwise returns as-is.
76
+ */
77
+ function normalizeIp(ip) {
78
+ if (!ip)
79
+ return '';
80
+ const lower = ip.toLowerCase();
81
+ if (lower.startsWith(IPV4_MAPPED_IPV6_PREFIX)) {
82
+ return ip.substring(IPV4_MAPPED_IPV6_PREFIX.length);
83
+ }
84
+ return ip;
85
+ }
86
+ /**
87
+ * Parse CM_ALLOWED_IPS environment variable string into CidrRange array.
88
+ * Throws Error on invalid CIDR format (fail-fast).
89
+ *
90
+ * [S4-002] Throws when entry count exceeds MAX_ALLOWED_IP_ENTRIES (256).
91
+ * Large CIDR entry counts cause parse delay and per-request OR-loop
92
+ * performance degradation.
93
+ *
94
+ * [S4-005] Throws when any entry exceeds MAX_CIDR_ENTRY_LENGTH (18 chars).
95
+ * IPv4 CIDR maximum is '255.255.255.255/32' (18 chars); longer input is
96
+ * rejected before regex matching.
97
+ *
98
+ * @throws {Error} Invalid IP address or CIDR format
99
+ * @throws {Error} Entry count exceeds MAX_ALLOWED_IP_ENTRIES (256)
100
+ * @throws {Error} Individual entry exceeds MAX_CIDR_ENTRY_LENGTH (18 chars)
101
+ */
102
+ function parseAllowedIps(envValue) {
103
+ const trimmed = envValue.trim();
104
+ if (trimmed.length === 0)
105
+ return [];
106
+ const entries = trimmed.split(',').map(e => e.trim()).filter(e => e.length > 0);
107
+ // [S4-002] DoS prevention: entry count upper limit
108
+ if (entries.length > MAX_ALLOWED_IP_ENTRIES) {
109
+ throw new Error(`CM_ALLOWED_IPS: too many entries (${entries.length}). Maximum is ${MAX_ALLOWED_IP_ENTRIES}.`);
110
+ }
111
+ const ranges = [];
112
+ for (const entry of entries) {
113
+ // [S4-005] Entry length validation (before regex)
114
+ if (entry.length > MAX_CIDR_ENTRY_LENGTH) {
115
+ throw new Error(`CM_ALLOWED_IPS: entry "${entry}" exceeds maximum length of ${MAX_CIDR_ENTRY_LENGTH} characters.`);
116
+ }
117
+ // Try CIDR format first (x.x.x.x/N)
118
+ const cidrMatch = entry.match(IPV4_CIDR_PATTERN);
119
+ if (cidrMatch) {
120
+ const octets = [
121
+ parseInt(cidrMatch[1], 10),
122
+ parseInt(cidrMatch[2], 10),
123
+ parseInt(cidrMatch[3], 10),
124
+ parseInt(cidrMatch[4], 10),
125
+ ];
126
+ const prefix = parseInt(cidrMatch[5], 10);
127
+ for (const octet of octets) {
128
+ if (octet < 0 || octet > 255) {
129
+ throw new Error(`CM_ALLOWED_IPS: invalid octet in "${entry}". Octets must be 0-255.`);
130
+ }
131
+ }
132
+ if (prefix < 0 || prefix > MAX_IPV4_PREFIX_LENGTH) {
133
+ throw new Error(`CM_ALLOWED_IPS: invalid prefix length in "${entry}". Must be 0-${MAX_IPV4_PREFIX_LENGTH}.`);
134
+ }
135
+ const network = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
136
+ // Create mask: for prefix=24, mask = 0xFFFFFF00
137
+ // For prefix=0, mask = 0x00000000
138
+ const mask = prefix === 0 ? 0 : ((0xFFFFFFFF << (32 - prefix)) >>> 0);
139
+ ranges.push({ network: (network & mask) >>> 0, mask });
140
+ continue;
141
+ }
142
+ // Try plain IP format (x.x.x.x -> treat as /32)
143
+ const ipMatch = entry.match(IPV4_PATTERN);
144
+ if (ipMatch) {
145
+ const octets = [
146
+ parseInt(ipMatch[1], 10),
147
+ parseInt(ipMatch[2], 10),
148
+ parseInt(ipMatch[3], 10),
149
+ parseInt(ipMatch[4], 10),
150
+ ];
151
+ for (const octet of octets) {
152
+ if (octet < 0 || octet > 255) {
153
+ throw new Error(`CM_ALLOWED_IPS: invalid octet in "${entry}". Octets must be 0-255.`);
154
+ }
155
+ }
156
+ const network = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
157
+ const mask = 0xFFFFFFFF >>> 0;
158
+ ranges.push({ network, mask });
159
+ continue;
160
+ }
161
+ // Neither valid IP nor valid CIDR
162
+ throw new Error(`CM_ALLOWED_IPS: invalid entry "${entry}". Expected IPv4 address or CIDR notation.`);
163
+ }
164
+ return ranges;
165
+ }
166
+ /**
167
+ * Check if an IP address is allowed by any of the given CIDR ranges.
168
+ * Multiple ranges are evaluated with OR logic (match any = allowed).
169
+ */
170
+ function isIpAllowed(ip, ranges) {
171
+ if (ranges.length === 0)
172
+ return false;
173
+ const normalized = normalizeIp(ip);
174
+ const ipInt = ipToInt(normalized);
175
+ if (ipInt === null)
176
+ return false;
177
+ for (const range of ranges) {
178
+ if ((ipInt & range.mask) >>> 0 === range.network) {
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ /**
185
+ * Get client IP from request headers.
186
+ *
187
+ * [S1-004] Request parsing responsibility - separate from CIDR matching
188
+ * (isIpAllowed). These are different responsibilities: request parsing vs
189
+ * IP range evaluation. If proxy-related settings grow (e.g., trusted proxies
190
+ * list via CM_TRUSTED_PROXIES), consider splitting to a separate module
191
+ * (e.g., request-ip.ts).
192
+ *
193
+ * [S4-001] WARNING: When CM_TRUST_PROXY=true, the leftmost IP from
194
+ * X-Forwarded-For is used. An attacker can inject arbitrary IPs at the
195
+ * front of the header. The reverse proxy MUST overwrite X-Forwarded-For
196
+ * with the client IP it received (trusted proxy sets the client IP it
197
+ * received as the first entry). If the proxy does not do this correctly,
198
+ * IP restriction bypass is possible.
199
+ * Future extension: introduce CM_TRUSTED_PROXIES for a trusted proxy IP
200
+ * list and switch to rightmost-non-trusted-IP extraction.
201
+ *
202
+ * @param headers - Request headers with get() method
203
+ * @returns Client IP string or null
204
+ */
205
+ function getClientIp(headers) {
206
+ if (trustProxy) {
207
+ // Trust X-Forwarded-For when CM_TRUST_PROXY=true
208
+ const xff = headers.get('x-forwarded-for');
209
+ if (xff) {
210
+ const firstIp = xff.split(',')[0].trim();
211
+ if (firstIp)
212
+ return firstIp;
213
+ }
214
+ }
215
+ // Default: use X-Real-IP (set by server.ts from socket.remoteAddress)
216
+ return headers.get('x-real-ip') || null;
217
+ }
218
+ // [S1-001] Module-level cache of parsed ranges
219
+ // Shared by middleware.ts and ws-server.ts via getAllowedRanges()
220
+ const cachedRanges = allowedIpsEnv.length > 0
221
+ ? parseAllowedIps(allowedIpsEnv)
222
+ : [];
223
+ /**
224
+ * Return the cached allowed CIDR ranges.
225
+ * Parsed once at module initialization from CM_ALLOWED_IPS.
226
+ *
227
+ * [S1-001] Use this instead of calling parseAllowedIps() each time.
228
+ * DRY cache strategy unified for HTTP and WebSocket layers.
229
+ */
230
+ function getAllowedRanges() {
231
+ return cachedRanges;
232
+ }
233
+ /**
234
+ * Check if IP restriction is enabled.
235
+ *
236
+ * [S1-003] Uses module-scope captured allowedIpsEnv (not process.env).
237
+ * Ensures cache consistency with getAllowedRanges().
238
+ */
239
+ function isIpRestrictionEnabled() {
240
+ return allowedIpsEnv.length > 0;
241
+ }
@@ -35,9 +35,10 @@ const cli_patterns_1 = require("./cli-patterns");
35
35
  */
36
36
  const POLLING_INTERVAL = 2000;
37
37
  /**
38
- * Maximum polling duration in milliseconds (default: 5 minutes)
38
+ * Maximum polling duration in milliseconds (default: 30 minutes)
39
+ * Previously 5 minutes, which caused silent polling stops for long-running tasks.
39
40
  */
40
- const MAX_POLLING_DURATION = 5 * 60 * 1000;
41
+ const MAX_POLLING_DURATION = 30 * 60 * 1000;
41
42
  /**
42
43
  * Number of tail lines to check for active thinking indicators in response extraction.
43
44
  *
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * WebSocket Server for Real-time Communication
4
4
  * Manages WebSocket connections and room-based message broadcasting
5
+ * Issue #331: WebSocket authentication via Cookie header
5
6
  */
6
7
  Object.defineProperty(exports, "__esModule", { value: true });
7
8
  exports.setupWebSocket = setupWebSocket;
@@ -10,14 +11,31 @@ exports.broadcastMessage = broadcastMessage;
10
11
  exports.cleanupRooms = cleanupRooms;
11
12
  exports.closeWebSocket = closeWebSocket;
12
13
  const ws_1 = require("ws");
14
+ const auth_1 = require("./auth");
15
+ const ip_restriction_1 = require("./ip-restriction");
13
16
  // Global state
14
17
  let wss = null;
15
18
  const clients = new Map();
16
19
  const rooms = new Map();
17
20
  /**
18
- * Setup WebSocket server on HTTP server
21
+ * Check if a WebSocket error is an expected non-fatal error.
22
+ * Common causes include mobile browser disconnects sending malformed close frames.
19
23
  *
20
- * @param server - HTTP server instance
24
+ * @param error - Error with optional code property
25
+ * @returns true if the error is expected and can be silently handled
26
+ */
27
+ function isExpectedWebSocketError(error) {
28
+ return (error.code === 'WS_ERR_INVALID_CLOSE_CODE' ||
29
+ error.message?.includes('Invalid WebSocket frame') ||
30
+ error.message?.includes('write after end') ||
31
+ error.message?.includes('ECONNRESET') ||
32
+ error.message?.includes('EPIPE'));
33
+ }
34
+ /**
35
+ * Setup WebSocket server on HTTP or HTTPS server
36
+ * Issue #331: Added auth check on WebSocket upgrade
37
+ *
38
+ * @param server - HTTP or HTTPS server instance
21
39
  *
22
40
  * @example
23
41
  * ```typescript
@@ -31,10 +49,44 @@ function setupWebSocket(server) {
31
49
  // Handle upgrade requests - only accept app WebSocket connections, not Next.js HMR
32
50
  server.on('upgrade', (request, socket, head) => {
33
51
  const pathname = request.url || '/';
34
- // Let Next.js handle its own HMR WebSocket connections
52
+ // Let Next.js handle its own HMR WebSocket connections in development.
53
+ // In production there are no /_next/ WebSocket connections (no HMR).
54
+ // Leaving the socket unhandled in production can trigger the Node.js 'request'
55
+ // event as a fallback on Node.js 19+, causing TypeError in handleRequestImpl
56
+ // because the response has no setHeader (Issue #331).
35
57
  if (pathname.startsWith('/_next/')) {
58
+ if (process.env.NODE_ENV !== 'development') {
59
+ socket.write('HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
60
+ socket.destroy();
61
+ }
36
62
  return;
37
63
  }
64
+ // Issue #332: WebSocket IP restriction
65
+ // [S2-008] Uses request.socket.remoteAddress directly (not getClientIp()).
66
+ // getClientIp() is for HTTP headers (X-Real-IP/X-Forwarded-For);
67
+ // WebSocket upgrade gets IP from the socket connection directly.
68
+ if ((0, ip_restriction_1.isIpRestrictionEnabled)()) {
69
+ const wsClientIp = (0, ip_restriction_1.normalizeIp)(request.socket.remoteAddress || '');
70
+ if (!(0, ip_restriction_1.isIpAllowed)(wsClientIp, (0, ip_restriction_1.getAllowedRanges)())) {
71
+ // [S4-004] Log injection prevention: normalizeIp() + substring(0, 45)
72
+ const safeIp = wsClientIp.substring(0, 45);
73
+ console.warn(`[IP-RESTRICTION] WebSocket denied: ${safeIp}`);
74
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
75
+ socket.destroy();
76
+ return;
77
+ }
78
+ }
79
+ // Issue #331: WebSocket authentication via Cookie header
80
+ if ((0, auth_1.isAuthEnabled)()) {
81
+ const cookieHeader = request.headers.cookie || '';
82
+ const cookies = (0, auth_1.parseCookies)(cookieHeader);
83
+ const token = cookies[auth_1.AUTH_COOKIE_NAME];
84
+ if (!token || !(0, auth_1.verifyToken)(token)) {
85
+ socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
86
+ socket.destroy();
87
+ return;
88
+ }
89
+ }
38
90
  wss.handleUpgrade(request, socket, head, (ws) => {
39
91
  wss.emit('connection', ws, request);
40
92
  });
@@ -57,13 +109,7 @@ function setupWebSocket(server) {
57
109
  const socket = ws._socket;
58
110
  if (socket) {
59
111
  socket.on('error', (err) => {
60
- // Suppress common mobile browser disconnect errors
61
- const isExpectedError = err.code === 'WS_ERR_INVALID_CLOSE_CODE' ||
62
- err.message?.includes('Invalid WebSocket frame') ||
63
- err.message?.includes('write after end') ||
64
- err.message?.includes('ECONNRESET') ||
65
- err.message?.includes('EPIPE');
66
- if (!isExpectedError) {
112
+ if (!isExpectedWebSocketError(err)) {
67
113
  console.error('[WS Socket] Error:', err.message);
68
114
  }
69
115
  // Immediately destroy the socket to prevent further errors
@@ -94,11 +140,7 @@ function setupWebSocket(server) {
94
140
  });
95
141
  // Handle errors (including invalid close codes from mobile browsers)
96
142
  ws.on('error', (error) => {
97
- // Suppress noisy errors from mobile browser disconnects
98
- const isExpectedError = error.code === 'WS_ERR_INVALID_CLOSE_CODE' ||
99
- error.message?.includes('Invalid WebSocket frame') ||
100
- error.message?.includes('write after end');
101
- if (!isExpectedError) {
143
+ if (!isExpectedWebSocketError(error)) {
102
144
  console.error('[WS] WebSocket error:', error.message);
103
145
  }
104
146
  // Immediately terminate to prevent further errors
@@ -111,7 +153,7 @@ function setupWebSocket(server) {
111
153
  handleDisconnect(ws);
112
154
  });
113
155
  });
114
- console.log('WebSocket server initialized');
156
+ // WebSocket server initialization complete (no log in production per CLAUDE.md)
115
157
  }
116
158
  /**
117
159
  * Handle incoming WebSocket message
@@ -137,7 +179,6 @@ function handleMessage(ws, message) {
137
179
  function handleSubscribe(ws, worktreeId) {
138
180
  const clientInfo = clients.get(ws);
139
181
  if (!clientInfo) {
140
- console.log(`[WS] handleSubscribe: clientInfo not found for worktreeId: ${worktreeId}`);
141
182
  return;
142
183
  }
143
184
  // Add worktreeId to client's subscriptions
@@ -148,7 +189,7 @@ function handleSubscribe(ws, worktreeId) {
148
189
  }
149
190
  const room = rooms.get(worktreeId);
150
191
  room.add(ws);
151
- console.log(`Client subscribed to worktree: ${worktreeId}, room size: ${room.size}, ws readyState: ${ws.readyState}`);
192
+ // Client subscribed (no log in production per CLAUDE.md)
152
193
  }
153
194
  /**
154
195
  * Unsubscribe client from a worktree room
@@ -168,20 +209,14 @@ function handleUnsubscribe(ws, worktreeId) {
168
209
  rooms.delete(worktreeId);
169
210
  }
170
211
  }
171
- console.log(`Client unsubscribed from worktree: ${worktreeId}`);
212
+ // Client unsubscribed (no log in production per CLAUDE.md)
172
213
  }
173
214
  /**
174
215
  * Broadcast message to all clients in a worktree room
175
216
  */
176
217
  function handleBroadcast(worktreeId, data) {
177
218
  const room = rooms.get(worktreeId);
178
- console.log(`[WS] handleBroadcast called for ${worktreeId}, room size: ${room?.size || 0}`);
179
- if (!room) {
180
- console.log(`[WS] No room found for ${worktreeId}`);
181
- return;
182
- }
183
- if (room.size === 0) {
184
- console.log(`[WS] Room for ${worktreeId} is empty`);
219
+ if (!room || room.size === 0) {
185
220
  return;
186
221
  }
187
222
  try {
@@ -190,21 +225,16 @@ function handleBroadcast(worktreeId, data) {
190
225
  worktreeId,
191
226
  data,
192
227
  });
193
- let successCount = 0;
194
- let errorCount = 0;
195
228
  room.forEach((client) => {
196
229
  if (client.readyState === ws_1.WebSocket.OPEN) {
197
230
  try {
198
231
  client.send(message);
199
- successCount++;
200
232
  }
201
233
  catch (sendError) {
202
- errorCount++;
203
234
  console.error(`Error sending WebSocket message to client:`, sendError);
204
235
  }
205
236
  }
206
237
  });
207
- console.log(`Broadcast to worktree ${worktreeId}: ${successCount}/${room.size} clients (${errorCount} errors)`);
208
238
  }
209
239
  catch (broadcastError) {
210
240
  console.error(`Error broadcasting to worktree ${worktreeId}:`, broadcastError);
@@ -309,7 +339,7 @@ function cleanupRooms(worktreeIds) {
309
339
  });
310
340
  // Delete the room
311
341
  rooms.delete(worktreeId);
312
- console.log(`[WS] Cleaned up room for worktree: ${worktreeId}`);
342
+ // Room cleaned up (no log in production per CLAUDE.md)
313
343
  }
314
344
  }
315
345
  }
@@ -329,6 +359,6 @@ function closeWebSocket() {
329
359
  // Close server
330
360
  wss.close();
331
361
  wss = null;
332
- console.log('WebSocket server closed');
362
+ // WebSocket server closed (no log in production per CLAUDE.md)
333
363
  }
334
364
  }
@@ -17,6 +17,7 @@ exports.CATEGORY_LABELS = {
17
17
  review: 'Review',
18
18
  documentation: 'Documentation',
19
19
  workflow: 'Workflow',
20
+ skill: 'Skills', // Issue #343: Skills category label
20
21
  // Standard command category labels (Issue #56)
21
22
  'standard-session': 'Standard (Session)',
22
23
  'standard-config': 'Standard (Config)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commandmate",
3
- "version": "0.2.13",
3
+ "version": "0.3.1",
4
4
  "description": "Git worktree management with Claude CLI and tmux sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -95,6 +95,6 @@
95
95
  "tsc-alias": "~1.8.16",
96
96
  "tsx": "^4.20.6",
97
97
  "typescript": "^5.5.0",
98
- "vitest": "^4.0.9"
98
+ "vitest": "^4.0.16"
99
99
  }
100
100
  }