@reefclaw/openclaw-plugin 0.1.1 → 0.1.2

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.
@@ -24,6 +24,16 @@ export declare class Bridge {
24
24
  private readonly listeners;
25
25
  /** rc_ connection token — also authenticates the webapp skill-content pull */
26
26
  private readonly connectionToken;
27
+ /** While the local SKILL.md is still a bootstrap (0.x), re-attempt the webapp
28
+ * pull on a fixed cadence so a box whose one-shot startup pull failed — a
29
+ * bad/rotated token during setup, a webapp blip, or an old connector build —
30
+ * still self-heals off the bootstrap even if it never reconnects. Cleared the
31
+ * moment the full trading SKILL.md (2.x) applies, and on stop(). */
32
+ private bootstrapHealTimer;
33
+ /** Guards against overlapping webapp pull chains (startup + reconnect + heal
34
+ * timer) double-applying / double-notifying during the one-time bootstrap→
35
+ * full transition. The chain that starts (attempt 1) owns the flag. */
36
+ private skillPullInFlight;
27
37
  constructor(provider: OpenClawProvider, connectorConfig: ConnectorConfig);
28
38
  /** Start the bridge: connect to relay + start provider */
29
39
  start(): void;
@@ -104,6 +114,23 @@ export declare class Bridge {
104
114
  * gate when enforced) as the relay pull path.
105
115
  */
106
116
  private pullSkillContentFromWebapp;
107
- /** Check relay's latest SKILL.md version against local and auto-update if stale */
117
+ /** Start a bounded periodic re-pull of the full SKILL.md while the box is
118
+ * still on the bootstrap (0.x). pullSkillContentFromWebapp() (fired once at
119
+ * startup) is a one-shot with a finite retry burst; if every attempt fails —
120
+ * rotated token during setup, webapp blip, or an old connector build — a box
121
+ * that then stays stably connected (no reconnect to re-trigger the pull)
122
+ * would remain stranded on the bootstrap forever. This interval re-attempts
123
+ * every few minutes until the full 2.x skill applies, then retires itself.
124
+ * Strict no-op once upgraded (guarded on version), so upgraded boxes add zero
125
+ * steady-state webapp load. */
126
+ private startBootstrapHeal;
127
+ /** Stop the bootstrap heal timer (on successful upgrade or bridge stop). */
128
+ private stopBootstrapHeal;
129
+ /** On every relay (re)connect: (1) if still on the bootstrap SKILL.md, re-fire
130
+ * the authenticated webapp pull — the relay's per-room store is empty for a
131
+ * fresh user, so the webapp endpoint is the only source of the first full
132
+ * copy and the one-shot in start() may have exhausted its retries during
133
+ * setup; (2) apply any newer version the relay advertises (the OTA push
134
+ * channel for updates once the store is populated). */
108
135
  private handleConnectAck;
109
136
  }
package/bridge/bridge.js CHANGED
@@ -47,6 +47,19 @@ const CRITICAL_EVENTS = new Set([
47
47
  'skill_update_applied', // OTA SKILL.md confirmation
48
48
  // All chat events are on the 'chat' channel and always sent immediately
49
49
  ]);
50
+ /** How often to re-attempt the webapp SKILL.md pull while still on a bootstrap
51
+ * (0.x) version. Long enough to be negligible load, short enough to un-strand
52
+ * a new user whose one-shot startup pull failed. */
53
+ const BOOTSTRAP_SKILL_HEAL_INTERVAL_MS = 10 * 60_000; // 10 min
54
+ /** True while the agent is still on the thin bootstrap SKILL.md and has NOT yet
55
+ * received the full trading instructions. The bootstrap ships 0.0.x; the full
56
+ * trading SKILL.md is 2.x — treat anything below 1.0.0 (or an unknown version)
57
+ * as bootstrap so we keep pulling until the real skill lands. */
58
+ function isBootstrapSkillVersion(version) {
59
+ if (!version)
60
+ return true;
61
+ return compareSemver('1.0.0', version) > 0; // '1.0.0' > version ⟺ version < 1.0.0
62
+ }
50
63
  export class Bridge {
51
64
  provider;
52
65
  connector;
@@ -79,6 +92,16 @@ export class Bridge {
79
92
  listeners = [];
80
93
  /** rc_ connection token — also authenticates the webapp skill-content pull */
81
94
  connectionToken;
95
+ /** While the local SKILL.md is still a bootstrap (0.x), re-attempt the webapp
96
+ * pull on a fixed cadence so a box whose one-shot startup pull failed — a
97
+ * bad/rotated token during setup, a webapp blip, or an old connector build —
98
+ * still self-heals off the bootstrap even if it never reconnects. Cleared the
99
+ * moment the full trading SKILL.md (2.x) applies, and on stop(). */
100
+ bootstrapHealTimer = null;
101
+ /** Guards against overlapping webapp pull chains (startup + reconnect + heal
102
+ * timer) double-applying / double-notifying during the one-time bootstrap→
103
+ * full transition. The chain that starts (attempt 1) owns the flag. */
104
+ skillPullInFlight = false;
82
105
  constructor(provider, connectorConfig) {
83
106
  this.provider = provider;
84
107
  this.currentSkillVersion = readLocalSkillVersion();
@@ -102,11 +125,17 @@ export class Bridge {
102
125
  // in the public npm package. Fire-and-forget with retries; the relay OTA
103
126
  // path (handleConnectAck) remains the push channel for updates.
104
127
  void this.pullSkillContentFromWebapp();
128
+ // The one-shot above has a finite retry burst; this keeps re-trying every
129
+ // few minutes until the full SKILL.md lands, so a box that stays connected
130
+ // without reconnecting can't be stranded on the bootstrap. No-op once
131
+ // upgraded (guarded on version).
132
+ this.startBootstrapHeal();
105
133
  }
106
134
  /** Stop the bridge: disconnect + stop provider */
107
135
  stop() {
108
136
  logger.info(TAG, 'Stopping bridge');
109
137
  this.stopThrottleTimer();
138
+ this.stopBootstrapHeal();
110
139
  this.unwireProviderEvents();
111
140
  this.connector.destroy();
112
141
  this.provider.stop();
@@ -916,6 +945,17 @@ export class Bridge {
916
945
  * gate when enforced) as the relay pull path.
917
946
  */
918
947
  async pullSkillContentFromWebapp(attempt = 1) {
948
+ // Only one pull chain at a time: startup, reconnect (handleConnectAck) and
949
+ // the bootstrap heal timer can each trigger a pull. Without this guard they
950
+ // could overlap during the one-time bootstrap→full transition and
951
+ // double-apply / double-notify the agent. The chain that starts (attempt 1)
952
+ // owns the flag; whichever attempt terminates the chain clears it. A retry
953
+ // (attempt > 1) is the same chain continuing, so it must NOT re-check/re-set.
954
+ if (attempt === 1) {
955
+ if (this.skillPullInFlight)
956
+ return;
957
+ this.skillPullInFlight = true;
958
+ }
919
959
  const MAX_ATTEMPTS = 5;
920
960
  // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips the
921
961
  // Authorization header on cross-origin redirect (verified 2026-03-18).
@@ -927,18 +967,21 @@ export class Bridge {
927
967
  });
928
968
  if (res.status === 401 || res.status === 403) {
929
969
  logger.warn(TAG, `SKILL.md webapp pull: not authorized (${res.status}) — connect the account first`);
930
- return; // A bad token won't get better by retrying.
970
+ this.skillPullInFlight = false;
971
+ return; // A bad token won't get better by retrying within this chain.
931
972
  }
932
973
  if (!res.ok)
933
974
  throw new Error(`HTTP ${res.status}`);
934
975
  const body = (await res.json());
935
976
  if (!body?.version || !body?.content) {
936
977
  logger.warn(TAG, 'SKILL.md webapp pull: response missing version/content');
978
+ this.skillPullInFlight = false;
937
979
  return;
938
980
  }
939
981
  this.currentSkillVersion = readLocalSkillVersion();
940
982
  if (compareSemver(body.version, this.currentSkillVersion) <= 0) {
941
983
  logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} already >= webapp v${body.version} — nothing to do`);
984
+ this.skillPullInFlight = false;
942
985
  return;
943
986
  }
944
987
  logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
@@ -952,25 +995,77 @@ export class Bridge {
952
995
  });
953
996
  if (!result.success)
954
997
  logger.error(TAG, `SKILL.md webapp pull: apply failed: ${result.message}`);
998
+ this.skillPullInFlight = false;
999
+ // A successful apply moved us off the bootstrap — retire the heal timer.
1000
+ if (result.success)
1001
+ this.stopBootstrapHeal();
955
1002
  }
956
1003
  catch (err) {
957
1004
  const msg = err instanceof Error ? err.message : String(err);
958
1005
  if (attempt >= MAX_ATTEMPTS) {
959
1006
  logger.error(TAG, `SKILL.md webapp pull failed after ${MAX_ATTEMPTS} attempts: ${msg}`);
1007
+ this.skillPullInFlight = false;
960
1008
  return;
961
1009
  }
962
1010
  const delayMs = attempt * 30_000;
963
1011
  logger.warn(TAG, `SKILL.md webapp pull failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${msg} — retrying in ${delayMs / 1000}s`);
1012
+ // Keep skillPullInFlight = true: the chain continues on the scheduled retry.
964
1013
  setTimeout(() => void this.pullSkillContentFromWebapp(attempt + 1), delayMs).unref?.();
965
1014
  }
966
1015
  }
967
- /** Check relay's latest SKILL.md version against local and auto-update if stale */
1016
+ /** Start a bounded periodic re-pull of the full SKILL.md while the box is
1017
+ * still on the bootstrap (0.x). pullSkillContentFromWebapp() (fired once at
1018
+ * startup) is a one-shot with a finite retry burst; if every attempt fails —
1019
+ * rotated token during setup, webapp blip, or an old connector build — a box
1020
+ * that then stays stably connected (no reconnect to re-trigger the pull)
1021
+ * would remain stranded on the bootstrap forever. This interval re-attempts
1022
+ * every few minutes until the full 2.x skill applies, then retires itself.
1023
+ * Strict no-op once upgraded (guarded on version), so upgraded boxes add zero
1024
+ * steady-state webapp load. */
1025
+ startBootstrapHeal() {
1026
+ if (this.bootstrapHealTimer)
1027
+ return;
1028
+ if (!isBootstrapSkillVersion(this.currentSkillVersion))
1029
+ return; // already on the full skill
1030
+ this.bootstrapHealTimer = setInterval(() => {
1031
+ // Re-read from disk: the file may have been upgraded out-of-band (relay
1032
+ // OTA push, manual SFTP) since we last cached it.
1033
+ this.currentSkillVersion = readLocalSkillVersion();
1034
+ if (!isBootstrapSkillVersion(this.currentSkillVersion)) {
1035
+ this.stopBootstrapHeal();
1036
+ return;
1037
+ }
1038
+ logger.info(TAG, `Still on bootstrap SKILL.md (v${this.currentSkillVersion ?? 'unknown'}) — re-attempting webapp pull`);
1039
+ void this.pullSkillContentFromWebapp();
1040
+ }, BOOTSTRAP_SKILL_HEAL_INTERVAL_MS);
1041
+ this.bootstrapHealTimer.unref?.();
1042
+ }
1043
+ /** Stop the bootstrap heal timer (on successful upgrade or bridge stop). */
1044
+ stopBootstrapHeal() {
1045
+ if (this.bootstrapHealTimer) {
1046
+ clearInterval(this.bootstrapHealTimer);
1047
+ this.bootstrapHealTimer = null;
1048
+ }
1049
+ }
1050
+ /** On every relay (re)connect: (1) if still on the bootstrap SKILL.md, re-fire
1051
+ * the authenticated webapp pull — the relay's per-room store is empty for a
1052
+ * fresh user, so the webapp endpoint is the only source of the first full
1053
+ * copy and the one-shot in start() may have exhausted its retries during
1054
+ * setup; (2) apply any newer version the relay advertises (the OTA push
1055
+ * channel for updates once the store is populated). */
968
1056
  async handleConnectAck(payload) {
1057
+ // Re-read local version (may have changed since startup)
1058
+ this.currentSkillVersion = readLocalSkillVersion();
1059
+ // Fresh-user self-heal: while still on the thin bootstrap SKILL.md, pull the
1060
+ // full instructions from the webapp on this (re)connect. Guarded (in-flight)
1061
+ // + downgrade-checked, so it's a strict no-op once upgraded.
1062
+ if (isBootstrapSkillVersion(this.currentSkillVersion)) {
1063
+ logger.info(TAG, `Connect ack while on bootstrap SKILL.md (v${this.currentSkillVersion ?? 'unknown'}) — pulling full instructions from webapp`);
1064
+ void this.pullSkillContentFromWebapp();
1065
+ }
969
1066
  const latestVersion = payload.latestSkillVersion;
970
1067
  if (!latestVersion)
971
1068
  return;
972
- // Re-read local version (may have changed since startup)
973
- this.currentSkillVersion = readLocalSkillVersion();
974
1069
  const cmp = compareSemver(latestVersion, this.currentSkillVersion);
975
1070
  if (cmp === 0) {
976
1071
  logger.debug(TAG, `SKILL.md up to date (v${latestVersion})`);
@@ -128,6 +128,10 @@ const CONTEXT_WEIGHT = {
128
128
  fetchOHLCV: 2,
129
129
  fetchFundingRate: 1,
130
130
  fetchOpenInterest: 1,
131
+ // ExchangeInfoCache boot load — ccxt loadMarkets() → GET /fapi/v1/exchangeInfo,
132
+ // IP weight 1 (doc-verified developers.binance.com 2026-07-10). Was the
133
+ // known "ungated_ip" ccxt-internal call named in the window-summary comment.
134
+ loadMarkets: 1,
131
135
  fetchBalance: 5,
132
136
  fetchPositions: 5,
133
137
  // BinancePrivateApi.fetchOpenOrders merges TWO REST calls (regular
@@ -175,6 +179,11 @@ const NEVER_PACE = new Set([
175
179
  'createOrder', 'cancelOrder', 'cancelAllOrders', 'cancelOrderByClientId',
176
180
  'createBracketOrder', 'createListenKey', 'keepAliveListenKey', 'closeListenKey',
177
181
  'fetchTodayIncomeBreakdownForced',
182
+ // ExchangeInfoCache boot load: runs once per adapter init and a failure
183
+ // BLOCKS the adapter (no retry until restart), so a paced-out load would
184
+ // trade ~1 weight of relief for a dead adapter. Ban-gated + counted like
185
+ // the listenKey contexts, never shed.
186
+ 'loadMarkets',
178
187
  ]);
179
188
  /** Low-priority contexts that shed FIRST under weight pressure (paced
180
189
  * against COSMETIC_SOFT_CEILING, not the full ceiling). Two safe-to-shed
@@ -10,6 +10,19 @@ export interface AgentGates {
10
10
  exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
11
11
  positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
12
12
  }
13
+ /** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
14
+ * from the users row and delivered on the config channel). The plugin NEVER
15
+ * re-derives billing state — it consumes `paidAccess` as-is. Absent field =
16
+ * unknown = the entitlement gate fails OPEN (older webapp, outage, garbage):
17
+ * billing enforcement must never depend on this read succeeding; the
18
+ * server-side 403 walls (webapp Pro routes + intel) are the hard layer. */
19
+ export interface AgentEntitlement {
20
+ state: 'active' | 'trialing' | 'grace' | 'expired' | 'none';
21
+ paidAccess: boolean;
22
+ tier?: string;
23
+ expiresAt?: string;
24
+ graceUntil?: string;
25
+ }
13
26
  /** The slice of /api/internal/config the plugin consumes today. `limits`
14
27
  * arrives in the payload but is deliberately not modeled yet. */
15
28
  export interface AgentConfig {
@@ -18,6 +31,8 @@ export interface AgentConfig {
18
31
  disabled: string[];
19
32
  };
20
33
  gates: AgentGates;
34
+ /** Absent when the server omitted it or the field failed validation. */
35
+ entitlement?: AgentEntitlement;
21
36
  }
22
37
  /** Hardcoded safe default — identical to pre-central behavior (nothing
23
38
  * disabled, no central gate values → local file / defaults rule). */
@@ -29,6 +44,13 @@ export declare function defaultAgentConfig(): AgentConfig;
29
44
  * the plugin doesn't already consider safe).
30
45
  */
31
46
  export declare function validateAgentConfig(raw: unknown): AgentConfig | null;
47
+ /** Per-field entitlement validation: a structurally-valid `{state, paidAccess}`
48
+ * pair (state in the enum, paidAccess a real boolean) is kept; ANYTHING else
49
+ * → undefined, and an absent entitlement makes the gate fail open. Garbage
50
+ * can pause new entries only by forging a syntactically-perfect verdict —
51
+ * and even then it can never touch closes/stops/emergency (those aren't in
52
+ * the gated set at all — see entitlement-gate.ts). */
53
+ export declare function validateEntitlement(raw: unknown): AgentEntitlement | undefined;
32
54
  /** Version-monotonic acceptance (basic rollback/replay protection): a fetched
33
55
  * config older than what we already applied is rejected. Equal versions are
34
56
  * acceptable (idempotent re-apply is a no-op for the gate). */
@@ -23,6 +23,13 @@ const TAG = 'agent-config';
23
23
  * because exitGate + positionReviewMode use identical values; a future gate
24
24
  * with a different enum gets its own constant. */
25
25
  const MODE_LADDER_VALUES = new Set(['off', 'shadow', 'observe', 'enforce']);
26
+ const ENTITLEMENT_STATES = new Set([
27
+ 'active',
28
+ 'trialing',
29
+ 'grace',
30
+ 'expired',
31
+ 'none',
32
+ ]);
26
33
  /** Hardcoded safe default — identical to pre-central behavior (nothing
27
34
  * disabled, no central gate values → local file / defaults rule). */
28
35
  export function defaultAgentConfig() {
@@ -51,7 +58,43 @@ export function validateAgentConfig(raw) {
51
58
  if (!Array.isArray(disabledRaw))
52
59
  return null;
53
60
  const disabled = disabledRaw.filter((v) => typeof v === 'string' && TOOL_NAME_RE.test(v));
54
- return { version, tools: { disabled }, gates: validateGates(obj.gates) };
61
+ const config = {
62
+ version,
63
+ tools: { disabled },
64
+ gates: validateGates(obj.gates),
65
+ };
66
+ const entitlement = validateEntitlement(obj.entitlement);
67
+ if (entitlement)
68
+ config.entitlement = entitlement;
69
+ return config;
70
+ }
71
+ /** Per-field entitlement validation: a structurally-valid `{state, paidAccess}`
72
+ * pair (state in the enum, paidAccess a real boolean) is kept; ANYTHING else
73
+ * → undefined, and an absent entitlement makes the gate fail open. Garbage
74
+ * can pause new entries only by forging a syntactically-perfect verdict —
75
+ * and even then it can never touch closes/stops/emergency (those aren't in
76
+ * the gated set at all — see entitlement-gate.ts). */
77
+ export function validateEntitlement(raw) {
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
79
+ return undefined;
80
+ const obj = raw;
81
+ const state = obj.state;
82
+ const paidAccess = obj.paidAccess;
83
+ if (typeof state !== 'string' || !ENTITLEMENT_STATES.has(state))
84
+ return undefined;
85
+ if (typeof paidAccess !== 'boolean')
86
+ return undefined;
87
+ const entitlement = {
88
+ state: state,
89
+ paidAccess,
90
+ };
91
+ if (typeof obj.tier === 'string')
92
+ entitlement.tier = obj.tier;
93
+ if (typeof obj.expiresAt === 'string')
94
+ entitlement.expiresAt = obj.expiresAt;
95
+ if (typeof obj.graceUntil === 'string')
96
+ entitlement.graceUntil = obj.graceUntil;
97
+ return entitlement;
55
98
  }
56
99
  /** Per-field gate validation (§5b): a structurally-absent/garbage `gates`
57
100
  * becomes `{}` (no central values → local config rules); each known gate is
@@ -1,4 +1,4 @@
1
- import { type AgentConfig, type AgentGates } from './agent-config-client.js';
1
+ import { type AgentConfig, type AgentEntitlement, type AgentGates } from './agent-config-client.js';
2
2
  import type { ToolGate } from './tool-gate.js';
3
3
  export declare function resolvePollIntervalMs(): number;
4
4
  export interface AgentConfigPollerOptions {
@@ -9,6 +9,13 @@ export interface AgentConfigPollerOptions {
9
9
  gateStore?: {
10
10
  apply(gates: AgentGates): void;
11
11
  };
12
+ /** Entitlement consumer (account-level enforcement). Optional, wired from
13
+ * index.ts with the module singleton `entitlementGate`. Receives the
14
+ * config's entitlement slice — including `undefined` when the server
15
+ * omitted it, which clears any held verdict (fail-open). */
16
+ entitlementGate?: {
17
+ apply(entitlement: AgentEntitlement | undefined): void;
18
+ };
12
19
  apiBaseUrl: string;
13
20
  token: string;
14
21
  fetchImpl?: typeof fetch;
@@ -54,6 +54,7 @@ export function startAgentConfigPoller(opts) {
54
54
  current = fetched;
55
55
  opts.gate.apply(fetched);
56
56
  opts.gateStore?.apply(fetched.gates);
57
+ opts.entitlementGate?.apply(fetched.entitlement);
57
58
  return true;
58
59
  };
59
60
  const poll = async (isBoot) => {
@@ -0,0 +1,51 @@
1
+ import type { AgentEntitlement } from './agent-config-client.js';
2
+ /** The ONLY tools an expired entitlement may pause. Deliberately a denylist
3
+ * (not "everything except safety"): a tool added tomorrow is un-gated by
4
+ * default, which is the conservative failure mode in a trading product.
5
+ * Intel/data tools are NOT here — the intelligence service and webapp 403
6
+ * those server-side with the same actionable message. */
7
+ export declare const ENTITLEMENT_GATED_TOOLS: ReadonlySet<string>;
8
+ export type EntitlementGateMode = 'enforce' | 'shadow' | 'off';
9
+ export declare function resolveEntitlementGateMode(): EntitlementGateMode;
10
+ /** Structured, agent-visible result for a blocked call. Worded so the agent
11
+ * stops cleanly (no retry loop), keeps managing the existing book, and tells
12
+ * the operator exactly how to restore service. */
13
+ export declare function entitlementBlockedResult(name: string): {
14
+ ok: false;
15
+ error: 'entitlement_expired';
16
+ tool: string;
17
+ message: string;
18
+ };
19
+ /** Minimal structural view of a registered plugin tool — same shape as
20
+ * tool-gate.ts GateablePluginTool (kept local to avoid a cross-module type
21
+ * dependency for one interface). */
22
+ export interface EntitlementGateableTool {
23
+ name: string;
24
+ execute: (...args: any[]) => any;
25
+ }
26
+ export declare class EntitlementGate {
27
+ private readonly mode;
28
+ private entitlement;
29
+ /** Avoids log spam: one line per state transition, not per blocked call. */
30
+ private lastLoggedKey;
31
+ constructor(mode?: EntitlementGateMode);
32
+ getMode(): EntitlementGateMode;
33
+ getEntitlement(): AgentEntitlement | undefined;
34
+ /** Apply the entitlement slice of a validated config (poller path).
35
+ * `undefined` (server omitted it / validation dropped it) CLEARS the held
36
+ * verdict — the gate then fails open rather than enforcing a stale one
37
+ * the server no longer asserts. */
38
+ apply(entitlement: AgentEntitlement | undefined): void;
39
+ /** Should this call be blocked? Non-gated names short-circuit FIRST (the
40
+ * safety set can never be touched, in any mode, with any payload); then
41
+ * only an explicit server verdict of paidAccess=false blocks. */
42
+ isBlocked(name: string): boolean;
43
+ /** Wrap a tool's execute with the gate check — same hot-apply pattern as
44
+ * ToolGate.wrapTool (state is consulted per call, not at registration). */
45
+ wrapTool<T extends EntitlementGateableTool>(tool: T, jsonResult: (data: unknown) => unknown): T;
46
+ /** Test-only. */
47
+ __reset(): void;
48
+ }
49
+ /** Module singleton — one gate per plugin process, shared by the poller
50
+ * (writer) and the tool-wrap in index.ts (consumer), mirroring gateStore. */
51
+ export declare const entitlementGate: EntitlementGate;
@@ -0,0 +1,137 @@
1
+ // EntitlementGate — the plugin-side enforcement point for account-level
2
+ // (billing) restrictions: when the server-resolved entitlement says the user
3
+ // has no paid access (trial expired past its end, subscription lapsed past
4
+ // the 7-day grace), NEW ENTRIES pause. Everything else keeps working.
5
+ //
6
+ // ★ SAFETY ANALYSIS (why this can never add risk to an existing book):
7
+ // the gate blocks ONLY the names in ENTITLEMENT_GATED_TOOLS — today exactly
8
+ // `create_order`, the one tool that ADDS risk and the one thing our servers
9
+ // cannot 403 (it goes plugin → Binance directly). Every risk-REDUCING and
10
+ // safety path (close_position, modify_stop, attach_brackets,
11
+ // audit_bracket_protection, record_position_reviews, the operator-control
12
+ // tools, kill/flatten/pause via the skill) is untouched by construction: it
13
+ // is not in the gated set, and a pin test asserts the set can never grow into
14
+ // the safety floor. This mirrors what an auto-pause on loss limits already
15
+ // does (no new entries, existing positions fully manageable) — an operation
16
+ // the product already considers safe.
17
+ //
18
+ // ★ THREAT MODEL HONESTY: the plugin runs on the trader's own hardware, so
19
+ // this gate is the COURTESY layer — it makes the agent stop cleanly with an
20
+ // actionable message instead of burning failed calls. The HARD enforcement is
21
+ // server-side: webapp Pro routes and the intelligence API both 403 on
22
+ // paidAccess=false, which removes the data the agent trades on.
23
+ //
24
+ // Fail direction: no entitlement data (older webapp, outage, garbage payload,
25
+ // no token) → ALLOW. Billing enforcement must never brick a paying customer
26
+ // because our config channel hiccuped; last-known-good semantics come from
27
+ // the agent-config cache like every other centrally-delivered value.
28
+ //
29
+ // Kill-switch: RC_ENTITLEMENT_GATE=off (never blocks) | shadow (log-only)
30
+ // | default enforce — same convention as RC_TOOL_GATE / RC_CENTRAL_GATES.
31
+ import { logger } from '../logger.js';
32
+ const TAG = 'entitlement-gate';
33
+ /** The ONLY tools an expired entitlement may pause. Deliberately a denylist
34
+ * (not "everything except safety"): a tool added tomorrow is un-gated by
35
+ * default, which is the conservative failure mode in a trading product.
36
+ * Intel/data tools are NOT here — the intelligence service and webapp 403
37
+ * those server-side with the same actionable message. */
38
+ export const ENTITLEMENT_GATED_TOOLS = new Set(['create_order']);
39
+ export function resolveEntitlementGateMode() {
40
+ const raw = (process.env.RC_ENTITLEMENT_GATE ?? '').toLowerCase();
41
+ if (raw === 'off')
42
+ return 'off';
43
+ if (raw === 'shadow')
44
+ return 'shadow';
45
+ return 'enforce';
46
+ }
47
+ /** Structured, agent-visible result for a blocked call. Worded so the agent
48
+ * stops cleanly (no retry loop), keeps managing the existing book, and tells
49
+ * the operator exactly how to restore service. */
50
+ export function entitlementBlockedResult(name) {
51
+ return {
52
+ ok: false,
53
+ error: 'entitlement_expired',
54
+ tool: name,
55
+ message: `New entries are paused: the ReefClaw subscription for this connection has expired ` +
56
+ `(trial ended or payment lapsed). Do not retry this call. Existing positions remain fully ` +
57
+ `manageable — close_position, modify_stop, attach_brackets, and all emergency/operator ` +
58
+ `controls work normally, and protective brackets stay enforced by the exchange. ` +
59
+ `Tell your operator to renew at https://reefclaw.com (Settings → Billing) to resume trading.`,
60
+ };
61
+ }
62
+ export class EntitlementGate {
63
+ mode;
64
+ entitlement;
65
+ /** Avoids log spam: one line per state transition, not per blocked call. */
66
+ lastLoggedKey = '';
67
+ constructor(mode = resolveEntitlementGateMode()) {
68
+ this.mode = mode;
69
+ if (mode !== 'enforce')
70
+ logger.info(TAG, `mode=${mode}`);
71
+ }
72
+ getMode() {
73
+ return this.mode;
74
+ }
75
+ getEntitlement() {
76
+ return this.entitlement;
77
+ }
78
+ /** Apply the entitlement slice of a validated config (poller path).
79
+ * `undefined` (server omitted it / validation dropped it) CLEARS the held
80
+ * verdict — the gate then fails open rather than enforcing a stale one
81
+ * the server no longer asserts. */
82
+ apply(entitlement) {
83
+ const key = entitlement ? `${entitlement.state}:${entitlement.paidAccess}` : '(none)';
84
+ if (key !== this.lastLoggedKey) {
85
+ this.lastLoggedKey = key;
86
+ logger.info(TAG, entitlement
87
+ ? `entitlement: state=${entitlement.state} paidAccess=${entitlement.paidAccess}` +
88
+ (entitlement.graceUntil ? ` graceUntil=${entitlement.graceUntil}` : '')
89
+ : 'entitlement: no server verdict — gate inactive (fail-open)');
90
+ }
91
+ this.entitlement = entitlement;
92
+ }
93
+ /** Should this call be blocked? Non-gated names short-circuit FIRST (the
94
+ * safety set can never be touched, in any mode, with any payload); then
95
+ * only an explicit server verdict of paidAccess=false blocks. */
96
+ isBlocked(name) {
97
+ if (this.mode === 'off')
98
+ return false;
99
+ if (!ENTITLEMENT_GATED_TOOLS.has(name))
100
+ return false;
101
+ if (!this.entitlement)
102
+ return false; // unknown → allow
103
+ if (this.entitlement.paidAccess !== false)
104
+ return false;
105
+ if (this.mode === 'shadow') {
106
+ logger.info(TAG, `SHADOW: would block ${name} (state=${this.entitlement.state})`);
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ /** Wrap a tool's execute with the gate check — same hot-apply pattern as
112
+ * ToolGate.wrapTool (state is consulted per call, not at registration). */
113
+ wrapTool(tool, jsonResult) {
114
+ if (!ENTITLEMENT_GATED_TOOLS.has(tool.name))
115
+ return tool;
116
+ const originalExecute = tool.execute.bind(tool);
117
+ return {
118
+ ...tool,
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
+ execute: (...args) => {
121
+ if (this.isBlocked(tool.name)) {
122
+ logger.info(TAG, `blocked ${tool.name} (entitlement state=${this.entitlement?.state ?? 'unknown'})`);
123
+ return Promise.resolve(jsonResult(entitlementBlockedResult(tool.name)));
124
+ }
125
+ return originalExecute(...args);
126
+ },
127
+ };
128
+ }
129
+ /** Test-only. */
130
+ __reset() {
131
+ this.entitlement = undefined;
132
+ this.lastLoggedKey = '';
133
+ }
134
+ }
135
+ /** Module singleton — one gate per plugin process, shared by the poller
136
+ * (writer) and the tool-wrap in index.ts (consumer), mirroring gateStore. */
137
+ export const entitlementGate = new EntitlementGate();