predicate-claw 0.1.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 (136) hide show
  1. package/.github/workflows/release.yml +76 -0
  2. package/.github/workflows/tests.yml +34 -0
  3. package/.markdownlint.yaml +5 -0
  4. package/.pre-commit-config.yaml +100 -0
  5. package/README.md +405 -0
  6. package/dist/src/adapter.d.ts +17 -0
  7. package/dist/src/adapter.js +36 -0
  8. package/dist/src/authority-client.d.ts +21 -0
  9. package/dist/src/authority-client.js +22 -0
  10. package/dist/src/circuit-breaker.d.ts +86 -0
  11. package/dist/src/circuit-breaker.js +174 -0
  12. package/dist/src/config.d.ts +8 -0
  13. package/dist/src/config.js +7 -0
  14. package/dist/src/control-plane-sync.d.ts +57 -0
  15. package/dist/src/control-plane-sync.js +99 -0
  16. package/dist/src/errors.d.ts +6 -0
  17. package/dist/src/errors.js +6 -0
  18. package/dist/src/index.d.ts +12 -0
  19. package/dist/src/index.js +12 -0
  20. package/dist/src/non-web-evidence.d.ts +46 -0
  21. package/dist/src/non-web-evidence.js +54 -0
  22. package/dist/src/openclaw-hooks.d.ts +27 -0
  23. package/dist/src/openclaw-hooks.js +54 -0
  24. package/dist/src/openclaw-plugin-api.d.ts +18 -0
  25. package/dist/src/openclaw-plugin-api.js +17 -0
  26. package/dist/src/provider.d.ts +48 -0
  27. package/dist/src/provider.js +154 -0
  28. package/dist/src/runtime-integration.d.ts +20 -0
  29. package/dist/src/runtime-integration.js +43 -0
  30. package/dist/src/web-evidence.d.ts +48 -0
  31. package/dist/src/web-evidence.js +49 -0
  32. package/dist/tests/adapter.test.d.ts +1 -0
  33. package/dist/tests/adapter.test.js +63 -0
  34. package/dist/tests/audit-event-e2e.test.d.ts +1 -0
  35. package/dist/tests/audit-event-e2e.test.js +209 -0
  36. package/dist/tests/authority-client.test.d.ts +1 -0
  37. package/dist/tests/authority-client.test.js +46 -0
  38. package/dist/tests/circuit-breaker.test.d.ts +1 -0
  39. package/dist/tests/circuit-breaker.test.js +200 -0
  40. package/dist/tests/control-plane-sync.test.d.ts +1 -0
  41. package/dist/tests/control-plane-sync.test.js +90 -0
  42. package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
  43. package/dist/tests/hack-vs-fix-demo.test.js +36 -0
  44. package/dist/tests/jwks-rotation.test.d.ts +1 -0
  45. package/dist/tests/jwks-rotation.test.js +232 -0
  46. package/dist/tests/load-latency.test.d.ts +1 -0
  47. package/dist/tests/load-latency.test.js +175 -0
  48. package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
  49. package/dist/tests/multi-tenant-isolation.test.js +146 -0
  50. package/dist/tests/non-web-evidence.test.d.ts +1 -0
  51. package/dist/tests/non-web-evidence.test.js +139 -0
  52. package/dist/tests/openclaw-hooks.test.d.ts +1 -0
  53. package/dist/tests/openclaw-hooks.test.js +38 -0
  54. package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
  55. package/dist/tests/openclaw-plugin-api.test.js +40 -0
  56. package/dist/tests/provider.test.d.ts +1 -0
  57. package/dist/tests/provider.test.js +190 -0
  58. package/dist/tests/runtime-integration.test.d.ts +1 -0
  59. package/dist/tests/runtime-integration.test.js +57 -0
  60. package/dist/tests/web-evidence.test.d.ts +1 -0
  61. package/dist/tests/web-evidence.test.js +89 -0
  62. package/docs/MIGRATION_GUIDE.md +405 -0
  63. package/docs/OPERATIONAL_RUNBOOK.md +389 -0
  64. package/docs/PRODUCTION_READINESS.md +134 -0
  65. package/docs/SLO_THRESHOLDS.md +193 -0
  66. package/examples/README.md +171 -0
  67. package/examples/docker/Dockerfile.test +16 -0
  68. package/examples/docker/README.md +48 -0
  69. package/examples/docker/docker-compose.test.yml +16 -0
  70. package/examples/non-web-evidence-demo.ts +184 -0
  71. package/examples/openclaw-plugin-smoke/index.ts +30 -0
  72. package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
  73. package/examples/openclaw-plugin-smoke/package.json +9 -0
  74. package/examples/openclaw_integration_example.py +41 -0
  75. package/examples/policy/README.md +165 -0
  76. package/examples/policy/approved-hosts.yaml +137 -0
  77. package/examples/policy/dev-workflow.yaml +206 -0
  78. package/examples/policy/policy.example.yaml +17 -0
  79. package/examples/policy/production-strict.yaml +97 -0
  80. package/examples/policy/sensitive-paths.yaml +114 -0
  81. package/examples/policy/source-trust.yaml +129 -0
  82. package/examples/policy/workspace-isolation.yaml +51 -0
  83. package/examples/runtime_registry_example.py +75 -0
  84. package/package.json +27 -0
  85. package/pyproject.toml +41 -0
  86. package/src/adapter.ts +45 -0
  87. package/src/authority-client.ts +50 -0
  88. package/src/circuit-breaker.ts +245 -0
  89. package/src/config.ts +15 -0
  90. package/src/control-plane-sync.ts +159 -0
  91. package/src/errors.ts +5 -0
  92. package/src/index.ts +12 -0
  93. package/src/non-web-evidence.ts +116 -0
  94. package/src/openclaw-hooks.ts +76 -0
  95. package/src/openclaw-plugin-api.ts +51 -0
  96. package/src/openclaw_predicate_provider/__init__.py +16 -0
  97. package/src/openclaw_predicate_provider/__main__.py +5 -0
  98. package/src/openclaw_predicate_provider/adapter.py +84 -0
  99. package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
  100. package/src/openclaw_predicate_provider/cli.py +160 -0
  101. package/src/openclaw_predicate_provider/config.py +42 -0
  102. package/src/openclaw_predicate_provider/errors.py +13 -0
  103. package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
  104. package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
  105. package/src/openclaw_predicate_provider/models.py +19 -0
  106. package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
  107. package/src/openclaw_predicate_provider/provider.py +69 -0
  108. package/src/openclaw_predicate_provider/py.typed +1 -0
  109. package/src/openclaw_predicate_provider/sidecar.py +59 -0
  110. package/src/provider.ts +220 -0
  111. package/src/runtime-integration.ts +68 -0
  112. package/src/web-evidence.ts +95 -0
  113. package/tests/adapter.test.ts +76 -0
  114. package/tests/audit-event-e2e.test.ts +258 -0
  115. package/tests/authority-client.test.ts +52 -0
  116. package/tests/circuit-breaker.test.ts +266 -0
  117. package/tests/conftest.py +9 -0
  118. package/tests/control-plane-sync.test.ts +114 -0
  119. package/tests/hack-vs-fix-demo.test.ts +44 -0
  120. package/tests/jwks-rotation.test.ts +274 -0
  121. package/tests/load-latency.test.ts +214 -0
  122. package/tests/multi-tenant-isolation.test.ts +183 -0
  123. package/tests/non-web-evidence.test.ts +168 -0
  124. package/tests/openclaw-hooks.test.ts +46 -0
  125. package/tests/openclaw-plugin-api.test.ts +50 -0
  126. package/tests/provider.test.ts +227 -0
  127. package/tests/runtime-integration.test.ts +70 -0
  128. package/tests/test_adapter.py +46 -0
  129. package/tests/test_cli.py +26 -0
  130. package/tests/test_openclaw_hooks.py +53 -0
  131. package/tests/test_provider.py +59 -0
  132. package/tests/test_runtime_integration.py +77 -0
  133. package/tests/test_sidecar_client.py +198 -0
  134. package/tests/web-evidence.test.ts +113 -0
  135. package/tsconfig.json +14 -0
  136. package/vitest.config.ts +7 -0
@@ -0,0 +1,51 @@
1
+ # Workspace Isolation Policy
2
+ # Restricts all file operations to a specific project directory.
3
+ #
4
+ # Usage:
5
+ # 1. Update WORKSPACE_ROOT to your project path
6
+ # 2. Add any additional allowed paths to the allowlist
7
+
8
+ version: 1
9
+
10
+ defaults:
11
+ effect: deny
12
+
13
+ rules:
14
+ # Allow reads within the workspace
15
+ - id: allow_workspace_reads
16
+ effect: allow
17
+ action: fs.read
18
+ resource: ./workspace/**
19
+
20
+ # Allow writes within the workspace
21
+ - id: allow_workspace_writes
22
+ effect: allow
23
+ action: fs.write
24
+ resource: ./workspace/**
25
+
26
+ # Allow reading package manifests (for dependency resolution)
27
+ - id: allow_package_manifests
28
+ effect: allow
29
+ action: fs.read
30
+ resource:
31
+ - ./package.json
32
+ - ./package-lock.json
33
+ - ./yarn.lock
34
+ - ./pnpm-lock.yaml
35
+ - ./Cargo.toml
36
+ - ./Cargo.lock
37
+ - ./go.mod
38
+ - ./go.sum
39
+ - ./requirements.txt
40
+ - ./pyproject.toml
41
+
42
+ # Deny all paths outside workspace (explicit for clarity)
43
+ - id: deny_outside_workspace
44
+ effect: deny
45
+ action: fs.*
46
+ resource: /**
47
+
48
+ metadata:
49
+ name: Workspace Isolation
50
+ description: Restrict file operations to project directory
51
+ version: 1.0.0
@@ -0,0 +1,75 @@
1
+ """Example wiring for OpenClawRuntimeIntegrator with a mock registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from openclaw_predicate_provider.integrations import OpenClawRuntimeIntegrator
9
+ from openclaw_predicate_provider.openclaw_hooks import HookEnvelope, OpenClawHooks
10
+
11
+
12
+ class MockRegistry:
13
+ def __init__(self) -> None:
14
+ self._handlers = {
15
+ "cmd.run": self._cmd_run,
16
+ "fs.readFile": self._fs_read,
17
+ "http.request": self._http_request,
18
+ }
19
+
20
+ async def _cmd_run(self, args: dict[str, Any]) -> dict[str, Any]:
21
+ return {"tool": "cmd.run", "args": args}
22
+
23
+ async def _fs_read(self, args: dict[str, Any]) -> dict[str, Any]:
24
+ return {"tool": "fs.readFile", "args": args}
25
+
26
+ async def _http_request(self, args: dict[str, Any]) -> dict[str, Any]:
27
+ return {"tool": "http.request", "args": args}
28
+
29
+ def get(self, tool_name: str):
30
+ return self._handlers[tool_name]
31
+
32
+ def set(self, tool_name: str, handler) -> None:
33
+ self._handlers[tool_name] = handler
34
+
35
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
36
+ return await self._handlers[tool_name](args)
37
+
38
+
39
+ def build_context(tool_name: str, args: dict[str, Any]) -> HookEnvelope:
40
+ return HookEnvelope(
41
+ tool_name=tool_name,
42
+ args=args,
43
+ session_id="demo-session",
44
+ source="trusted_ui",
45
+ tenant_id="demo-tenant",
46
+ )
47
+
48
+
49
+ async def main() -> None:
50
+ # In real OpenClaw integration this uses GuardedProvider+ToolAdapter hooks.
51
+ class PassThroughHooks:
52
+ async def on_cmd_run(self, envelope, execute):
53
+ return await execute(envelope.args)
54
+
55
+ async def on_fs_read(self, envelope, execute):
56
+ return await execute(envelope.args)
57
+
58
+ async def on_http_request(self, envelope, execute):
59
+ return await execute(envelope.args)
60
+
61
+ hooks = PassThroughHooks()
62
+ integrator = OpenClawRuntimeIntegrator(
63
+ hooks=hooks, # type: ignore[arg-type]
64
+ context_builder=build_context,
65
+ )
66
+ registry = MockRegistry()
67
+ integrator.register(registry)
68
+
69
+ print(await registry.invoke("cmd.run", {"command": "echo hello"}))
70
+ print(await registry.invoke("fs.readFile", {"path": "./README.md"}))
71
+ print(await registry.invoke("http.request", {"url": "https://example.com"}))
72
+
73
+
74
+ if __name__ == "__main__":
75
+ asyncio.run(main())
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "predicate-claw",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript OpenClaw security provider with Predicate Authority pre-execution checks.",
5
+ "main": "dist/src/index.js",
6
+ "types": "dist/src/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json",
9
+ "typecheck": "tsc --noEmit",
10
+ "test": "vitest run",
11
+ "test:demo": "vitest run tests/hack-vs-fix-demo.test.ts",
12
+ "test:ci": "npm run typecheck && npm test"
13
+ },
14
+ "keywords": [],
15
+ "author": "",
16
+ "license": "(MIT OR Apache-2.0)",
17
+ "type": "module",
18
+ "dependencies": {
19
+ "@predicatesystems/authority": "^0.3.3"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.3.0",
23
+ "openclaw": "^2026.2.19-2",
24
+ "typescript": "^5.9.3",
25
+ "vitest": "^4.0.18"
26
+ }
27
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openclaw-predicate-provider"
7
+ version = "0.1.0"
8
+ description = "OpenClaw provider for deterministic Predicate Authority enforcement."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT OR Apache-2.0" }
12
+ authors = [{ name = "Predicate Systems" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "pydantic>=2.7.0",
21
+ "httpx>=0.27.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ openclaw-predicate-provider = "openclaw_predicate_provider.cli:main"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0.0",
30
+ ]
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+ include = ["openclaw_predicate_provider*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ openclaw_predicate_provider = ["py.typed"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ python_files = ["test_*.py"]
package/src/adapter.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { GuardedProvider } from "./provider.js";
2
+
3
+ export interface ToolRunArgs {
4
+ args: Record<string, unknown>;
5
+ context: Record<string, unknown>;
6
+ execute: (args: Record<string, unknown>) => Promise<unknown>;
7
+ }
8
+
9
+ export class ToolAdapter {
10
+ constructor(private readonly guard: Pick<GuardedProvider, "guardOrThrow">) {}
11
+
12
+ async run(params: ToolRunArgs & { action: string; resource: string }) {
13
+ await this.guard.guardOrThrow({
14
+ action: params.action,
15
+ resource: params.resource,
16
+ args: params.args,
17
+ context: params.context,
18
+ });
19
+ return params.execute(params.args);
20
+ }
21
+
22
+ async runShell(params: ToolRunArgs) {
23
+ return this.run({
24
+ ...params,
25
+ action: "shell.execute",
26
+ resource: String(params.args.command ?? ""),
27
+ });
28
+ }
29
+
30
+ async readFile(params: ToolRunArgs) {
31
+ return this.run({
32
+ ...params,
33
+ action: "fs.read",
34
+ resource: String(params.args.path ?? ""),
35
+ });
36
+ }
37
+
38
+ async httpRequest(params: ToolRunArgs) {
39
+ return this.run({
40
+ ...params,
41
+ action: "net.http",
42
+ resource: String(params.args.url ?? ""),
43
+ });
44
+ }
45
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ AuthorityClient,
3
+ type AuthorizationRequest,
4
+ } from "@predicatesystems/authority";
5
+ import type { ProviderConfig } from "./config.js";
6
+
7
+ export interface AuthorityDecision {
8
+ allow: boolean;
9
+ reason?: string;
10
+ mandateId?: string;
11
+ }
12
+
13
+ export interface AuthorityAdapter {
14
+ authorize(request: AuthorizationRequest): Promise<AuthorityDecision>;
15
+ }
16
+
17
+ interface SdkDecision {
18
+ allowed: boolean;
19
+ reason?: string;
20
+ mandate_id?: string | null;
21
+ }
22
+
23
+ interface SdkLike {
24
+ authorize(request: AuthorizationRequest): Promise<SdkDecision>;
25
+ }
26
+
27
+ export function createAuthorityAdapter(client: SdkLike): AuthorityAdapter {
28
+ return {
29
+ async authorize(request: AuthorizationRequest): Promise<AuthorityDecision> {
30
+ const decision = await client.authorize(request);
31
+ return {
32
+ allow: decision.allowed,
33
+ reason: decision.reason,
34
+ mandateId: decision.mandate_id ?? undefined,
35
+ };
36
+ },
37
+ };
38
+ }
39
+
40
+ export function createDefaultAuthorityAdapter(
41
+ config: ProviderConfig,
42
+ ): AuthorityAdapter {
43
+ const sdkClient = new AuthorityClient({
44
+ baseUrl: config.baseUrl,
45
+ timeoutMs: config.timeoutMs,
46
+ maxRetries: config.maxRetries,
47
+ backoffInitialMs: config.backoffInitialMs,
48
+ });
49
+ return createAuthorityAdapter(sdkClient);
50
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Circuit breaker for sidecar outage resilience.
3
+ *
4
+ * States:
5
+ * - CLOSED: Normal operation, requests pass through
6
+ * - OPEN: Too many failures, requests fail fast without calling sidecar
7
+ * - HALF_OPEN: Testing if sidecar has recovered
8
+ */
9
+
10
+ export type CircuitState = "closed" | "open" | "half_open";
11
+
12
+ export interface CircuitBreakerConfig {
13
+ /** Number of failures before opening the circuit */
14
+ failureThreshold: number;
15
+ /** Time in ms before attempting recovery (half-open state) */
16
+ resetTimeoutMs: number;
17
+ /** Number of successful calls in half-open to close circuit */
18
+ successThreshold: number;
19
+ /** Optional callback when state changes */
20
+ onStateChange?: (from: CircuitState, to: CircuitState) => void;
21
+ }
22
+
23
+ export const defaultCircuitBreakerConfig: CircuitBreakerConfig = {
24
+ failureThreshold: 5,
25
+ resetTimeoutMs: 30_000,
26
+ successThreshold: 2,
27
+ };
28
+
29
+ export interface CircuitBreakerMetrics {
30
+ state: CircuitState;
31
+ failureCount: number;
32
+ successCount: number;
33
+ lastFailureTime: number | null;
34
+ totalFailures: number;
35
+ totalSuccesses: number;
36
+ totalRejections: number;
37
+ }
38
+
39
+ export class CircuitBreaker {
40
+ private state: CircuitState = "closed";
41
+ private failureCount = 0;
42
+ private successCount = 0;
43
+ private lastFailureTime: number | null = null;
44
+ private totalFailures = 0;
45
+ private totalSuccesses = 0;
46
+ private totalRejections = 0;
47
+
48
+ constructor(private readonly config: CircuitBreakerConfig) {}
49
+
50
+ getMetrics(): CircuitBreakerMetrics {
51
+ return {
52
+ state: this.state,
53
+ failureCount: this.failureCount,
54
+ successCount: this.successCount,
55
+ lastFailureTime: this.lastFailureTime,
56
+ totalFailures: this.totalFailures,
57
+ totalSuccesses: this.totalSuccesses,
58
+ totalRejections: this.totalRejections,
59
+ };
60
+ }
61
+
62
+ getState(): CircuitState {
63
+ return this.state;
64
+ }
65
+
66
+ /**
67
+ * Check if request should be allowed through.
68
+ * Returns true if allowed, false if circuit is open.
69
+ */
70
+ allowRequest(): boolean {
71
+ if (this.state === "closed") {
72
+ return true;
73
+ }
74
+
75
+ if (this.state === "open") {
76
+ const now = Date.now();
77
+ if (
78
+ this.lastFailureTime !== null &&
79
+ now - this.lastFailureTime >= this.config.resetTimeoutMs
80
+ ) {
81
+ this.transitionTo("half_open");
82
+ return true;
83
+ }
84
+ this.totalRejections++;
85
+ return false;
86
+ }
87
+
88
+ // half_open: allow limited requests to test recovery
89
+ return true;
90
+ }
91
+
92
+ /**
93
+ * Record a successful call.
94
+ */
95
+ recordSuccess(): void {
96
+ this.totalSuccesses++;
97
+
98
+ if (this.state === "half_open") {
99
+ this.successCount++;
100
+ if (this.successCount >= this.config.successThreshold) {
101
+ this.transitionTo("closed");
102
+ }
103
+ } else if (this.state === "closed") {
104
+ // Reset failure count on success
105
+ this.failureCount = 0;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Record a failed call.
111
+ */
112
+ recordFailure(): void {
113
+ this.totalFailures++;
114
+ this.lastFailureTime = Date.now();
115
+
116
+ if (this.state === "half_open") {
117
+ // Any failure in half-open reopens the circuit
118
+ this.transitionTo("open");
119
+ return;
120
+ }
121
+
122
+ if (this.state === "closed") {
123
+ this.failureCount++;
124
+ if (this.failureCount >= this.config.failureThreshold) {
125
+ this.transitionTo("open");
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Force reset to closed state (e.g., for testing or manual recovery).
132
+ */
133
+ reset(): void {
134
+ this.transitionTo("closed");
135
+ }
136
+
137
+ private transitionTo(newState: CircuitState): void {
138
+ if (this.state === newState) return;
139
+
140
+ const oldState = this.state;
141
+ this.state = newState;
142
+
143
+ // Reset counters on state change
144
+ if (newState === "closed") {
145
+ this.failureCount = 0;
146
+ this.successCount = 0;
147
+ } else if (newState === "half_open") {
148
+ this.successCount = 0;
149
+ } else if (newState === "open") {
150
+ this.successCount = 0;
151
+ }
152
+
153
+ this.config.onStateChange?.(oldState, newState);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Exponential backoff calculator with jitter.
159
+ */
160
+ export interface BackoffConfig {
161
+ initialMs: number;
162
+ maxMs: number;
163
+ multiplier: number;
164
+ jitterFactor: number;
165
+ }
166
+
167
+ export const defaultBackoffConfig: BackoffConfig = {
168
+ initialMs: 100,
169
+ maxMs: 10_000,
170
+ multiplier: 2,
171
+ jitterFactor: 0.1,
172
+ };
173
+
174
+ export function calculateBackoff(
175
+ attempt: number,
176
+ config: BackoffConfig = defaultBackoffConfig,
177
+ ): number {
178
+ const base = Math.min(
179
+ config.initialMs * Math.pow(config.multiplier, attempt),
180
+ config.maxMs,
181
+ );
182
+ const jitter = base * config.jitterFactor * (Math.random() * 2 - 1);
183
+ return Math.max(0, Math.round(base + jitter));
184
+ }
185
+
186
+ /**
187
+ * Sleep for a given number of milliseconds.
188
+ */
189
+ export function sleep(ms: number): Promise<void> {
190
+ return new Promise((resolve) => setTimeout(resolve, ms));
191
+ }
192
+
193
+ /**
194
+ * Wrap an async function with circuit breaker and retry logic.
195
+ */
196
+ export async function withCircuitBreaker<T>(
197
+ breaker: CircuitBreaker,
198
+ fn: () => Promise<T>,
199
+ options?: {
200
+ maxRetries?: number;
201
+ backoffConfig?: BackoffConfig;
202
+ isFailure?: (error: unknown) => boolean;
203
+ },
204
+ ): Promise<T> {
205
+ const maxRetries = options?.maxRetries ?? 0;
206
+ const backoffConfig = options?.backoffConfig ?? defaultBackoffConfig;
207
+ const isFailure = options?.isFailure ?? (() => true);
208
+
209
+ if (!breaker.allowRequest()) {
210
+ throw new CircuitOpenError("Circuit breaker is open");
211
+ }
212
+
213
+ let lastError: unknown;
214
+
215
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
216
+ try {
217
+ const result = await fn();
218
+ breaker.recordSuccess();
219
+ return result;
220
+ } catch (error) {
221
+ lastError = error;
222
+
223
+ if (!isFailure(error)) {
224
+ // Not a circuit-breaker-relevant failure (e.g., business logic error)
225
+ throw error;
226
+ }
227
+
228
+ breaker.recordFailure();
229
+
230
+ if (attempt < maxRetries && breaker.allowRequest()) {
231
+ const delay = calculateBackoff(attempt, backoffConfig);
232
+ await sleep(delay);
233
+ }
234
+ }
235
+ }
236
+
237
+ throw lastError;
238
+ }
239
+
240
+ export class CircuitOpenError extends Error {
241
+ constructor(message: string) {
242
+ super(message);
243
+ this.name = "CircuitOpenError";
244
+ }
245
+ }
package/src/config.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface ProviderConfig {
2
+ baseUrl: string;
3
+ timeoutMs: number;
4
+ maxRetries: number;
5
+ backoffInitialMs: number;
6
+ failClosed: boolean;
7
+ }
8
+
9
+ export const defaultProviderConfig: ProviderConfig = {
10
+ baseUrl: "http://127.0.0.1:8787",
11
+ timeoutMs: 300,
12
+ maxRetries: 0,
13
+ backoffInitialMs: 100,
14
+ failClosed: true,
15
+ };