@linkedclaw/openclaw-plugin 0.1.10 → 0.1.11

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/dist/index.d.ts CHANGED
@@ -136,6 +136,14 @@ interface PluginRuntimeConfig extends ProviderConfig {
136
136
  autoAcceptInvokes: boolean;
137
137
  autoAcceptSessions: boolean;
138
138
  autoAcceptGigTasks: boolean;
139
+ /**
140
+ * Which OpenClaw agent serves marketplace jobs — the `<agent>` segment of the
141
+ * subagent session key (`agent:<id>:subagent:...`). Default `"main"`. Point
142
+ * this at a dedicated Docker-sandboxed agent (sandbox mode=all, scope=agent,
143
+ * restricted tools) so untrusted requester prompts run inside a real boundary,
144
+ * not the operator's main agent. See the provider skill's security step.
145
+ */
146
+ servingAgentId: string;
139
147
  }
140
148
  declare function parseConfig(raw: Record<string, unknown>): PluginRuntimeConfig;
141
149
 
package/dist/index.js CHANGED
@@ -11964,6 +11964,19 @@ var DEFAULT_AUTH_FAILURE_CODES = Object.freeze([
11964
11964
  1008,
11965
11965
  ...Array.from({ length: 99 }, (_, i) => 4001 + i)
11966
11966
  ]);
11967
+ var DEFAULT_AUTH_FATAL_FRAME_CODES = Object.freeze([
11968
+ "identify_invalid",
11969
+ "secret_isolation_violation",
11970
+ "key_revoked",
11971
+ "key_scope_insufficient",
11972
+ "token_invalid",
11973
+ "agent_unauthorized",
11974
+ "actor_unauthorized",
11975
+ "agent_not_found",
11976
+ "mandate_not_found",
11977
+ "mandate_inactive",
11978
+ "mandate_binding_failed"
11979
+ ]);
11967
11980
  var RelayClient = class extends EventEmitter {
11968
11981
  url;
11969
11982
  agentId;
@@ -11973,14 +11986,19 @@ var RelayClient = class extends EventEmitter {
11973
11986
  reconnectBaseMs;
11974
11987
  reconnectMaxMs;
11975
11988
  authGraceMs;
11989
+ handshakeTimeoutMs;
11976
11990
  authFailureCloseCodes;
11991
+ authFatalFrameCodes;
11977
11992
  connector;
11978
11993
  transport;
11979
11994
  heartbeatTimer;
11980
11995
  authGraceTimer;
11981
11996
  stopped = false;
11982
11997
  reconnectAttempt = 0;
11998
+ throttled = false;
11983
11999
  fatalState = null;
12000
+ fireEnd;
12001
+ loopPromise;
11984
12002
  constructor(opts) {
11985
12003
  super();
11986
12004
  this.url = opts.url;
@@ -11991,9 +12009,9 @@ var RelayClient = class extends EventEmitter {
11991
12009
  this.reconnectBaseMs = opts.reconnectBaseMs ?? 1e3;
11992
12010
  this.reconnectMaxMs = opts.reconnectMaxMs ?? 3e4;
11993
12011
  this.authGraceMs = opts.authGraceMs ?? 5e3;
11994
- this.authFailureCloseCodes = new Set(
11995
- opts.authFailureCloseCodes ?? DEFAULT_AUTH_FAILURE_CODES
11996
- );
12012
+ this.handshakeTimeoutMs = opts.handshakeTimeoutMs ?? 1e4;
12013
+ this.authFailureCloseCodes = new Set(opts.authFailureCloseCodes ?? DEFAULT_AUTH_FAILURE_CODES);
12014
+ this.authFatalFrameCodes = new Set(opts.authFatalFrameCodes ?? DEFAULT_AUTH_FATAL_FRAME_CODES);
11997
12015
  this.connector = opts.connector ?? nodeWsConnector;
11998
12016
  }
11999
12017
  getFatal() {
@@ -12005,27 +12023,17 @@ var RelayClient = class extends EventEmitter {
12005
12023
  emit(event, ...args) {
12006
12024
  return super.emit(event, ...args);
12007
12025
  }
12026
+ /**
12027
+ * Open the first connection. Resolves once identify is sent; rejects on
12028
+ * first-attempt failure ("initial failure = caller's problem" — e.g. the
12029
+ * plugin holders surface it to the operator). Either way, NO background
12030
+ * reconnect chains survive a failed connect().
12031
+ */
12008
12032
  async connect() {
12009
12033
  this.stopped = false;
12010
- await this.openOnce();
12011
- }
12012
- async openOnce() {
12013
- try {
12014
- const { transport, ready } = await this.connector(this.url);
12015
- this.transport = transport;
12016
- transport.addMessageListener((data) => this.handleFrame(data));
12017
- transport.addCloseListener(
12018
- (code, reason) => this.handleClose(code, `close ${code}: ${reason}`)
12019
- );
12020
- transport.addErrorListener((err) => this.handleClose(null, `error: ${err.message}`));
12021
- await ready;
12022
- await this.sendIdentify();
12023
- this.startHeartbeat();
12024
- this.emit("connected");
12025
- this.startAuthGraceTimer();
12026
- } catch (err) {
12027
- throw new NetworkError(`relay connect failed: ${err.message}`, err);
12028
- }
12034
+ this.fatalState = null;
12035
+ const first = await this.openConnection();
12036
+ this.loopPromise = this.runLoop(first.ended);
12029
12037
  }
12030
12038
  async send(frame) {
12031
12039
  if (!this.transport) throw new NetworkError("relay not connected");
@@ -12042,7 +12050,132 @@ var RelayClient = class extends EventEmitter {
12042
12050
  }
12043
12051
  this.transport = void 0;
12044
12052
  }
12053
+ this.fireEnd?.({ code: 1e3, reason: "client_stop" });
12054
+ await this.loopPromise?.catch(() => {
12055
+ });
12056
+ this.loopPromise = void 0;
12057
+ }
12058
+ // ------------------------------------------------------------------
12059
+ // Connection lifecycle (single-flight)
12060
+ // ------------------------------------------------------------------
12061
+ async openConnection() {
12062
+ let resolveEnd;
12063
+ const ended = new Promise((r) => {
12064
+ resolveEnd = r;
12065
+ });
12066
+ let fired = false;
12067
+ const fireEnd = (e) => {
12068
+ if (!fired) {
12069
+ fired = true;
12070
+ resolveEnd(e);
12071
+ }
12072
+ };
12073
+ this.fireEnd = fireEnd;
12074
+ try {
12075
+ const { transport, ready } = await this.connector(this.url, {
12076
+ handshakeTimeoutMs: this.handshakeTimeoutMs
12077
+ });
12078
+ transport.addMessageListener((data) => this.handleFrame(data));
12079
+ transport.addCloseListener((code, reason) => fireEnd({ code, reason: `close ${code}: ${reason}` }));
12080
+ transport.addErrorListener(
12081
+ (err) => fireEnd({
12082
+ code: null,
12083
+ reason: `error: ${err.message}`,
12084
+ httpStatus: err.httpStatus
12085
+ })
12086
+ );
12087
+ await this.withHandshakeTimeout(ready);
12088
+ this.transport = transport;
12089
+ await this.sendIdentify();
12090
+ this.startHeartbeat();
12091
+ this.emit("connected");
12092
+ this.startAuthGraceTimer();
12093
+ return { ended };
12094
+ } catch (err) {
12095
+ fireEnd({
12096
+ code: null,
12097
+ reason: `connect failed: ${err.message}`,
12098
+ httpStatus: err.httpStatus
12099
+ });
12100
+ throw new NetworkError(`relay connect failed: ${err.message}`, err);
12101
+ }
12102
+ }
12103
+ async withHandshakeTimeout(ready) {
12104
+ let timer;
12105
+ const timeout = new Promise((_, reject) => {
12106
+ timer = setTimeout(
12107
+ () => reject(new Error(`handshake timeout after ${this.handshakeTimeoutMs}ms`)),
12108
+ this.handshakeTimeoutMs
12109
+ );
12110
+ timer.unref?.();
12111
+ });
12112
+ try {
12113
+ await Promise.race([ready, timeout]);
12114
+ } finally {
12115
+ if (timer) clearTimeout(timer);
12116
+ }
12045
12117
  }
12118
+ async runLoop(firstEnded) {
12119
+ let ended = firstEnded;
12120
+ while (true) {
12121
+ const end = await ended;
12122
+ this.stopHeartbeat();
12123
+ this.cancelAuthGraceTimer();
12124
+ this.transport = void 0;
12125
+ this.emit("disconnected", end.reason);
12126
+ if (this.stopped) return;
12127
+ const fatal = this.classifyFatal(end);
12128
+ if (fatal) {
12129
+ this.stopped = true;
12130
+ this.fatalState = fatal;
12131
+ this.emit("fatal", fatal);
12132
+ return;
12133
+ }
12134
+ this.throttled = end.code === 1013;
12135
+ for (; ; ) {
12136
+ await this.backoffDelay();
12137
+ if (this.stopped) return;
12138
+ try {
12139
+ ({ ended } = await this.openConnection());
12140
+ break;
12141
+ } catch (err) {
12142
+ const status = err.httpStatus ?? err.cause?.httpStatus;
12143
+ if (status === 401 || status === 403) {
12144
+ const info = { reason: `upgrade rejected: HTTP ${status}`, code: null };
12145
+ this.stopped = true;
12146
+ this.fatalState = info;
12147
+ this.emit("fatal", info);
12148
+ return;
12149
+ }
12150
+ }
12151
+ }
12152
+ }
12153
+ }
12154
+ classifyFatal(end) {
12155
+ if (end.fatalFrameCode) {
12156
+ return { reason: `fatal error frame: ${end.fatalFrameCode}`, code: end.code };
12157
+ }
12158
+ if (end.code !== null && this.authFailureCloseCodes.has(end.code)) {
12159
+ return { reason: end.reason, code: end.code };
12160
+ }
12161
+ if (end.httpStatus === 401 || end.httpStatus === 403) {
12162
+ return { reason: `upgrade rejected: HTTP ${end.httpStatus}`, code: null };
12163
+ }
12164
+ return null;
12165
+ }
12166
+ async backoffDelay() {
12167
+ this.reconnectAttempt += 1;
12168
+ let base = Math.min(this.reconnectBaseMs * 2 ** (this.reconnectAttempt - 1), this.reconnectMaxMs);
12169
+ if (this.throttled) base = Math.min(base * 2, this.reconnectMaxMs * 2);
12170
+ const jitter = base * (0.5 + Math.random());
12171
+ await new Promise((r) => {
12172
+ const t = setTimeout(r, jitter);
12173
+ t.unref?.();
12174
+ });
12175
+ }
12176
+ // ------------------------------------------------------------------
12177
+ // Frames / heartbeat / grace (unchanged semantics)
12178
+ // ------------------------------------------------------------------
12046
12179
  async sendIdentify() {
12047
12180
  await this.send({
12048
12181
  type: MessageType.IDENTIFY,
@@ -12077,33 +12210,30 @@ var RelayClient = class extends EventEmitter {
12077
12210
  } catch {
12078
12211
  return;
12079
12212
  }
12213
+ if (frame.type === "error" && typeof frame.code === "string" && this.authFatalFrameCodes.has(frame.code)) {
12214
+ this.fireEnd?.({
12215
+ code: null,
12216
+ reason: `error frame: ${frame.code}`,
12217
+ fatalFrameCode: frame.code
12218
+ });
12219
+ }
12080
12220
  this.emit("raw", frame);
12081
12221
  const evt = parseInbound(frame);
12082
12222
  if (evt) this.emit("event", evt);
12083
12223
  }
12084
- handleClose(code, reason) {
12085
- this.stopHeartbeat();
12086
- this.cancelAuthGraceTimer();
12087
- this.transport = void 0;
12088
- this.emit("disconnected", reason);
12089
- if (this.stopped) return;
12090
- if (code !== null && this.authFailureCloseCodes.has(code)) {
12091
- this.stopped = true;
12092
- this.fatalState = { reason, code };
12093
- this.emit("fatal", { reason, code });
12094
- return;
12095
- }
12096
- void this.scheduleReconnect();
12097
- }
12098
12224
  startAuthGraceTimer() {
12099
12225
  this.cancelAuthGraceTimer();
12100
12226
  if (this.authGraceMs <= 0) {
12101
12227
  this.reconnectAttempt = 0;
12228
+ this.throttled = false;
12102
12229
  return;
12103
12230
  }
12104
12231
  this.authGraceTimer = setTimeout(() => {
12105
12232
  this.authGraceTimer = void 0;
12106
- if (this.transport) this.reconnectAttempt = 0;
12233
+ if (this.transport) {
12234
+ this.reconnectAttempt = 0;
12235
+ this.throttled = false;
12236
+ }
12107
12237
  }, this.authGraceMs);
12108
12238
  if (typeof this.authGraceTimer.unref === "function") {
12109
12239
  this.authGraceTimer.unref();
@@ -12115,30 +12245,15 @@ var RelayClient = class extends EventEmitter {
12115
12245
  this.authGraceTimer = void 0;
12116
12246
  }
12117
12247
  }
12118
- async scheduleReconnect() {
12119
- this.reconnectAttempt += 1;
12120
- const base = Math.min(
12121
- this.reconnectBaseMs * 2 ** (this.reconnectAttempt - 1),
12122
- this.reconnectMaxMs
12123
- );
12124
- const jitter = base * (0.5 + Math.random());
12125
- await new Promise((r) => {
12126
- const t = setTimeout(r, jitter);
12127
- if (typeof t.unref === "function") {
12128
- t.unref();
12129
- }
12130
- });
12131
- if (this.stopped) return;
12132
- try {
12133
- await this.openOnce();
12134
- } catch {
12135
- if (!this.stopped) void this.scheduleReconnect();
12136
- }
12137
- }
12138
12248
  };
12139
- var nodeWsConnector = async (url) => {
12249
+ var nodeWsConnector = async (url, opts) => {
12140
12250
  const { WebSocket: WebSocket2 } = await Promise.resolve().then(() => (init_wrapper(), wrapper_exports));
12141
- const ws = new WebSocket2(url);
12251
+ const ws = new WebSocket2(url, { handshakeTimeout: opts?.handshakeTimeoutMs ?? 1e4 });
12252
+ ws.on("unexpected-response", (_req, res) => {
12253
+ const err = new Error(`unexpected server response: ${res.statusCode}`);
12254
+ err.httpStatus = res.statusCode;
12255
+ ws.emit("error", err);
12256
+ });
12142
12257
  const transport = {
12143
12258
  send: (data) => {
12144
12259
  return new Promise((resolve, reject) => {
@@ -12318,6 +12433,8 @@ var DEFAULT_INVOKE_TIMEOUT_MS = 3e4;
12318
12433
  var DEFAULT_SESSION_TURN_TIMEOUT_MS = 6e4;
12319
12434
  var DEFAULT_GIG_TASK_OFFER_TIMEOUT_MS = 3e4;
12320
12435
  var DEFAULT_GIG_TASK_EXECUTE_TIMEOUT_MS = 3e5;
12436
+ var DEFAULT_SESSION_IDLE_TIMEOUT_MS = 144e5;
12437
+ var DEFAULT_SESSION_REAPER_INTERVAL_MS = 3e5;
12321
12438
  var GIG_TASK_SUBMIT_MAX_RETRIES = 3;
12322
12439
  var GIG_TASK_SUBMIT_BASE_MS = 500;
12323
12440
  var GIG_TASK_SUBMIT_CAP_MS = 8e3;
@@ -12335,6 +12452,7 @@ var ProviderRuntime = class {
12335
12452
  running = false;
12336
12453
  connected = false;
12337
12454
  fatal = null;
12455
+ reaperTimer = null;
12338
12456
  constructor(deps) {
12339
12457
  this.cloud = deps.cloud;
12340
12458
  this.relay = deps.relay;
@@ -12545,10 +12663,45 @@ var ProviderRuntime = class {
12545
12663
  this.relay.on("event", (evt) => {
12546
12664
  void this.dispatch(evt);
12547
12665
  });
12666
+ const interval = this.config.sessionReaperIntervalMs ?? DEFAULT_SESSION_REAPER_INTERVAL_MS;
12667
+ this.reaperTimer = setInterval(() => {
12668
+ void this.reapIdleSessions();
12669
+ }, interval);
12670
+ this.reaperTimer.unref?.();
12548
12671
  await this.relay.connect();
12549
12672
  }
12673
+ /**
12674
+ * Evict sessions idle past the TTL (#28a). For each, run onSessionEnd(..., "idle_timeout")
12675
+ * so provider-side cleanup fires, emit `session_reaped` for observability, and free the
12676
+ * slot so a subsequent create succeeds where it would otherwise hit `provider_busy`.
12677
+ * Mirrors py `ProviderSkill._reaper_loop`.
12678
+ */
12679
+ async reapIdleSessions() {
12680
+ const ttl = this.config.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;
12681
+ const now = performance.now();
12682
+ for (const sessionId of [...this.activeSessions.keys()]) {
12683
+ const session = this.activeSessions.get(sessionId);
12684
+ if (!session || now - session.lastActivity < ttl) continue;
12685
+ this.activeSessions.delete(sessionId);
12686
+ this.emitEvent("session_reaped", { session_id: sessionId });
12687
+ if (this.handler.onSessionEnd) {
12688
+ try {
12689
+ await this.handler.onSessionEnd({
12690
+ type: "session.end",
12691
+ session_id: sessionId,
12692
+ reason: "idle_timeout"
12693
+ });
12694
+ } catch {
12695
+ }
12696
+ }
12697
+ }
12698
+ }
12550
12699
  async stop() {
12551
12700
  this.running = false;
12701
+ if (this.reaperTimer !== null) {
12702
+ clearInterval(this.reaperTimer);
12703
+ this.reaperTimer = null;
12704
+ }
12552
12705
  if (this.activeSessions.size > 0) {
12553
12706
  this.emitEvent("draining", { live_sessions: this.activeSessions.size });
12554
12707
  }
@@ -12656,7 +12809,9 @@ var ProviderRuntime = class {
12656
12809
  const activeSession = {
12657
12810
  requesterId: evt.requester_id,
12658
12811
  capability: evt.capability,
12659
- replySeq: 0
12812
+ replySeq: 0,
12813
+ lastActivity: performance.now()
12814
+ // monotonic — reaper parity (#28a)
12660
12815
  };
12661
12816
  const acceptExtras = {};
12662
12817
  const reqEphB64 = evt.eph_enc_pub;
@@ -12677,6 +12832,7 @@ var ProviderRuntime = class {
12677
12832
  async handleSessionMessage(evt) {
12678
12833
  const session = this.activeSessions.get(evt.session_id);
12679
12834
  if (!session) return;
12835
+ session.lastActivity = performance.now();
12680
12836
  const timeout = this.config.sessionTurnTimeoutMs ?? DEFAULT_SESSION_TURN_TIMEOUT_MS;
12681
12837
  let handlerEvt = evt;
12682
12838
  if (session.K) {
@@ -12763,7 +12919,7 @@ var ProviderRuntime = class {
12763
12919
  }
12764
12920
  }
12765
12921
  async handleSessionEnd(evt) {
12766
- this.activeSessions.delete(evt.session_id);
12922
+ if (!this.activeSessions.delete(evt.session_id)) return;
12767
12923
  if (this.handler.onSessionEnd) {
12768
12924
  try {
12769
12925
  await this.handler.onSessionEnd(evt);
@@ -13075,7 +13231,8 @@ function parseConfig(raw) {
13075
13231
  autoStartProvider: raw["autoStartProvider"] !== false,
13076
13232
  autoAcceptInvokes: raw["autoAcceptInvokes"] !== false,
13077
13233
  autoAcceptSessions: raw["autoAcceptSessions"] !== false,
13078
- autoAcceptGigTasks: raw["autoAcceptGigTasks"] === true
13234
+ autoAcceptGigTasks: raw["autoAcceptGigTasks"] === true,
13235
+ servingAgentId: typeof raw["servingAgentId"] === "string" && raw["servingAgentId"].trim() ? raw["servingAgentId"].trim() : "main"
13079
13236
  };
13080
13237
  }
13081
13238
 
@@ -13206,13 +13363,13 @@ var SubagentHandler = class {
13206
13363
  }
13207
13364
  // ───── keys ─────
13208
13365
  sessionKey(id) {
13209
- return `agent:main:subagent:linkedclaw-session-${id}`;
13366
+ return `agent:${this.config.servingAgentId}:subagent:linkedclaw-session-${id}`;
13210
13367
  }
13211
13368
  invokeKey(id) {
13212
- return `agent:main:subagent:linkedclaw-invoke-${id}`;
13369
+ return `agent:${this.config.servingAgentId}:subagent:linkedclaw-invoke-${id}`;
13213
13370
  }
13214
13371
  gigTaskKey(id) {
13215
- return `agent:main:subagent:linkedclaw-gig-task-${id}`;
13372
+ return `agent:${this.config.servingAgentId}:subagent:linkedclaw-gig-task-${id}`;
13216
13373
  }
13217
13374
  };
13218
13375
  function formatPayload(payload) {