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.
- package/.env.example +21 -2
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +31 -23
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +7 -7
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/1.pack +0 -0
- package/.next/cache/webpack/client-production/2.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/required-server-files.json +1 -1
- package/.next/routes-manifest.json +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/api/app/update-check/route.js +1 -1
- package/.next/server/app/api/auth/login/route.js +1 -0
- package/.next/server/app/api/auth/login/route.js.nft.json +1 -0
- package/.next/server/app/api/auth/logout/route.js +1 -0
- package/.next/server/app/api/auth/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/auth/status/route.js +1 -0
- package/.next/server/app/api/auth/status/route.js.nft.json +1 -0
- package/.next/server/app/api/hooks/claude-done/route.js +1 -1
- package/.next/server/app/api/hooks/claude-done/route.js.nft.json +1 -1
- package/.next/server/app/api/repositories/route.js +2 -2
- package/.next/server/app/api/repositories/route.js.nft.json +1 -1
- package/.next/server/app/api/slash-commands/route.js +1 -1
- package/.next/server/app/api/slash-commands/route.js.nft.json +1 -1
- package/.next/server/app/api/slash-commands.body +1 -1
- package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/slash-commands/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/page.js +2 -2
- package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page.js +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +11 -7
- package/.next/server/chunks/3013.js +1 -0
- package/.next/server/chunks/3074.js +1 -0
- package/.next/server/chunks/{1287.js → 3294.js} +2 -2
- package/.next/server/chunks/3860.js +1 -1
- package/.next/server/chunks/4893.js +2 -2
- package/.next/server/chunks/539.js +35 -0
- package/.next/server/chunks/5795.js +1 -0
- package/.next/server/chunks/7536.js +1 -1
- package/.next/server/chunks/7566.js +19 -0
- package/.next/server/chunks/8693.js +1 -1
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-manifest.json +28 -2
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/server/src/middleware.js +14 -0
- package/.next/server/src/middleware.js.map +1 -0
- package/.next/static/chunks/{2626.2125083a1ff3b80a.js → 6163.f672451d4575decf.js} +1 -1
- package/.next/static/chunks/{656.d72f25ce819bd77e.js → 656.5e2de0173f5a06bd.js} +1 -1
- package/.next/static/chunks/8091-925542bdfc843dce.js +1 -0
- package/.next/static/chunks/8528-4d554d3b94d4cf9b.js +1 -0
- package/.next/static/chunks/app/{layout-07755491d5d57242.js → layout-9110f9a5e41c6bf4.js} +1 -1
- package/.next/static/chunks/app/login/page-2d42204ba87cd136.js +1 -0
- package/.next/static/chunks/app/page-238b5a70d8c101e9.js +1 -0
- package/.next/static/chunks/app/worktrees/[id]/page-a556551ce5c69dec.js +1 -0
- package/.next/static/chunks/main-db79434ee4a6c931.js +1 -0
- package/.next/static/chunks/webpack-3c0ee3ce5b546818.js +1 -0
- package/.next/static/css/b9ea6a4fad17dc32.css +3 -0
- package/.next/trace +5 -5
- package/.next/types/app/api/auth/login/route.ts +343 -0
- package/.next/types/app/api/auth/logout/route.ts +343 -0
- package/.next/types/app/api/auth/status/route.ts +343 -0
- package/.next/types/app/login/page.ts +79 -0
- package/README.md +6 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2 -0
- package/dist/cli/commands/start.d.ts +2 -0
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +159 -14
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +4 -0
- package/dist/cli/config/security-messages.d.ts +3 -1
- package/dist/cli/config/security-messages.d.ts.map +1 -1
- package/dist/cli/config/security-messages.js +6 -2
- package/dist/cli/index.js +17 -0
- package/dist/cli/types/index.d.ts +17 -0
- package/dist/cli/types/index.d.ts.map +1 -1
- package/dist/cli/utils/daemon.d.ts.map +1 -1
- package/dist/cli/utils/daemon.js +16 -3
- package/dist/config/auth-config.d.ts +43 -0
- package/dist/config/auth-config.d.ts.map +1 -0
- package/dist/config/auth-config.js +112 -0
- package/dist/lib/auth.d.ts +104 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +250 -0
- package/dist/server/server.js +123 -12
- package/dist/server/src/config/auth-config.js +112 -0
- package/dist/server/src/lib/auth.js +250 -0
- package/dist/server/src/lib/auto-yes-manager.js +180 -96
- package/dist/server/src/lib/claude-session.js +31 -6
- package/dist/server/src/lib/cli-patterns.js +1 -1
- package/dist/server/src/lib/ip-restriction.js +241 -0
- package/dist/server/src/lib/response-poller.js +3 -2
- package/dist/server/src/lib/ws-server.js +63 -33
- package/dist/server/src/types/slash-commands.js +1 -0
- package/package.json +2 -2
- package/.next/server/chunks/9238.js +0 -35
- package/.next/server/chunks/9367.js +0 -19
- package/.next/static/chunks/5970-2e18108d0cabd8af.js +0 -1
- package/.next/static/chunks/816-af44cb865b0c980e.js +0 -1
- package/.next/static/chunks/app/page-a6593b9640df66a6.js +0 -1
- package/.next/static/chunks/app/worktrees/[id]/page-d9a7913679eccfd9.js +0 -1
- package/.next/static/chunks/main-f00f82f1cf18dd99.js +0 -1
- package/.next/static/chunks/webpack-e6531fcf859d9451.js +0 -1
- package/.next/static/css/897ffb669f47c97b.css +0 -3
- /package/.next/static/{oUEq-Bd47xtkJcFDOI6rr → hmAjbCPjxX_C0Os7rphI1}/_buildManifest.js +0 -0
- /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
|
-
//
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
98
|
+
"vitest": "^4.0.16"
|
|
99
99
|
}
|
|
100
100
|
}
|