@azumag/opencode-rate-limit-fallback 1.49.0 → 1.50.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.
@@ -29,7 +29,10 @@ export declare class ConfigReloader {
29
29
  private worktree?;
30
30
  private notifyOnReload;
31
31
  private reloadMetrics;
32
- constructor(config: PluginConfig, configPath: string | null, logger: Logger, validator: ConfigValidator, client: OpenCodeClient, components: ComponentRefs, directory: string, worktree?: string, notifyOnReload?: boolean);
32
+ private minReloadIntervalMs;
33
+ private recentReloadAttempts;
34
+ private maxReloadAttemptsPerMinute;
35
+ constructor(config: PluginConfig, configPath: string | null, logger: Logger, validator: ConfigValidator, client: OpenCodeClient, components: ComponentRefs, directory: string, worktree?: string, notifyOnReload?: boolean, minReloadIntervalMs?: number, maxReloadAttemptsPerMinute?: number);
33
36
  /**
34
37
  * Reload configuration from file
35
38
  */
@@ -50,6 +53,10 @@ export declare class ConfigReloader {
50
53
  * Get reload metrics
51
54
  */
52
55
  getReloadMetrics(): ReloadMetrics;
56
+ /**
57
+ * Check if reload is allowed based on rate limiting
58
+ */
59
+ private checkRateLimit;
53
60
  /**
54
61
  * Show success toast notification
55
62
  */
@@ -17,7 +17,11 @@ export class ConfigReloader {
17
17
  worktree;
18
18
  notifyOnReload;
19
19
  reloadMetrics;
20
- constructor(config, configPath, logger, validator, client, components, directory, worktree, notifyOnReload = true) {
20
+ // Rate limiting for reload operations
21
+ minReloadIntervalMs;
22
+ recentReloadAttempts;
23
+ maxReloadAttemptsPerMinute;
24
+ constructor(config, configPath, logger, validator, client, components, directory, worktree, notifyOnReload = true, minReloadIntervalMs = 1000, maxReloadAttemptsPerMinute = 10) {
21
25
  this.config = config;
22
26
  this.configPath = configPath;
23
27
  this.logger = logger;
@@ -32,6 +36,10 @@ export class ConfigReloader {
32
36
  successfulReloads: 0,
33
37
  failedReloads: 0,
34
38
  };
39
+ // Rate limiting settings
40
+ this.minReloadIntervalMs = minReloadIntervalMs;
41
+ this.maxReloadAttemptsPerMinute = maxReloadAttemptsPerMinute;
42
+ this.recentReloadAttempts = [];
35
43
  }
36
44
  /**
37
45
  * Reload configuration from file
@@ -41,9 +49,17 @@ export class ConfigReloader {
41
49
  success: false,
42
50
  timestamp: Date.now(),
43
51
  };
52
+ // Rate limiting check
53
+ const rateLimitCheck = this.checkRateLimit();
54
+ if (!rateLimitCheck.allowed) {
55
+ result.error = rateLimitCheck.reason || 'Rate limit exceeded';
56
+ this.logger.warn(`Config reload blocked: ${result.error}`);
57
+ return result;
58
+ }
44
59
  // Track reload metrics
45
60
  this.reloadMetrics.totalReloads++;
46
61
  this.reloadMetrics.lastReloadTime = result.timestamp;
62
+ this.recentReloadAttempts.push(result.timestamp);
47
63
  if (!this.configPath) {
48
64
  result.error = 'No config file path available';
49
65
  this.reloadMetrics.failedReloads++;
@@ -173,6 +189,34 @@ export class ConfigReloader {
173
189
  getReloadMetrics() {
174
190
  return { ...this.reloadMetrics };
175
191
  }
192
+ /**
193
+ * Check if reload is allowed based on rate limiting
194
+ */
195
+ checkRateLimit() {
196
+ const now = Date.now();
197
+ const oneMinuteAgo = now - 60000;
198
+ // Clean up old reload attempts
199
+ this.recentReloadAttempts = this.recentReloadAttempts.filter(timestamp => timestamp > oneMinuteAgo);
200
+ // Check minimum interval between reloads
201
+ if (this.reloadMetrics.lastReloadTime) {
202
+ const timeSinceLastReload = now - this.reloadMetrics.lastReloadTime;
203
+ if (timeSinceLastReload < this.minReloadIntervalMs) {
204
+ const waitTime = this.minReloadIntervalMs - timeSinceLastReload;
205
+ return {
206
+ allowed: false,
207
+ reason: `Too soon. Wait ${waitTime}ms before reloading`,
208
+ };
209
+ }
210
+ }
211
+ // Check maximum attempts per minute
212
+ if (this.recentReloadAttempts.length >= this.maxReloadAttemptsPerMinute) {
213
+ return {
214
+ allowed: false,
215
+ reason: `Rate limit exceeded. Maximum ${this.maxReloadAttemptsPerMinute} reloads per minute`,
216
+ };
217
+ }
218
+ return { allowed: true };
219
+ }
176
220
  /**
177
221
  * Show success toast notification
178
222
  */
@@ -2,7 +2,7 @@
2
2
  * Configuration loading and validation
3
3
  */
4
4
  import { existsSync, readFileSync } from "fs";
5
- import { join } from "path";
5
+ import { join, resolve, normalize, relative } from "path";
6
6
  import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, DEFAULT_CIRCUIT_BREAKER_CONFIG, } from '../types/index.js';
7
7
  import { DEFAULT_HEALTH_TRACKER_CONFIG, DEFAULT_COOLDOWN_MS, DEFAULT_FALLBACK_MODE, DEFAULT_LOG_CONFIG, DEFAULT_METRICS_CONFIG, DEFAULT_CONFIG_RELOAD_CONFIG, } from '../config/defaults.js';
8
8
  /**
@@ -20,6 +20,32 @@ export const DEFAULT_CONFIG = {
20
20
  metrics: DEFAULT_METRICS_CONFIG,
21
21
  configReload: DEFAULT_CONFIG_RELOAD_CONFIG,
22
22
  };
23
+ /**
24
+ * Validate that a path does not contain directory traversal attempts
25
+ */
26
+ function validatePathSafety(path, allowedDirs) {
27
+ try {
28
+ const resolvedPath = resolve(path);
29
+ const normalizedPath = normalize(path);
30
+ // Check for obvious path traversal patterns
31
+ if (normalizedPath.includes('..')) {
32
+ return false;
33
+ }
34
+ // Check that resolved path is within allowed directories
35
+ for (const allowedDir of allowedDirs) {
36
+ const resolvedAllowedDir = resolve(allowedDir);
37
+ const relativePath = relative(resolvedAllowedDir, resolvedPath);
38
+ // If relative path does not start with '..', the path is within the allowed directory
39
+ if (!relativePath.startsWith('..')) {
40
+ return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
23
49
  /**
24
50
  * Validate configuration values
25
51
  */
@@ -70,18 +96,18 @@ export function loadConfig(directory, worktree, logger) {
70
96
  // Build search paths: worktree first, then directory, then home locations
71
97
  const searchDirs = [];
72
98
  if (worktree) {
73
- searchDirs.push(worktree);
99
+ searchDirs.push(resolve(worktree));
74
100
  }
75
101
  if (!worktree || worktree !== directory) {
76
- searchDirs.push(directory);
102
+ searchDirs.push(resolve(directory));
77
103
  }
104
+ searchDirs.push(resolve(homedir));
105
+ searchDirs.push(resolve(xdgConfigHome));
78
106
  const configPaths = [];
79
107
  for (const dir of searchDirs) {
80
108
  configPaths.push(join(dir, ".opencode", "rate-limit-fallback.json"));
81
109
  configPaths.push(join(dir, "rate-limit-fallback.json"));
82
110
  }
83
- configPaths.push(join(homedir, ".opencode", "rate-limit-fallback.json"));
84
- configPaths.push(join(xdgConfigHome, "opencode", "rate-limit-fallback.json"));
85
111
  // Log search paths for debugging
86
112
  if (logger) {
87
113
  logger.debug(`Searching for config file in ${configPaths.length} locations`);
@@ -92,6 +118,13 @@ export function loadConfig(directory, worktree, logger) {
92
118
  }
93
119
  for (const configPath of configPaths) {
94
120
  if (existsSync(configPath)) {
121
+ // Validate path safety before reading
122
+ if (!validatePathSafety(configPath, searchDirs)) {
123
+ if (logger) {
124
+ logger.warn(`Config file rejected due to path validation: ${configPath}`);
125
+ }
126
+ continue;
127
+ }
95
128
  try {
96
129
  const content = readFileSync(configPath, "utf-8");
97
130
  const userConfig = JSON.parse(content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.49.0",
3
+ "version": "1.50.0",
4
4
  "description": "OpenCode plugin that automatically switches to fallback models when rate limited",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",