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.
- package/.env.example +21 -2
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +30 -22
- 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 +3 -3
- 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 +13 -9
- 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-9418e49bdc1de02c.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/ip-restriction.js +241 -0
- package/dist/server/src/lib/response-poller.js +92 -39
- 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-c99258f57461962c.js +0 -1
- package/.next/static/chunks/main-f00f82f1cf18dd99.js +0 -1
- package/.next/static/chunks/webpack-af8567a485ade35a.js +0 -1
- package/.next/static/css/897ffb669f47c97b.css +0 -3
- /package/.next/static/{ym6mA6Dl9wX62h3AoYO45 → clTo9tuAoPMLcGRuVENfO}/_buildManifest.js +0 -0
- /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
|
-
//
|
|
282
|
-
|
|
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
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
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
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
*
|
|
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.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.
|
|
98
|
+
"vitest": "^4.0.16"
|
|
99
99
|
}
|
|
100
100
|
}
|