commandmate 0.2.12 → 0.3.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 (155) hide show
  1. package/.env.example +21 -2
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/app-build-manifest.json +30 -22
  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 +3 -3
  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 +13 -9
  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-9418e49bdc1de02c.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/ip-restriction.js +241 -0
  141. package/dist/server/src/lib/response-poller.js +92 -39
  142. package/dist/server/src/lib/ws-server.js +63 -33
  143. package/dist/server/src/types/slash-commands.js +1 -0
  144. package/package.json +2 -2
  145. package/.next/server/chunks/9238.js +0 -35
  146. package/.next/server/chunks/9367.js +0 -19
  147. package/.next/static/chunks/5970-2e18108d0cabd8af.js +0 -1
  148. package/.next/static/chunks/816-af44cb865b0c980e.js +0 -1
  149. package/.next/static/chunks/app/page-a6593b9640df66a6.js +0 -1
  150. package/.next/static/chunks/app/worktrees/[id]/page-c99258f57461962c.js +0 -1
  151. package/.next/static/chunks/main-f00f82f1cf18dd99.js +0 -1
  152. package/.next/static/chunks/webpack-af8567a485ade35a.js +0 -1
  153. package/.next/static/css/897ffb669f47c97b.css +0 -3
  154. /package/.next/static/{ym6mA6Dl9wX62h3AoYO45 → clTo9tuAoPMLcGRuVENfO}/_buildManifest.js +0 -0
  155. /package/.next/static/{ym6mA6Dl9wX62h3AoYO45 → clTo9tuAoPMLcGRuVENfO}/_ssgManifest.js +0 -0
@@ -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
+ }
@@ -17,6 +17,7 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.cleanClaudeResponse = cleanClaudeResponse;
19
19
  exports.cleanGeminiResponse = cleanGeminiResponse;
20
+ exports.resolveExtractionStartIndex = resolveExtractionStartIndex;
20
21
  exports.startPolling = startPolling;
21
22
  exports.stopPolling = stopPolling;
22
23
  exports.stopAllPolling = stopAllPolling;
@@ -72,6 +73,31 @@ const GEMINI_LOADING_INDICATORS = [
72
73
  function incompleteResult(lineCount) {
73
74
  return { response: '', isComplete: false, lineCount };
74
75
  }
76
+ /**
77
+ * Build a complete ExtractionResult for a detected prompt.
78
+ *
79
+ * Shared between Claude early prompt detection (section 3-4, site 1) and
80
+ * fallback prompt detection (section 3-4, site 2) in extractResponse().
81
+ * Applies resolveExtractionStartIndex() to limit extraction to lastCapturedLine
82
+ * onwards, then strips ANSI codes for safe DB storage (Stage 4 MF-001).
83
+ *
84
+ * @param lines - The trimmed tmux buffer lines array
85
+ * @param lastCapturedLine - Number of lines previously captured
86
+ * @param totalLines - Total line count in the buffer
87
+ * @param bufferReset - External buffer reset flag
88
+ * @param cliToolId - CLI tool identifier
89
+ * @param findRecentUserPromptIndex - Callback to locate the most recent user prompt
90
+ * @returns ExtractionResult with isComplete: true and ANSI-stripped response
91
+ */
92
+ function buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex) {
93
+ const startIndex = resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
94
+ const extractedLines = lines.slice(startIndex);
95
+ return {
96
+ response: (0, cli_patterns_1.stripAnsi)(extractedLines.join('\n')),
97
+ isComplete: true,
98
+ lineCount: totalLines,
99
+ };
100
+ }
75
101
  /**
76
102
  * Active pollers map: "worktreeId:cliToolId" -> NodeJS.Timeout
77
103
  */
@@ -223,6 +249,64 @@ function cleanGeminiResponse(response) {
223
249
  }
224
250
  return cleanedLines.join('\n').trim();
225
251
  }
252
+ /**
253
+ * Determine the start index for response extraction based on buffer state.
254
+ * Shared between normal response extraction and prompt detection paths.
255
+ *
256
+ * Implements a 4-branch decision tree for startIndex determination:
257
+ * 1. bufferWasReset -> findRecentUserPromptIndex(40) + 1, or 0 if not found
258
+ * 2. cliToolId === 'codex' -> Math.max(0, lastCapturedLine)
259
+ * 3. lastCapturedLine >= totalLines - 5 (scroll boundary) ->
260
+ * findRecentUserPromptIndex(50) + 1, or totalLines - 40 if not found
261
+ * 4. Normal case -> Math.max(0, lastCapturedLine)
262
+ *
263
+ * `bufferWasReset` is computed internally from `lastCapturedLine`, `totalLines`,
264
+ * and `bufferReset`. Callers do NOT need to pre-compute `bufferWasReset`.
265
+ * (Design: MF-001 responsibility boundary)
266
+ *
267
+ * Design references:
268
+ * - Issue #326 design policy section 3-2 (4-branch startIndex table)
269
+ * - Stage 4 SF-001: Defensive validation (negative lastCapturedLine clamped to 0)
270
+ * - Stage 1 SF-001: findRecentUserPromptIndex as callback for SRP/testability
271
+ *
272
+ * @param lastCapturedLine - Number of lines previously captured from the tmux buffer.
273
+ * Negative values are defensively clamped to 0 (Stage 4 SF-001).
274
+ * @param totalLines - Total number of (non-empty-trailing) lines in the current tmux buffer.
275
+ * @param bufferReset - External flag indicating the buffer was reset (e.g., session restart).
276
+ * Combined with `lastCapturedLine >= totalLines` to derive internal `bufferWasReset`.
277
+ * @param cliToolId - CLI tool identifier. Affects branch 2 (Codex-specific path).
278
+ * Note: When called from the Claude early prompt detection path (section 3-4),
279
+ * cliToolId is always 'claude', making the Codex branch unreachable in that context.
280
+ * The parameter is retained for the function's generality across all call sites.
281
+ * @param findRecentUserPromptIndex - Callback that searches the tmux buffer backwards
282
+ * for the most recent user prompt line within a given window size.
283
+ * Returns the line index (>= 0) if found, or -1 if not found.
284
+ * @returns The 0-based line index from which response extraction should begin.
285
+ *
286
+ * @internal Exported for testing only
287
+ */
288
+ function resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex) {
289
+ // Defensive validation: clamp negative values to 0 (Stage 4 SF-001)
290
+ lastCapturedLine = Math.max(0, lastCapturedLine);
291
+ // Compute bufferWasReset internally (MF-001: responsibility boundary)
292
+ const bufferWasReset = lastCapturedLine >= totalLines || bufferReset;
293
+ // Branch 1: Buffer was reset - find the most recent user prompt as anchor
294
+ if (bufferWasReset) {
295
+ const foundUserPrompt = findRecentUserPromptIndex(40);
296
+ return foundUserPrompt >= 0 ? foundUserPrompt + 1 : 0;
297
+ }
298
+ // Branch 2: Codex uses lastCapturedLine directly (Codex-specific TUI behavior)
299
+ if (cliToolId === 'codex') {
300
+ return Math.max(0, lastCapturedLine);
301
+ }
302
+ // Branch 3: Near scroll boundary - buffer may have scrolled, search for user prompt
303
+ if (lastCapturedLine >= totalLines - 5) {
304
+ const foundUserPrompt = findRecentUserPromptIndex(50);
305
+ return foundUserPrompt >= 0 ? foundUserPrompt + 1 : Math.max(0, totalLines - 40);
306
+ }
307
+ // Branch 4: Normal case - start from lastCapturedLine
308
+ return Math.max(0, lastCapturedLine);
309
+ }
226
310
  /**
227
311
  * Extract CLI tool response from tmux output
228
312
  * Detects when a CLI tool has completed a response by looking for tool-specific patterns
@@ -278,13 +362,8 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
278
362
  const fullOutput = lines.join('\n');
279
363
  const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
280
364
  if (promptDetection.isPrompt) {
281
- // Return the full output as a complete interactive prompt
282
- // Use the cleaned output without ANSI codes
283
- return {
284
- response: (0, cli_patterns_1.stripAnsi)(fullOutput),
285
- isComplete: true,
286
- lineCount: totalLines,
287
- };
365
+ // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
366
+ return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
288
367
  }
289
368
  }
290
369
  // Strip ANSI codes before pattern matching
@@ -302,32 +381,9 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
302
381
  // CLI tool has completed response
303
382
  // Extract the response content from lastCapturedLine to the separator (not just last 20 lines)
304
383
  const responseLines = [];
305
- // Handle tmux buffer scrolling: if lastCapturedLine >= totalLines, the buffer has scrolled
306
- // In this case, we need to find the response in the current visible buffer
307
- let startIndex;
308
- // For all tools: check if buffer has been reset/cleared (startIndex would be >= totalLines)
309
- // This happens when a session is restarted or buffer is cleared
310
- const bufferWasReset = lastCapturedLine >= totalLines || bufferReset;
311
- if (bufferWasReset) {
312
- // Buffer was reset - find the most recent user prompt
313
- const foundUserPrompt = findRecentUserPromptIndex(40);
314
- startIndex = foundUserPrompt >= 0 ? foundUserPrompt + 1 : 0;
315
- }
316
- else if (cliToolId === 'codex') {
317
- // Normal case for Codex: use lastCapturedLine
318
- startIndex = Math.max(0, lastCapturedLine);
319
- }
320
- else if (lastCapturedLine >= totalLines - 5) {
321
- // Buffer may have scrolled - look for the start of the new response
322
- // Find the last user input prompt to identify where the response starts
323
- const foundUserPrompt = findRecentUserPromptIndex(50);
324
- // Start extraction from after the user prompt, or from a safe earlier point
325
- startIndex = foundUserPrompt >= 0 ? foundUserPrompt + 1 : Math.max(0, totalLines - 40);
326
- }
327
- else {
328
- // Normal case: start from lastCapturedLine
329
- startIndex = Math.max(0, lastCapturedLine);
330
- }
384
+ // Determine start index for response extraction using shared helper
385
+ // Handles buffer reset, Codex-specific logic, scroll boundary, and normal cases
386
+ const startIndex = resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
331
387
  let endIndex = totalLines; // Track where extraction actually ended
332
388
  for (let i = startIndex; i < totalLines; i++) {
333
389
  const line = lines[i];
@@ -415,12 +471,9 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
415
471
  const fullOutput = lines.join('\n');
416
472
  const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
417
473
  if (promptDetection.isPrompt) {
418
- // This is an interactive prompt - consider it complete
419
- return {
420
- response: fullOutput,
421
- isComplete: true,
422
- lineCount: totalLines,
423
- };
474
+ // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
475
+ // stripAnsi is applied inside buildPromptExtractionResult (Stage 4 MF-001: XSS risk mitigation)
476
+ return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
424
477
  }
425
478
  // Not a prompt, but we may have a partial response in progress (even if Claude shows a spinner)
426
479
  const responseLines = [];
@@ -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.12",
3
+ "version": "0.3.0",
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
  }