@reefclaw/openclaw-plugin 0.1.22 → 0.1.24
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.js +110 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +4 -0
- package/bridge/provider.d.ts +36 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +37 -2
- package/bridge/providers/gateway.js +164 -9
- package/bridge/providers/mock.js +1 -0
- package/bridge/providers/onboarding-commands.d.ts +12 -1
- package/bridge/providers/onboarding-commands.js +25 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +7 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/config/tool-gate.js +1 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +73 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +2 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +18 -0
- package/tools/hl-submit-agent-approval.d.ts +27 -0
- package/tools/hl-submit-agent-approval.js +140 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
package/bridge/providers/mock.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
2
2
|
import type { ToolMap } from '../gateway/tool-discovery.js';
|
|
3
|
-
import type { ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome } from '../provider.js';
|
|
3
|
+
import type { ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome, HlSubmitApprovalOutcome } from '../provider.js';
|
|
4
4
|
import type { TradingMode } from '@reefclaw/shared';
|
|
5
5
|
export interface SetTradingModeOutcome {
|
|
6
6
|
ok: boolean;
|
|
@@ -116,6 +116,17 @@ export declare function executeProvisionHlAgentWallet(ctx: OnboardingContext, ar
|
|
|
116
116
|
regenerate?: boolean;
|
|
117
117
|
confirm_venue_switch?: boolean;
|
|
118
118
|
}): Promise<HlProvisionOutcome>;
|
|
119
|
+
/** Forward an hl_submit_agent_approval invocation to the plugin. The payload
|
|
120
|
+
* is a signature the operator's wallet produced — no secret material. */
|
|
121
|
+
export declare function executeSubmitHlAgentApproval(ctx: OnboardingContext, args: {
|
|
122
|
+
action: Record<string, unknown>;
|
|
123
|
+
nonce: number;
|
|
124
|
+
signature: {
|
|
125
|
+
r: string;
|
|
126
|
+
s: string;
|
|
127
|
+
v: number;
|
|
128
|
+
};
|
|
129
|
+
}): Promise<HlSubmitApprovalOutcome>;
|
|
119
130
|
/** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
|
|
120
131
|
export declare function executeHlAgentWalletStatus(ctx: OnboardingContext): Promise<HlAgentWalletStatusOutcome>;
|
|
121
132
|
/** Forward a set_exchange_credentials invocation to the plugin via HTTP.
|
|
@@ -254,6 +254,31 @@ export async function executeProvisionHlAgentWallet(ctx, args) {
|
|
|
254
254
|
return { ok: false, message: reason, venue: 'hyperliquid', mode: 'PAPER' };
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
+
/** Forward an hl_submit_agent_approval invocation to the plugin. The payload
|
|
258
|
+
* is a signature the operator's wallet produced — no secret material. */
|
|
259
|
+
export async function executeSubmitHlAgentApproval(ctx, args) {
|
|
260
|
+
const tool = ctx.toolMap.hl_submit_agent_approval;
|
|
261
|
+
if (!tool || !ctx.http) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
message: 'hl_submit_agent_approval tool not available on gateway — update the ReefClaw plugin (npx @reefclaw/connect@latest)',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const result = await ctx.http.invoke(tool, {
|
|
269
|
+
action: args.action,
|
|
270
|
+
nonce: args.nonce,
|
|
271
|
+
signature: args.signature,
|
|
272
|
+
operator_token: ctx.operatorToken,
|
|
273
|
+
});
|
|
274
|
+
return result.data;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
278
|
+
logger.error(TAG, `hl_submit_agent_approval failed: ${reason}`);
|
|
279
|
+
return { ok: false, message: reason };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
257
282
|
/** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
|
|
258
283
|
export async function executeHlAgentWalletStatus(ctx) {
|
|
259
284
|
const tool = ctx.toolMap.hl_agent_wallet_status;
|
package/bridge/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { Channel, RequestFrame, ResponseFrame, EventFrame, Frame, Emergency
|
|
|
3
3
|
export { VALID_CHANNELS, VALID_EMERGENCY_ACTIONS } from '@reefclaw/shared';
|
|
4
4
|
type EventFrame = _EventFrame;
|
|
5
5
|
/** Methods the skill accepts from the relay (browser → skill) */
|
|
6
|
-
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "get_bracket_config", "set_bracket_requirement"];
|
|
6
|
+
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "hl_submit_agent_approval", "get_bracket_config", "set_bracket_requirement", "connector.update"];
|
|
7
7
|
/** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
|
|
8
8
|
* enforces this before dispatching — a session without the scope gets a
|
|
9
9
|
* 403 error. PR2 ships the scope as session-wide (inherited from the Clerk
|
package/bridge/types.js
CHANGED
|
@@ -21,9 +21,14 @@ export const ALLOWED_METHODS = [
|
|
|
21
21
|
// Hyperliquid guided onboarding (box-generated agent wallet) — operator.write-gated.
|
|
22
22
|
'hl_provision_agent_wallet',
|
|
23
23
|
'hl_agent_wallet_status',
|
|
24
|
+
'hl_submit_agent_approval',
|
|
24
25
|
// Bracket-orders config (Phase 3.5b) — operator.write-gated.
|
|
25
26
|
'get_bracket_config',
|
|
26
27
|
'set_bracket_requirement',
|
|
28
|
+
// Operator-triggered connector update. Runs a FIXED command on the box over
|
|
29
|
+
// the gateway PTY — carries no command/version/args field by design (see the
|
|
30
|
+
// security note in providers/connector-update.ts). operator.write-gated.
|
|
31
|
+
'connector.update',
|
|
27
32
|
];
|
|
28
33
|
/** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
|
|
29
34
|
* enforces this before dispatching — a session without the scope gets a
|
|
@@ -38,8 +43,10 @@ export const OPERATOR_WRITE_METHODS = new Set([
|
|
|
38
43
|
'clear_exchange_credentials',
|
|
39
44
|
'hl_provision_agent_wallet',
|
|
40
45
|
'hl_agent_wallet_status',
|
|
46
|
+
'hl_submit_agent_approval',
|
|
41
47
|
'get_bracket_config',
|
|
42
48
|
'set_bracket_requirement',
|
|
49
|
+
'connector.update',
|
|
43
50
|
'emergency.kill',
|
|
44
51
|
'emergency.flatten',
|
|
45
52
|
'emergency.pause',
|
package/ccxt/binance-private.js
CHANGED
|
@@ -813,7 +813,8 @@ export class BinancePrivateApi {
|
|
|
813
813
|
const raw = await this.exchange.fetchTicker(symbol);
|
|
814
814
|
noteSuccess();
|
|
815
815
|
return {
|
|
816
|
-
symbol
|
|
816
|
+
// Echo the REQUESTED symbol — see BinancePublicApi.fetchTicker.
|
|
817
|
+
symbol,
|
|
817
818
|
last: raw.last ?? 0,
|
|
818
819
|
bid: raw.bid ?? 0,
|
|
819
820
|
ask: raw.ask ?? 0,
|
package/ccxt/binance-public.js
CHANGED
|
@@ -86,7 +86,12 @@ export class BinancePublicApi {
|
|
|
86
86
|
const raw = await this.exchange.fetchTicker(symbol);
|
|
87
87
|
noteSuccess();
|
|
88
88
|
return {
|
|
89
|
-
symbol
|
|
89
|
+
// Echo the REQUESTED symbol, not ccxt's unified form. ccxt rewrites
|
|
90
|
+
// 'ETH/USDT' → 'ETH/USDT:USDT' on USDM futures; returning that made
|
|
91
|
+
// callers key per-symbol caches under a name the caller never asked
|
|
92
|
+
// for (see the simulator's tickerKey note). Same invariant that
|
|
93
|
+
// intel-public.ts fetchTicker already documents.
|
|
94
|
+
symbol,
|
|
90
95
|
last: raw.last ?? 0,
|
|
91
96
|
bid: raw.bid ?? 0,
|
|
92
97
|
ask: raw.ask ?? 0,
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* never whole-payload rejection. Gates are added one at a time
|
|
5
5
|
* (TOOL_DISTRIBUTION_ARCHITECTURE.md §11 step 3): `exitGate` (slice 2) →
|
|
6
6
|
* `positionReviewMode` (slice 3, the Position Decision Journal
|
|
7
|
-
* heartbeat-mandate + superset gate, file key `positionReview.mode`)
|
|
8
|
-
*
|
|
7
|
+
* heartbeat-mandate + superset gate, file key `positionReview.mode`) →
|
|
8
|
+
* `approvalMode` (slice 4, per-trade operator approval, file key
|
|
9
|
+
* `approval.mode`). The first two ride the same four-stage
|
|
10
|
+
* `off → shadow → observe → enforce` ladder; approvalMode has its own. */
|
|
9
11
|
export interface AgentGates {
|
|
10
12
|
exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
11
13
|
positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
14
|
+
approvalMode?: 'off' | 'per_trade';
|
|
12
15
|
}
|
|
13
16
|
/** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
|
|
14
17
|
* from the users row and delivered on the config channel). The plugin NEVER
|
|
@@ -23,6 +23,15 @@ 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
|
+
/** approvalMode's own enum — deliberately NOT the four-stage ladder.
|
|
27
|
+
*
|
|
28
|
+
* ★ `shadow` is absent on purpose. Shadow proposal telemetry is driven by the
|
|
29
|
+
* APPROVAL_SHADOW_MODE systemd env flag, which is boot-snapshotted and local
|
|
30
|
+
* to the box; it is not a central value. Accepting 'shadow' here would let a
|
|
31
|
+
* central push silently enable telemetry writes the operator never configured,
|
|
32
|
+
* and would collide with the env flag's precedence. Central can express
|
|
33
|
+
* exactly the two states that change trading behaviour. */
|
|
34
|
+
const APPROVAL_MODE_VALUES = new Set(['off', 'per_trade']);
|
|
26
35
|
const ENTITLEMENT_STATES = new Set([
|
|
27
36
|
'active',
|
|
28
37
|
'trialing',
|
|
@@ -112,6 +121,10 @@ function validateGates(raw) {
|
|
|
112
121
|
if (typeof positionReviewMode === 'string' && MODE_LADDER_VALUES.has(positionReviewMode)) {
|
|
113
122
|
gates.positionReviewMode = positionReviewMode;
|
|
114
123
|
}
|
|
124
|
+
const approvalMode = obj.approvalMode;
|
|
125
|
+
if (typeof approvalMode === 'string' && APPROVAL_MODE_VALUES.has(approvalMode)) {
|
|
126
|
+
gates.approvalMode = approvalMode;
|
|
127
|
+
}
|
|
115
128
|
return gates;
|
|
116
129
|
}
|
|
117
130
|
/** Version-monotonic acceptance (basic rollback/replay protection): a fetched
|
|
@@ -65,7 +65,11 @@ export function startAgentConfigPoller(opts) {
|
|
|
65
65
|
});
|
|
66
66
|
if (fetched) {
|
|
67
67
|
loggedFetchFailure = false;
|
|
68
|
-
|
|
68
|
+
// Only touch the cache file when the config actually changed —
|
|
69
|
+
// isAcceptableVersion accepts equal versions, so this used to do a
|
|
70
|
+
// blocking write+rename every 60s poll forever.
|
|
71
|
+
const prevJson = JSON.stringify(current);
|
|
72
|
+
if (applyIfAcceptable(fetched, 'network') && JSON.stringify(fetched) !== prevJson) {
|
|
69
73
|
writeCachedConfig(fetched, opts.cachePath);
|
|
70
74
|
}
|
|
71
75
|
return;
|
package/config/gate-store.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ declare class GateStore {
|
|
|
9
9
|
/** The central positionReview.mode, or null when central has no value (or the
|
|
10
10
|
* kill-switch is on) — null tells the reader to fall back to the file. */
|
|
11
11
|
getPositionReviewMode(): AgentGates['positionReviewMode'] | null;
|
|
12
|
+
/** The central approval.mode, or null when central has no value (or the
|
|
13
|
+
* kill-switch is on) — null tells the reader to fall back to the file.
|
|
14
|
+
*
|
|
15
|
+
* ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
|
|
16
|
+
* telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
|
|
17
|
+
* weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
|
|
18
|
+
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
19
|
+
* position unprotected. */
|
|
20
|
+
getApprovalMode(): AgentGates['approvalMode'] | null;
|
|
12
21
|
/** Test-only. */
|
|
13
22
|
__reset(): void;
|
|
14
23
|
}
|
package/config/gate-store.js
CHANGED
|
@@ -30,11 +30,13 @@ class GateStore {
|
|
|
30
30
|
apply(gates) {
|
|
31
31
|
const next = gates ?? {};
|
|
32
32
|
const changed = next.exitGate !== this.gates.exitGate ||
|
|
33
|
-
next.positionReviewMode !== this.gates.positionReviewMode
|
|
33
|
+
next.positionReviewMode !== this.gates.positionReviewMode ||
|
|
34
|
+
next.approvalMode !== this.gates.approvalMode;
|
|
34
35
|
this.gates = { ...next };
|
|
35
36
|
if (changed) {
|
|
36
37
|
logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
|
|
37
|
-
`positionReviewMode=${next.positionReviewMode ?? UNSET}`
|
|
38
|
+
`positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
|
|
39
|
+
`approvalMode=${next.approvalMode ?? UNSET}`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
/** The central exitGate mode, or null when central has no value (or the
|
|
@@ -51,6 +53,19 @@ class GateStore {
|
|
|
51
53
|
return null;
|
|
52
54
|
return this.gates.positionReviewMode ?? null;
|
|
53
55
|
}
|
|
56
|
+
/** The central approval.mode, or null when central has no value (or the
|
|
57
|
+
* kill-switch is on) — null tells the reader to fall back to the file.
|
|
58
|
+
*
|
|
59
|
+
* ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
|
|
60
|
+
* telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
|
|
61
|
+
* weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
|
|
62
|
+
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
63
|
+
* position unprotected. */
|
|
64
|
+
getApprovalMode() {
|
|
65
|
+
if (!centralGatesEnabled())
|
|
66
|
+
return null;
|
|
67
|
+
return this.gates.approvalMode ?? null;
|
|
68
|
+
}
|
|
54
69
|
/** Test-only. */
|
|
55
70
|
__reset() {
|
|
56
71
|
this.gates = {};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
|
|
11
11
|
// audit F12) — the agent cannot reach these writes conversationally. The file
|
|
12
12
|
// is the plugin's OWN config store; nothing here touches OpenClaw's config.
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { logger } from '../logger.js';
|
|
@@ -82,17 +82,39 @@ export function loadMicroLiveConfig(path) {
|
|
|
82
82
|
catch { /* best-effort — adapter default applies */ }
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
|
+
// mtime(ns)+size memo: several hot paths re-read this file per tool call
|
|
86
|
+
// (approval mode, bracket mode/requirements, exit gate, review mode, the
|
|
87
|
+
// microstructure flag × N symbols on the review path). A write always bumps
|
|
88
|
+
// mtime, so the documented per-call hot-reload semantics are preserved
|
|
89
|
+
// exactly — an unchanged file just costs one stat instead of read+parse.
|
|
90
|
+
// structuredClone on both sides keeps today's fresh-object-per-call contract.
|
|
91
|
+
const readMemo = new Map();
|
|
85
92
|
/** Read the config file. Returns `{}` if the file doesn't exist.
|
|
86
93
|
* Throws if the file exists but is unreadable or not valid JSON — callers
|
|
87
94
|
* should treat that as an abort signal, not silently overwrite. */
|
|
88
95
|
export function readPluginConfig(path = defaultConfigPath()) {
|
|
89
|
-
if (!existsSync(path))
|
|
96
|
+
if (!existsSync(path)) {
|
|
97
|
+
readMemo.delete(path);
|
|
90
98
|
return {};
|
|
99
|
+
}
|
|
100
|
+
let stat;
|
|
101
|
+
try {
|
|
102
|
+
const s = statSync(path, { bigint: true });
|
|
103
|
+
stat = { mtimeNs: s.mtimeNs, size: s.size };
|
|
104
|
+
const hit = readMemo.get(path);
|
|
105
|
+
if (hit && hit.mtimeNs === stat.mtimeNs && hit.size === stat.size) {
|
|
106
|
+
return structuredClone(hit.parsed);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch { /* stat raced a delete — fall through to the plain read */ }
|
|
91
110
|
const raw = readFileSync(path, 'utf-8');
|
|
92
111
|
const parsed = JSON.parse(raw);
|
|
93
112
|
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
94
113
|
throw new Error(`plugin-config.json root is not an object`);
|
|
95
114
|
}
|
|
115
|
+
if (stat) {
|
|
116
|
+
readMemo.set(path, { ...stat, parsed: structuredClone(parsed) });
|
|
117
|
+
}
|
|
96
118
|
return parsed;
|
|
97
119
|
}
|
|
98
120
|
/** Apply a patch on top of the existing file and write atomically.
|
package/config/tool-gate.js
CHANGED
|
@@ -35,6 +35,7 @@ export const UNGOVERNABLE_TOOLS = new Set([
|
|
|
35
35
|
// operator-only channel; disabling them would strand the HL setup flow.
|
|
36
36
|
'hl_provision_agent_wallet',
|
|
37
37
|
'hl_agent_wallet_status',
|
|
38
|
+
'hl_submit_agent_approval',
|
|
38
39
|
]);
|
|
39
40
|
/** RC_TOOL_GATE: 'off' = kill-switch (gate never blocks), 'shadow' = log
|
|
40
41
|
* would-be blocks but execute anyway, anything else/default = 'enforce'.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
3
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
4
|
+
* replaced (mocks/instrumentation). */
|
|
5
|
+
export declare function keepAliveFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Keep-alive HTTP for the plugin's intel/webapp clients.
|
|
2
|
+
//
|
|
3
|
+
// Node's built-in fetch closes idle sockets after undici's 4s default, and the
|
|
4
|
+
// gap between two agent tool calls is LLM think-time (seconds to tens of
|
|
5
|
+
// seconds) — so every intel/webapp call was paying a fresh TCP+TLS handshake
|
|
6
|
+
// (~2 RTTs) before any server work started. A dedicated undici Agent with a
|
|
7
|
+
// 60s idle timeout holds the connection across those gaps.
|
|
8
|
+
//
|
|
9
|
+
// undici loads via createRequire (same rule as CCXT — see
|
|
10
|
+
// docs/CLAUDE/plugin-integration.md) and the whole module FAILS OPEN to the
|
|
11
|
+
// global fetch: dist-only overlay deploys land on boxes whose node_modules
|
|
12
|
+
// predate this dependency, and a missing package must degrade to today's
|
|
13
|
+
// behaviour, never crash the connector.
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
// Captured at module load. When someone REPLACES globalThis.fetch later
|
|
16
|
+
// (vitest fetch mocks, tracing wrappers), keepAliveFetch honors the
|
|
17
|
+
// replacement instead of undici — otherwise every fetch-stubbing test (and
|
|
18
|
+
// any legitimate instrumentation) would be silently bypassed onto the real
|
|
19
|
+
// network.
|
|
20
|
+
const nativeFetch = globalThis.fetch;
|
|
21
|
+
let cached;
|
|
22
|
+
function build() {
|
|
23
|
+
try {
|
|
24
|
+
const req = createRequire(import.meta.url);
|
|
25
|
+
const undici = req('undici');
|
|
26
|
+
const dispatcher = new undici.Agent({
|
|
27
|
+
keepAliveTimeout: 60_000,
|
|
28
|
+
keepAliveMaxTimeout: 300_000,
|
|
29
|
+
connections: 16,
|
|
30
|
+
});
|
|
31
|
+
// undici's own fetch + Agent are used together: passing an npm-undici
|
|
32
|
+
// dispatcher to Node's built-in fetch can fail an instanceof check against
|
|
33
|
+
// the internal undici copy.
|
|
34
|
+
return (url, init) => undici.fetch(url, { ...init, dispatcher });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return (url, init) => fetch(url, init);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
41
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
42
|
+
* replaced (mocks/instrumentation). */
|
|
43
|
+
export function keepAliveFetch(url, init) {
|
|
44
|
+
if (globalThis.fetch !== nativeFetch) {
|
|
45
|
+
return globalThis.fetch(url, init);
|
|
46
|
+
}
|
|
47
|
+
if (!cached)
|
|
48
|
+
cached = build();
|
|
49
|
+
return cached(url, init);
|
|
50
|
+
}
|
package/index.js
CHANGED
|
@@ -68,6 +68,7 @@ import { testExchangeCredentialsTool } from './tools/test-exchange-credentials.j
|
|
|
68
68
|
import { clearExchangeCredentialsTool } from './tools/clear-exchange-credentials.js';
|
|
69
69
|
import { hlProvisionAgentWalletTool } from './tools/hl-provision-agent-wallet.js';
|
|
70
70
|
import { hlAgentWalletStatusTool } from './tools/hl-agent-wallet-status.js';
|
|
71
|
+
import { hlSubmitAgentApprovalTool } from './tools/hl-submit-agent-approval.js';
|
|
71
72
|
import { ensureCredentialTransportKey } from './security/sealed-credentials.js';
|
|
72
73
|
// Tool implementations
|
|
73
74
|
import { fetchTickerTool } from './tools/fetch-ticker.js';
|
|
@@ -850,6 +851,16 @@ const TOOL_PARAMS = {
|
|
|
850
851
|
probe: { type: 'boolean', description: 'Tool-discovery probe — returns immediately without config or network access' },
|
|
851
852
|
},
|
|
852
853
|
},
|
|
854
|
+
hl_submit_agent_approval: {
|
|
855
|
+
type: 'object',
|
|
856
|
+
properties: {
|
|
857
|
+
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
858
|
+
action: { type: 'object', description: "The wallet-signed approveAgent action, verbatim. Only type='approveAgent' is accepted." },
|
|
859
|
+
nonce: { type: 'number', description: 'Outer nonce — must equal action.nonce (user-signed actions carry one nonce)' },
|
|
860
|
+
signature: { type: 'object', description: 'Wallet signature { r, s, v } over the approveAgent typed data' },
|
|
861
|
+
probe: { type: 'boolean', description: 'Tool-discovery probe — returns immediately without config or network access' },
|
|
862
|
+
},
|
|
863
|
+
},
|
|
853
864
|
clear_exchange_credentials: {
|
|
854
865
|
type: 'object',
|
|
855
866
|
properties: {
|
|
@@ -978,7 +989,10 @@ const paperTradingPlugin = {
|
|
|
978
989
|
// gateway process must reload it before serving HTTP API calls.
|
|
979
990
|
const reloadState = () => {
|
|
980
991
|
try {
|
|
981
|
-
|
|
992
|
+
// Skips the full read+parse (and replaceState) when the on-disk file
|
|
993
|
+
// hasn't changed since the last load — this runs on every paper-mode
|
|
994
|
+
// tool call and the file grows with trade history.
|
|
995
|
+
const fresh = stateManager.loadSyncIfChanged();
|
|
982
996
|
if (fresh) {
|
|
983
997
|
simulator.replaceState(fresh);
|
|
984
998
|
}
|
|
@@ -1282,11 +1296,32 @@ const paperTradingPlugin = {
|
|
|
1282
1296
|
// rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
|
|
1283
1297
|
const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
|
|
1284
1298
|
const resolveApprovalMode = () => {
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1299
|
+
// Precedence (config-service slice 4, mirrors loadExitGateMode /
|
|
1300
|
+
// loadPositionReviewMode):
|
|
1301
|
+
//
|
|
1302
|
+
// central (agent_config.gates.approvalMode) → plugin-config.json →
|
|
1303
|
+
// APPROVAL_SHADOW_MODE env → 'off'
|
|
1304
|
+
//
|
|
1305
|
+
// Central is enum-validated plugin-side (agent-config-client
|
|
1306
|
+
// validateGates) and can only ever say 'off' | 'per_trade' — it cannot
|
|
1307
|
+
// enable shadow telemetry and cannot weaken the safety floor. Kill-switch
|
|
1308
|
+
// RC_CENTRAL_GATES=off makes the store report null and the file rules
|
|
1309
|
+
// again. Read per call, so a dashboard flip applies within one poll
|
|
1310
|
+
// (~60 s) with no restart.
|
|
1311
|
+
const central = gateStore.getApprovalMode();
|
|
1312
|
+
if (central === 'per_trade')
|
|
1313
|
+
return 'per_trade';
|
|
1314
|
+
if (central !== 'off') {
|
|
1315
|
+
// No central value — fall back to the local file.
|
|
1316
|
+
try {
|
|
1317
|
+
if (readPluginConfig().approval?.mode === 'per_trade')
|
|
1318
|
+
return 'per_trade';
|
|
1319
|
+
}
|
|
1320
|
+
catch { /* config unreadable — env-only fallback below */ }
|
|
1288
1321
|
}
|
|
1289
|
-
|
|
1322
|
+
// Central 'off' still permits local shadow telemetry: shadow does not
|
|
1323
|
+
// change trading behaviour (the order fires either way), it only writes
|
|
1324
|
+
// an extra row, and the env flag is the operator's own local choice.
|
|
1290
1325
|
return approvalShadowEnabled ? 'shadow' : 'off';
|
|
1291
1326
|
};
|
|
1292
1327
|
// Proposal manager + listener credentials: built whenever ingest
|
|
@@ -1316,8 +1351,18 @@ const paperTradingPlugin = {
|
|
|
1316
1351
|
logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
|
|
1317
1352
|
}
|
|
1318
1353
|
}
|
|
1354
|
+
else if (bootApprovalMode === 'per_trade') {
|
|
1355
|
+
// FAIL-CLOSED state: create_order will REFUSE every new entry until the
|
|
1356
|
+
// credentials are restored or approval.mode is set back to off. Loud at
|
|
1357
|
+
// boot AND at each refusal (create-order.ts) — a boot-only signal is how
|
|
1358
|
+
// this used to go unnoticed while orders fired without the gate.
|
|
1359
|
+
logger.error(TAG, `approval mode=per_trade but ingest token / REEFCLAW_USER_ID missing — proposals CANNOT ` +
|
|
1360
|
+
`reach the operator, so create_order will REFUSE every new entry (fail-closed). ` +
|
|
1361
|
+
`Restore the plugin connectionToken/WEBAPP_INGEST_TOKEN + REEFCLAW_USER_ID, or set ` +
|
|
1362
|
+
`approval.mode=off. Exits, stops, brackets and operator controls are unaffected.`);
|
|
1363
|
+
}
|
|
1319
1364
|
else if (bootApprovalMode !== 'off') {
|
|
1320
|
-
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing —
|
|
1365
|
+
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — shadow telemetry disabled (orders fire directly, as in off mode)`);
|
|
1321
1366
|
}
|
|
1322
1367
|
}
|
|
1323
1368
|
// ---- Create exchange adapter based on trading mode ----
|
|
@@ -1876,6 +1921,14 @@ const paperTradingPlugin = {
|
|
|
1876
1921
|
pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
|
|
1877
1922
|
});
|
|
1878
1923
|
},
|
|
1924
|
+
// The approval path just went away (mode flipped off, or live→PAPER).
|
|
1925
|
+
// Anything still pending is now un-fireable but still shows an Approve
|
|
1926
|
+
// button — cancel it rather than leave the operator a dead control.
|
|
1927
|
+
onApprovalPathDisabled: async () => {
|
|
1928
|
+
if (!proposalManagerCtx)
|
|
1929
|
+
return;
|
|
1930
|
+
await proposalManagerCtx.manager.cancelAll(proposalManagerCtx.userId, 'mode_disabled');
|
|
1931
|
+
},
|
|
1879
1932
|
});
|
|
1880
1933
|
runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
|
|
1881
1934
|
// Boot application — the same path every later swap takes.
|
|
@@ -2726,6 +2779,20 @@ const paperTradingPlugin = {
|
|
|
2726
2779
|
return jsonResult(await hlProvisionAgentWalletTool(params, { runtime, bootVenue: venue }));
|
|
2727
2780
|
},
|
|
2728
2781
|
},
|
|
2782
|
+
{
|
|
2783
|
+
name: 'hl_submit_agent_approval',
|
|
2784
|
+
label: 'Submit Hyperliquid Agent Approval',
|
|
2785
|
+
description: "Operator-only. Submit the operator's wallet-signed approveAgent action to Hyperliquid FROM THIS MACHINE (the dashboard browser is often blocked from reaching the exchange API by ad-blockers/shields). Strictly limited to type='approveAgent' targeting this box's own provisioned agent wallet — it is not a general signed-action relay. Refused without dashboard operator provenance.",
|
|
2786
|
+
parameters: TOOL_PARAMS.hl_submit_agent_approval,
|
|
2787
|
+
execute: async (_id, params) => {
|
|
2788
|
+
if (params?.probe === true)
|
|
2789
|
+
return jsonResult(await hlSubmitAgentApprovalTool(params));
|
|
2790
|
+
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2791
|
+
if (!prov.ok)
|
|
2792
|
+
return jsonResult({ error: prov.error });
|
|
2793
|
+
return jsonResult(await hlSubmitAgentApprovalTool(params));
|
|
2794
|
+
},
|
|
2795
|
+
},
|
|
2729
2796
|
{
|
|
2730
2797
|
name: 'hl_agent_wallet_status',
|
|
2731
2798
|
label: 'Hyperliquid Agent Wallet Status',
|
|
@@ -322,6 +322,12 @@ export declare class PositionDecisionsClient {
|
|
|
322
322
|
private fireAndForget;
|
|
323
323
|
private run;
|
|
324
324
|
private runReturning;
|
|
325
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
326
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
327
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
328
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
329
|
+
private static readonly READ_MAX_ATTEMPTS;
|
|
330
|
+
private static readonly READ_DEADLINE_MS;
|
|
325
331
|
private runGetReturning;
|
|
326
332
|
private sleepBackoff;
|
|
327
333
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// - postEntry/Review/Close are fire-and-forget (don't block the WS hot path).
|
|
13
13
|
//
|
|
14
14
|
// All four routes accept the same auth: Bearer + X-User-Id headers.
|
|
15
|
+
import { keepAliveFetch } from '../http/keepalive-fetch.js';
|
|
15
16
|
import { logger, formatError } from '../logger.js';
|
|
16
17
|
const TAG = 'position-decisions-client';
|
|
17
18
|
export class PositionDecisionsClient {
|
|
@@ -25,7 +26,7 @@ export class PositionDecisionsClient {
|
|
|
25
26
|
this.opts = {
|
|
26
27
|
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
27
28
|
ingestToken: options.ingestToken,
|
|
28
|
-
fetchImpl: options.fetchImpl ??
|
|
29
|
+
fetchImpl: options.fetchImpl ?? keepAliveFetch,
|
|
29
30
|
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
|
|
30
31
|
maxAttempts: options.maxAttempts ?? 4,
|
|
31
32
|
baseBackoffMs: options.baseBackoffMs ?? 250,
|
|
@@ -65,7 +66,12 @@ export class PositionDecisionsClient {
|
|
|
65
66
|
qs.set('mode', mode);
|
|
66
67
|
if (exchange)
|
|
67
68
|
qs.set('exchange', exchange);
|
|
68
|
-
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}
|
|
69
|
+
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`, {
|
|
70
|
+
// Reconcile-sweep read, NOT on the agent decision path — keep the
|
|
71
|
+
// write-grade retry budget: a null here skips a whole reconcile pass.
|
|
72
|
+
maxAttempts: this.opts.maxAttempts,
|
|
73
|
+
deadlineMs: Number.POSITIVE_INFINITY,
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
/** Read endpoint for the Phase 1 self-reflection feature. Awaited.
|
|
71
77
|
* Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
|
|
@@ -246,12 +252,24 @@ export class PositionDecisionsClient {
|
|
|
246
252
|
logger.error(TAG, `POST ${url} dropped after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
|
|
247
253
|
return null;
|
|
248
254
|
}
|
|
249
|
-
|
|
255
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
256
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
257
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
258
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
259
|
+
static READ_MAX_ATTEMPTS = 2;
|
|
260
|
+
static READ_DEADLINE_MS = 8_000;
|
|
261
|
+
async runGetReturning(userId, path, budget) {
|
|
250
262
|
const url = `${this.opts.baseUrl}${path}`;
|
|
251
|
-
|
|
263
|
+
const maxAttempts = budget?.maxAttempts ??
|
|
264
|
+
Math.min(this.opts.maxAttempts, PositionDecisionsClient.READ_MAX_ATTEMPTS);
|
|
265
|
+
const deadlineAt = Date.now() + (budget?.deadlineMs ?? PositionDecisionsClient.READ_DEADLINE_MS);
|
|
266
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
267
|
+
const remainingMs = deadlineAt - Date.now();
|
|
268
|
+
if (remainingMs <= 0)
|
|
269
|
+
break;
|
|
252
270
|
try {
|
|
253
271
|
const ac = new AbortController();
|
|
254
|
-
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
272
|
+
const tid = setTimeout(() => ac.abort(), Math.min(this.opts.requestTimeoutMs, remainingMs));
|
|
255
273
|
let res;
|
|
256
274
|
try {
|
|
257
275
|
res = await this.opts.fetchImpl(url, {
|
|
@@ -285,16 +303,16 @@ export class PositionDecisionsClient {
|
|
|
285
303
|
logger.warn(TAG, `GET ${url} → ${res.status} (terminal, dropped). userId=${userId} body=${errBody.slice(0, 200)}`);
|
|
286
304
|
return null;
|
|
287
305
|
}
|
|
288
|
-
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${
|
|
306
|
+
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${maxAttempts}).`);
|
|
289
307
|
}
|
|
290
308
|
catch (err) {
|
|
291
|
-
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${
|
|
309
|
+
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
|
|
292
310
|
}
|
|
293
|
-
if (attempt <
|
|
311
|
+
if (attempt < maxAttempts && Date.now() < deadlineAt) {
|
|
294
312
|
await this.sleepBackoff(attempt);
|
|
295
313
|
}
|
|
296
314
|
}
|
|
297
|
-
logger.error(TAG, `GET ${url} failed after ${
|
|
315
|
+
logger.error(TAG, `GET ${url} failed after ${maxAttempts} attempt(s)/deadline. userId=${userId}.`);
|
|
298
316
|
return null;
|
|
299
317
|
}
|
|
300
318
|
async sleepBackoff(attempt) {
|
|
@@ -14,6 +14,16 @@ export interface ApprovalLifecycleDeps {
|
|
|
14
14
|
hasWiring: () => boolean;
|
|
15
15
|
/** Build a listener bound to THIS adapter. Called only when starting. */
|
|
16
16
|
buildListener: (adapter: IExchangeAdapter) => StartableListener;
|
|
17
|
+
/** Called when a RUNNING listener was torn down and no replacement started —
|
|
18
|
+
* i.e. the approval path was deliberately disabled (mode flipped away from
|
|
19
|
+
* per_trade, or the live adapter went away). Any proposal still pending is
|
|
20
|
+
* now un-fireable but still approvable on the dashboard, so the caller
|
|
21
|
+
* cancels them (design doc §10 row 12).
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT called from stop() — a shutdown drain is a restart, not a
|
|
24
|
+
* disable, and cancelling the operator's live proposals on every plugin
|
|
25
|
+
* restart would be both wrong and obnoxious. */
|
|
26
|
+
onApprovalPathDisabled?: () => void | Promise<void>;
|
|
17
27
|
}
|
|
18
28
|
export declare class ApprovalListenerLifecycle {
|
|
19
29
|
private readonly deps;
|
|
@@ -52,14 +52,28 @@ export class ApprovalListenerLifecycle {
|
|
|
52
52
|
return this.chain;
|
|
53
53
|
}
|
|
54
54
|
async apply(adapter) {
|
|
55
|
+
// A listener was running before this swap? Then if we end up NOT starting a
|
|
56
|
+
// replacement, whatever is still pending has been orphaned.
|
|
57
|
+
const hadListener = this.listener !== undefined;
|
|
55
58
|
// Always tear down the previous listener first — it is bound to the OLD
|
|
56
59
|
// adapter and must never fire through it again.
|
|
57
60
|
await this.stopCurrent();
|
|
61
|
+
const orphaned = async (why) => {
|
|
62
|
+
if (!hadListener || !this.deps.onApprovalPathDisabled)
|
|
63
|
+
return;
|
|
64
|
+
logger.info(TAG, `approval path disabled (${why}) — cancelling any pending proposals`);
|
|
65
|
+
try {
|
|
66
|
+
await this.deps.onApprovalPathDisabled();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
logger.warn(TAG, `pending-proposal cancel failed: ${formatError(err)}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
58
72
|
if (!adapter.isLive)
|
|
59
|
-
return;
|
|
73
|
+
return orphaned('adapter is no longer live');
|
|
60
74
|
const mode = this.deps.resolveApprovalMode();
|
|
61
75
|
if (mode !== 'per_trade')
|
|
62
|
-
return;
|
|
76
|
+
return orphaned(`approval.mode=${mode}`);
|
|
63
77
|
if (!this.deps.hasWiring()) {
|
|
64
78
|
logger.warn(TAG, "approval.mode='per_trade' but ingest credentials/proposal manager missing — listener NOT started; approvals will not fire");
|
|
65
79
|
return;
|