@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.
- package/bridge/bridge.d.ts +28 -1
- package/bridge/bridge.js +99 -4
- package/ccxt/binance-ban-gate.js +9 -0
- package/config/agent-config-client.d.ts +22 -0
- package/config/agent-config-client.js +44 -1
- package/config/agent-config-poller.d.ts +8 -1
- package/config/agent-config-poller.js +1 -0
- package/config/entitlement-gate.d.ts +51 -0
- package/config/entitlement-gate.js +137 -0
- package/index.js +40 -23
- package/ingest/pending-entry-metadata.d.ts +31 -9
- package/ingest/pending-entry-metadata.js +70 -16
- package/ingest/position-auto-capture.js +14 -3
- package/ingest/readiness-reporter.d.ts +19 -0
- package/ingest/readiness-reporter.js +142 -0
- package/live/exchange-info-cache.d.ts +3 -1
- package/live/exchange-info-cache.js +17 -2
- package/package.json +1 -1
- package/signals/strategy-adapter.d.ts +35 -2
- package/signals/strategy-adapter.js +87 -10
- package/tools/create-order.js +26 -20
package/bridge/bridge.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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})`);
|
package/ccxt/binance-ban-gate.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -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();
|