@gholl-studio/pier-connector 0.3.27 → 0.6.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.
@@ -18,6 +18,21 @@
18
18
  "default": "https://pier-connector.gholl.com/api/v1",
19
19
  "description": "Pier API Endpoint (from Website)"
20
20
  },
21
+ "nodeId": {
22
+ "type": "string",
23
+ "default": "",
24
+ "description": "Pier Node ID (from Website)"
25
+ },
26
+ "secretKey": {
27
+ "type": "string",
28
+ "default": "",
29
+ "description": "Pier Secret Key (from Website)"
30
+ },
31
+ "privateKey": {
32
+ "type": "string",
33
+ "default": "",
34
+ "description": "Pier Private (Signing) Key (starts with 0x)"
35
+ },
21
36
 
22
37
  "natsUrl": {
23
38
  "type": "string",
@@ -58,6 +73,18 @@
58
73
  "label": "Pier API URL",
59
74
  "placeholder": "https://pier-connector.gholl.com/api/v1"
60
75
  },
76
+ "nodeId": {
77
+ "label": "Node ID",
78
+ "placeholder": "node-xxxxxxxx"
79
+ },
80
+ "secretKey": {
81
+ "label": "Secret Key",
82
+ "placeholder": "sk-xxxxxxxx"
83
+ },
84
+ "privateKey": {
85
+ "label": "Private Key (0x...)",
86
+ "placeholder": "0x..."
87
+ },
61
88
 
62
89
  "natsUrl": {
63
90
  "label": "NATS WebSocket URL (Override)",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gholl-studio/pier-connector",
3
3
  "author": "gholl",
4
- "version": "0.3.27",
4
+ "version": "0.6.2",
5
5
  "description": "OpenClaw plugin that connects to the Pier job marketplace. Automatically fetches, executes, and reports distributed tasks for rewards.",
6
6
  "type": "module",
7
7
  "main": "src/index.ts",
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "license": "MIT",
42
42
  "dependencies": {
43
- "@gholl-studio/pier-sdk": "^1.1.0",
43
+ "@gholl-studio/pier-sdk": "file:../packages/pier-sdk",
44
44
  "@nats-io/jetstream": "^3.3.1",
45
45
  "@nats-io/transport-node": "^3.0.0",
46
46
  "ethers": "^6.16.0",
package/src/inbound.ts CHANGED
@@ -87,7 +87,7 @@ export async function handleInbound(
87
87
  Surface: 'pier',
88
88
  OriginatingChannel: 'pier',
89
89
  OriginatingTo: `chat:${jobId}`,
90
- WasMentioned: true,
90
+ WasMentioned: inbound.WasMentioned ?? true,
91
91
  CommandAuthorized: true,
92
92
  SystemPrompt: injectedPrompt,
93
93
  MessageId: inbound.messageId,
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
8
8
  import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk';
9
- import { protocol } from '@gholl-studio/pier-sdk';
9
+ import { protocol, PierCrypto, TaskRequestParams } from '@gholl-studio/pier-sdk';
10
10
  const { createRequestPayload } = protocol;
11
11
  import { DEFAULTS } from './config.js';
12
12
  import { PierRobot } from './robot.js';
@@ -27,7 +27,7 @@ function mergedCfgFrom(legacy: any, account: any): PierAccountConfig {
27
27
  pierApiUrl: merged.pierApiUrl || DEFAULTS.PIER_API_URL,
28
28
  nodeId: merged.nodeId || DEFAULTS.NODE_ID,
29
29
  secretKey: merged.secretKey || DEFAULTS.SECRET_KEY,
30
- privateKey: merged.privateKey || process.env.PIER_PRIVATE_KEY || DEFAULTS.PRIVATE_KEY,
30
+ privateKey: merged.privateKey || DEFAULTS.PRIVATE_KEY,
31
31
  natsUrl: merged.natsUrl || DEFAULTS.NATS_URL,
32
32
  subject: merged.subject || DEFAULTS.SUBJECT,
33
33
  publishSubject: merged.publishSubject || DEFAULTS.PUBLISH_SUBJECT,
@@ -405,6 +405,256 @@ const register = (api: PierPluginApi) => {
405
405
  }
406
406
  }, { optional: true });
407
407
 
408
+ // Multi-Agent Workspace Tools
409
+ api.registerTool({
410
+ name: 'pier_create_workspace',
411
+ label: 'Create Workspace',
412
+ description: 'Creates a decentralized multi-agent workspace (group chat) and initializes its whiteboard.',
413
+ parameters: {
414
+ type: 'object',
415
+ properties: {
416
+ namespace: { type: 'string', description: 'Federation namespace. Default to "default" if omitted.' }
417
+ }
418
+ },
419
+ async execute(_id, params, ctx: any) {
420
+ const accountId = params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default';
421
+ const robot = instances.get(accountId) || instances.values().next().value;
422
+ if (!robot) return { content: [{ type: 'text', text: 'Error: Robot not found' }], details: {} };
423
+
424
+ try {
425
+ const groupId = (globalThis.crypto as any).randomUUID ? (globalThis.crypto as any).randomUUID() : (Math.random().toString(36).substring(2));
426
+ const kv = await robot.client.nats.getWorkspaceKV(groupId);
427
+ await kv.put('status', new TextEncoder().encode('Active Workspace Created'));
428
+ await kv.put('budget', new TextEncoder().encode('100'));
429
+ return { content: [{ type: 'text', text: `Workspace created successfully. Group ID: ${groupId}. Initial budget set to 100.` }], details: { groupId } };
430
+ } catch (err: any) {
431
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
432
+ }
433
+ }
434
+ }, { optional: true });
435
+
436
+ api.registerTool({
437
+ name: 'pier_read_whiteboard',
438
+ label: 'Read Whiteboard',
439
+ description: 'Read the current shared state from the workspace whiteboard (NATS KV)',
440
+ parameters: {
441
+ type: 'object',
442
+ properties: {
443
+ groupId: { type: 'string', description: 'The Workspace Group ID' },
444
+ key: { type: 'string', description: 'The key to read' },
445
+ namespace: { type: 'string', description: 'Federation namespace' }
446
+ },
447
+ required: ['groupId', 'key']
448
+ },
449
+ async execute(_id, params, ctx: any) {
450
+ const robot = instances.get(params.accountId || ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
451
+ if (!robot) return { content: [{ type: 'text', text: 'Error: Robot not found' }], details: {} };
452
+
453
+ try {
454
+ const kv = await robot.client.nats.getWorkspaceKV(params.groupId);
455
+ const entry = await kv.get(params.key);
456
+ if (!entry) return { content: [{ type: 'text', text: '[Whiteboard: Key Not Found. Use revision 0 to update.]' }], details: { revision: 0 } };
457
+ const valueStr = new TextDecoder().decode(entry.value);
458
+ const result = JSON.stringify({ revision: entry.revision, value: valueStr });
459
+ return { content: [{ type: 'text', text: `Data retrieved:\n${result}\n(Supply this revision to pier_update_whiteboard)` }], details: { revision: entry.revision } };
460
+ } catch (err: any) {
461
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
462
+ }
463
+ }
464
+ }, { optional: true });
465
+
466
+ api.registerTool({
467
+ name: 'pier_update_whiteboard',
468
+ label: 'Update Whiteboard',
469
+ description: 'Update the shared state on the workspace whiteboard using CAS (Compare-And-Swap). Provide expected_revision from read.',
470
+ parameters: {
471
+ type: 'object',
472
+ properties: {
473
+ groupId: { type: 'string' },
474
+ key: { type: 'string' },
475
+ value: { type: 'string' },
476
+ expected_revision: { type: 'number', description: 'The revision number from the last read (use 0 if key not found)' },
477
+ namespace: { type: 'string' }
478
+ },
479
+ required: ['groupId', 'key', 'value', 'expected_revision']
480
+ },
481
+ async execute(_id, params, ctx: any) {
482
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
483
+ if (!robot) return { content: [{ type: 'text', text: 'Robot not found' }], details: {} };
484
+
485
+ try {
486
+ const kv = await robot.client.nats.getWorkspaceKV(params.groupId);
487
+
488
+ try {
489
+ // if expected_revision is 0 it means we expect the key does not exist yet (or we use create)
490
+ if (params.expected_revision === 0) {
491
+ try {
492
+ await kv.create(params.key, new TextEncoder().encode(params.value));
493
+ } catch (e: any) {
494
+ if (e.message?.includes('wrong last sequence')) throw e; // Pass to conflict handler
495
+ await kv.update(params.key, new TextEncoder().encode(params.value), 0); // fallback
496
+ }
497
+ } else {
498
+ await kv.update(params.key, new TextEncoder().encode(params.value), params.expected_revision);
499
+ }
500
+ return { content: [{ type: 'text', text: `Whiteboard key '${params.key}' updated successfully.` }], details: {} };
501
+ } catch (updateErr: any) {
502
+ if (updateErr.message && (updateErr.message.includes('wrong last sequence') || updateErr.code === '400' || updateErr.api_error?.err_code === 10071)) {
503
+ return { content: [{ type: 'text', text: `ERROR: CAS Conflict - Another agent modified this key while you were working. Please use pier_read_whiteboard to get the latest revision and try again.` }], details: {} };
504
+ }
505
+ throw updateErr;
506
+ }
507
+ } catch (err: any) {
508
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
509
+ }
510
+ }
511
+ }, { optional: true });
512
+
513
+ api.registerTool({
514
+ name: 'pier_group_mention',
515
+ label: 'Mention in Workspace',
516
+ description: 'Send a message in the workspace group chat explicitly mentioning an agent to wake them up',
517
+ parameters: {
518
+ type: 'object',
519
+ properties: {
520
+ groupId: { type: 'string' },
521
+ mentions: { type: 'array', items: { type: 'string' }, description: 'Array of target Agent Node IDs' },
522
+ content: { type: 'string', description: 'Message content' },
523
+ namespace: { type: 'string' }
524
+ },
525
+ required: ['groupId', 'mentions', 'content']
526
+ },
527
+ async execute(_id, params, ctx: any) {
528
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
529
+ if (!robot) return { content: [{ type: 'text', text: 'Robot not found' }], details: {} };
530
+
531
+ try {
532
+ const msgId = (globalThis.crypto as any).randomUUID ? (globalThis.crypto as any).randomUUID() : (Math.random().toString(36).substring(2));
533
+ const ns = params.namespace || 'default';
534
+
535
+ let payload: any = {
536
+ version: 'psp-1.0',
537
+ id: msgId,
538
+ namespace: ns,
539
+ timestamp: Date.now(),
540
+ senderId: robot.accountId,
541
+ groupId: params.groupId,
542
+ content: params.content,
543
+ mentions: params.mentions
544
+ };
545
+
546
+ if (robot.config.privateKey) {
547
+ payload = await PierCrypto.signSwarmMessage(payload, robot.config.privateKey);
548
+ }
549
+
550
+ await robot.client.nats.publishGroupChat(params.groupId, payload, ns);
551
+ return { content: [{ type: 'text', text: `Message sent to workspace ${params.groupId} successfully.` }], details: {} };
552
+ } catch (err: any) {
553
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], details: {} };
554
+ }
555
+ }
556
+ }, { optional: true });
557
+
558
+ api.registerTool({
559
+ name: 'pier_search_workers',
560
+ label: 'Search Workers',
561
+ description: 'Find active agents on the Pier network. When \`capability\` is provided, uses fast Subject-based Discovery (PSP 1.0). Falls back to KV full-scan if omitted.',
562
+ parameters: {
563
+ type: 'object',
564
+ properties: {
565
+ capability: { type: 'string', description: '(Recommended) Filter by a specific capability e.g. "translation", "code-execution". Uses fast per-subject discovery when set.' }
566
+ }
567
+ },
568
+ async execute(_id, params, ctx: any) {
569
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
570
+ if (!robot) return { content: [{ type: 'text', text: 'Error' }], details: {} };
571
+
572
+ // --- PSP 1.0 Subject-based Discovery (fast path, used when capability is specified) ---
573
+ if (params.capability) {
574
+ try {
575
+ const nodes = await robot.client.nats.queryDiscovery(params.capability);
576
+ return { content: [{ type: 'text', text: JSON.stringify(nodes, null, 2) }], details: {} };
577
+ } catch (e: any) {
578
+ // Fall through to KV scan
579
+ }
580
+ }
581
+
582
+ // --- KV Full-scan (fallback when no capability filter, or subject discovery fails) ---
583
+ try {
584
+ const kv = await robot.client.nats.getWorkspaceKV('active_nodes');
585
+ const activeNodes = [];
586
+ const iter = await kv.keys();
587
+ for await (const k of iter) {
588
+ const entry = await kv.get(k);
589
+ if (entry) {
590
+ try {
591
+ // S10: DoS protection
592
+ if (entry.value.length > 1024 * 1024) continue;
593
+ const nodeData = JSON.parse(new TextDecoder().decode(entry.value));
594
+ if (Date.now() - nodeData.timestamp < 120000) {
595
+ // --- Zero-Trust: Verify signed heartbeat (PSP 1.0 Hard-Block) ---
596
+ if (nodeData.version === 'psp-1.0' && nodeData.signature && nodeData.publicKey) {
597
+ const isValid = PierCrypto.verifySwarmMessage(nodeData);
598
+ if (!isValid) {
599
+ console.warn(`[pier_search_workers] 🔴 Dropping node ${k}: invalid signature (spoofed identity).`);
600
+ continue;
601
+ }
602
+ }
603
+ activeNodes.push(nodeData);
604
+ }
605
+ } catch (e) {}
606
+ }
607
+ }
608
+ return { content: [{ type: 'text', text: JSON.stringify(activeNodes.slice(0, 10), null, 2) }], details: {} };
609
+ } catch (e: any) {
610
+ // Final fallback: central API
611
+ try {
612
+ const resp = await fetch(`${robot.client.apiUrl}/nodes`);
613
+ const nodes = await resp.json();
614
+ const active = nodes.filter((n: any) => n.is_online).slice(0, 10);
615
+ return { content: [{ type: 'text', text: JSON.stringify(active, null, 2) }], details: {} };
616
+ } catch (fallbackErr: any) {
617
+ return { content: [{ type: 'text', text: `All discovery methods failed: ${e.message}` }], details: {} };
618
+ }
619
+ }
620
+ }
621
+ }, { optional: true });
622
+
623
+
624
+ api.registerTool({
625
+ name: 'pier_invite_collaborator',
626
+ label: 'Invite to Workspace',
627
+ description: 'Send a targeted job request to a node to invite them.',
628
+ parameters: {
629
+ type: 'object',
630
+ properties: {
631
+ nodeId: { type: 'string' },
632
+ groupId: { type: 'string' },
633
+ roleDescription: { type: 'string' },
634
+ namespace: { type: 'string' }
635
+ },
636
+ required: ['nodeId', 'groupId', 'roleDescription']
637
+ },
638
+ async execute(_id, params, ctx: any) {
639
+ const robot = instances.get(ctx?.metadata?.accountId || ctx?.accountId || 'default') || instances.values().next().value;
640
+ if (!robot) return { content: [{ type: 'text', text: 'Error' }], details: {} };
641
+ try {
642
+ let req: any = createRequestPayload({
643
+ task: `INVITATION: Join my workspace ${params.groupId}.\nRole: ${params.roleDescription}\nPlease use 'pier_group_mention' tool to reply in group ${params.groupId}.\nYou can also use 'pier_read_whiteboard' with groupId=${params.groupId} to view the plan.`,
644
+ targetNodeId: params.nodeId,
645
+ namespace: params.namespace || 'default',
646
+ senderId: robot.accountId,
647
+ meta: { sender: robot.accountId, workspace: params.groupId }
648
+ } as TaskRequestParams);
649
+ if (robot.config.privateKey) {
650
+ req = await PierCrypto.signSwarmMessage(req, robot.config.privateKey);
651
+ }
652
+ await robot.js!.publish(`jobs.node.${params.nodeId}`, new TextEncoder().encode(JSON.stringify(req)));
653
+ return { content: [{ type: 'text', text: `Invitation sent to ${params.nodeId}` }], details: {} };
654
+ } catch(e: any) { return { content: [{ type: 'text', text: e.message }], details: {} }; }
655
+ }
656
+ }, { optional: true });
657
+
408
658
  // Status Command
409
659
  api.registerCommand({
410
660
  name: 'pier',
@@ -10,6 +10,12 @@ const { normalizeInboundPayload } = protocol;
10
10
  * Parse a raw NATS message into a structured job object.
11
11
  */
12
12
  export function parseJob(msg: any, logger?: any) {
13
+ // S10: DoS protection - check raw bytes before stringifying
14
+ if (msg.data && msg.data.length > 1024 * 1024) { // 1MB limit
15
+ if (logger) logger.error(`[pier-connector] Payload too large: ${msg.data.length} bytes. Dropped.`);
16
+ return { ok: false, error: 'Payload size exceeded' };
17
+ }
18
+
13
19
  let raw: string;
14
20
  try {
15
21
  raw = msg.string();
package/src/robot.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  * @description Implements the PierRobot class, managing NATS connectivity, heartbeat, and job lifecycle.
4
4
  */
5
5
 
6
- import { PierClient, protocol } from '@gholl-studio/pier-sdk';
6
+ import { PierClient, protocol, GroupMessagePayload, PierCrypto } from '@gholl-studio/pier-sdk';
7
7
  const { createRequestPayload, createResultPayload, createErrorPayload, normalizeInboundPayload } = protocol;
8
8
  import { safeRespond, truncate } from './job-handler.js';
9
9
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, type JetStreamClient, type JetStreamManager } from '@nats-io/jetstream';
10
+ import { ethers } from 'ethers';
10
11
  import type { PierAccountConfig, PierPluginApi, InboundMessage } from './types.js';
11
12
 
12
13
  type NatsConnection = any; // Fallback for NatsConnection typing if not directly exported from jetstream
@@ -21,6 +22,11 @@ export class PierRobot {
21
22
  public jsm: JetStreamManager | null = null;
22
23
  public activeNodeJobs: Map<string, any> = new Map();
23
24
  public jobSubscriptions: Map<string, any> = new Map();
25
+ public groupRingBuffer: Map<string, GroupMessagePayload[]> = new Map();
26
+ // Anti-replay nonce cache: stores "senderId:nonce" -> expiry timestamp (5 min TTL)
27
+ private nonceCache: Map<string, number> = new Map();
28
+ private nonceKv: any = null;
29
+ private discoverySubscriptions: any[] = [];
24
30
  public isBusy: boolean = false;
25
31
  public connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected';
26
32
  public stats = { received: 0, completed: 0, failed: 0 };
@@ -67,6 +73,30 @@ export class PierRobot {
67
73
  this.config.secretKey,
68
74
  this.config.capabilities
69
75
  );
76
+
77
+ // Phase 2: Decentralized KV Keep-Alive (PSP 1.0 — signed heartbeat)
78
+ try {
79
+ const kv = await this.client.nats.getWorkspaceKV('active_nodes');
80
+ let heartbeatPayload: any = {
81
+ version: 'psp-1.0',
82
+ id: `hb_${this.config.nodeId}_${Date.now()}`,
83
+ namespace: 'default',
84
+ timestamp: Date.now(),
85
+ senderId: this.accountId,
86
+ // --- node fields ---
87
+ nodeId: this.config.nodeId,
88
+ accountId: this.accountId,
89
+ capabilities: this.config.capabilities,
90
+ };
91
+ if (this.config.privateKey) {
92
+ heartbeatPayload.publicKey = new ethers.Wallet(this.config.privateKey).address;
93
+ heartbeatPayload = await PierCrypto.signSwarmMessage(heartbeatPayload, this.config.privateKey);
94
+ }
95
+ await kv.put(`node_${this.config.nodeId}`, new TextEncoder().encode(JSON.stringify(heartbeatPayload)));
96
+ } catch (kvErr: any) {
97
+ this.logger.debug(`[pier-connector][${this.accountId}] Decentralized KV Keep-Alive failed: ${kvErr.message}`);
98
+ }
99
+
70
100
  return data.nats_config || null;
71
101
  } catch (err: any) {
72
102
  this.logger.warn(`[pier-connector][${this.accountId}] Heartbeat failed: ${err.message}`);
@@ -111,6 +141,8 @@ export class PierRobot {
111
141
  for await (const msg of iter) {
112
142
  try {
113
143
  const rawMsg = new TextDecoder().decode(msg.data);
144
+ // S10: DoS protection
145
+ if (msg.data.length > 1024 * 1024) { msg.ack(); continue; }
114
146
  let msgPayload = JSON.parse(rawMsg);
115
147
 
116
148
  if (msgPayload.id && processedMessages.has(msgPayload.id)) { msg.ack(); continue; }
@@ -155,16 +187,224 @@ export class PierRobot {
155
187
  }
156
188
  }
157
189
 
190
+ private async checkAndStoreNonce(senderId: string, nonce: string): Promise<boolean> {
191
+ const nonceCacheKey = `${senderId}:${nonce}`;
192
+ const now = Date.now();
193
+
194
+ // 1. Fast local cache check
195
+ if (this.nonceCache.has(nonceCacheKey)) {
196
+ return false;
197
+ }
198
+
199
+ // 2. Persistent KV check (if available)
200
+ if (this.nonceKv) {
201
+ try {
202
+ const existing = await this.nonceKv.get(nonceCacheKey);
203
+ if (existing) {
204
+ this.nonceCache.set(nonceCacheKey, now + 5 * 60 * 1000);
205
+ return false;
206
+ }
207
+ // Store in KV with TTL (the bucket has TTL, but we put it here)
208
+ await this.nonceKv.put(nonceCacheKey, new TextEncoder().encode(now.toString()));
209
+ } catch (e: any) {
210
+ this.logger.debug(`[pier-connector] Nonce KV check failed (falling back to memory): ${e.message}`);
211
+ }
212
+ }
213
+
214
+ // S13: Heap limit protection - prevent nonce collection from growing infinitely
215
+ const MAX_CACHE_ENTRIES = 100000;
216
+ if (this.nonceCache.size >= MAX_CACHE_ENTRIES) {
217
+ // Aggressive cleanup: drop everything older than now if still at limit
218
+ for (const [k, exp] of this.nonceCache) {
219
+ if (exp < now) this.nonceCache.delete(k);
220
+ }
221
+ // If still over limit, reject new entry to protect memory
222
+ if (this.nonceCache.size >= MAX_CACHE_ENTRIES) {
223
+ this.logger.warn(`[pier-connector] Nonce cache full (${this.nonceCache.size}). Memory protection triggered.`);
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // 3. Update local cache
229
+ this.nonceCache.set(nonceCacheKey, now + 5 * 60 * 1000);
230
+
231
+ // Periodic local cleanup
232
+ if (this.nonceCache.size > 10000) {
233
+ for (const [k, exp] of this.nonceCache) {
234
+ if (exp < now) this.nonceCache.delete(k);
235
+ }
236
+ }
237
+
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * S7: Decrement budget with CAS retry logic.
243
+ * Ensures atomic decrement even under high concurrency.
244
+ */
245
+ private async decrementBudgetWithRetry(groupId: string): Promise<{ success: boolean; budgetLeft: number }> {
246
+ const MAX_RETRIES = 5;
247
+ let attempt = 0;
248
+
249
+ while (attempt < MAX_RETRIES) {
250
+ try {
251
+ const kv = await this.client.nats.getWorkspaceKV(groupId);
252
+ const entry = await kv.get('budget');
253
+ if (!entry) return { success: true, budgetLeft: Infinity }; // No budget set, unlimited
254
+
255
+ let currentBudget = parseInt(new TextDecoder().decode(entry.value), 10);
256
+ if (isNaN(currentBudget)) return { success: true, budgetLeft: Infinity };
257
+
258
+ if (currentBudget <= 0) return { success: false, budgetLeft: 0 };
259
+
260
+ const nextBudget = currentBudget - 1;
261
+ try {
262
+ await kv.update('budget', new TextEncoder().encode(nextBudget.toString()), entry.revision);
263
+ return { success: true, budgetLeft: nextBudget };
264
+ } catch (casErr: any) {
265
+ // CAS Failure: another node updated first. Retry.
266
+ attempt++;
267
+ this.logger.debug(`[pier-connector] Budget CAS conflict for ${groupId}, retry ${attempt}/${MAX_RETRIES}`);
268
+ continue;
269
+ }
270
+ } catch (err: any) {
271
+ this.logger.error(`[pier-connector] Budget KV error: ${err.message}`);
272
+ return { success: false, budgetLeft: -1 };
273
+ }
274
+ }
275
+ return { success: false, budgetLeft: -1 }; // Retries exhausted
276
+ }
277
+
278
+ async handleGroupChat(subject: string, payload: GroupMessagePayload) {
279
+ if (!payload || !payload.id || !payload.groupId) return;
280
+
281
+ // --- Zero-Trust Crypto Verification ---
282
+ if (payload.signature && payload.publicKey) {
283
+ const isValid = PierCrypto.verifySwarmMessage(payload as any);
284
+ if (!isValid) {
285
+ this.logger.warn(`[pier-connector][${this.accountId}] ⚠️ INVALID SIGNATURE from ${payload.senderId} (${payload.publicKey}). Message DROPPED.`);
286
+ return;
287
+ }
288
+ } else {
289
+ this.logger.warn(`[pier-connector][${this.accountId}] ⚠️ UNSIGNATURED MESSAGE from ${payload.senderId}. Accepting via Soft-Block fallback.`);
290
+ }
291
+
292
+ // Don't echo our own messages
293
+ if (payload.senderId === this.config.nodeId || payload.senderId === this.accountId) return;
294
+
295
+ // --- Anti-Replay: Nonce deduplication check (PSP 1.0) ---
296
+ if (payload.nonce) {
297
+ const isFresh = await this.checkAndStoreNonce(payload.senderId, payload.nonce);
298
+ if (!isFresh) {
299
+ this.logger.warn(`[pier-connector][${this.accountId}] ⛔ REPLAY ATTACK DETECTED: duplicate nonce from ${payload.senderId}. Dropped.`);
300
+ return;
301
+ }
302
+ }
303
+
304
+ const isMentioned = !!payload.mentions && (payload.mentions.includes(this.accountId) || payload.mentions.includes('all'));
305
+
306
+ const buffer = this.groupRingBuffer.get(payload.groupId) || [];
307
+ buffer.push(payload);
308
+ if (buffer.length > 10) buffer.shift();
309
+ this.groupRingBuffer.set(payload.groupId, buffer);
310
+
311
+ if (isMentioned) {
312
+ const backlog = buffer.slice(0, -1);
313
+ let injectedBody = payload.content;
314
+ if (backlog.length > 0) {
315
+ const backlogText = backlog.map(m => `[${new Date(m.timestamp).toLocaleTimeString()}] ${m.senderId}: ${m.content}`).join('\n');
316
+ injectedBody = `[System: You were just mentioned. Background context you overheard while silent:\n${backlogText}\nNow, please respond to the latest mention.]\n\n${payload.content}`;
317
+ }
318
+
319
+ // Setup FinOps Circuit Breaker Check (S7 Hardened)
320
+ try {
321
+ const { success, budgetLeft } = await this.decrementBudgetWithRetry(payload.groupId);
322
+
323
+ if (!success) {
324
+ this.logger.error(`[pier-connector] Budget exhausted or update conflict for workspace ${payload.groupId}. Task BLOCKED.`);
325
+ let sysPayload: any = {
326
+ version: 'psp-1.0',
327
+ id: `sys-${Date.now()}`,
328
+ namespace: payload.namespace || 'default',
329
+ groupId: payload.groupId,
330
+ senderId: this.accountId,
331
+ content: budgetLeft === 0 ? 'Critical Error: Workspace budget exhausted.' : 'Internal Error: Concurrent budget lock failure.',
332
+ mentions: [],
333
+ timestamp: Date.now()
334
+ };
335
+ if (this.config.privateKey) {
336
+ sysPayload = await PierCrypto.signSwarmMessage(sysPayload, this.config.privateKey);
337
+ }
338
+ await this.client.nats.publishGroupChat(payload.groupId, sysPayload, sysPayload.namespace);
339
+ return; // Hard Block / Fail-Close
340
+ }
341
+
342
+ if (budgetLeft <= 20 && budgetLeft !== Infinity) {
343
+ injectedBody = `[System WARNING: FinOps Budget is extremely low (${budgetLeft} left). Please summarize your thoughts and conclude your work immediately.]\n\n` + injectedBody;
344
+ }
345
+ } catch (err: any) {
346
+ this.logger.warn(`Failed to check budget for workspace ${payload.groupId}: ${err.message}`);
347
+ }
348
+
349
+ this.logger.info(`[pier-connector][${this.accountId}] 🔔 Mentioned in group ${payload.groupId} by ${payload.senderId}`);
350
+ this.isBusy = true;
351
+ await this.onInbound({
352
+ accountId: this.accountId,
353
+ senderId: `pierWorkspace:${payload.senderId}`,
354
+ body: injectedBody,
355
+ jobId: payload.groupId,
356
+ messageId: payload.id,
357
+ WasMentioned: true
358
+ }, payload.groupId);
359
+ this.isBusy = false;
360
+ } else {
361
+ // Silently observe and update context without triggering LLM
362
+ this.logger.debug(`[pier-connector][${this.accountId}] 🤫 Observing group ${payload.groupId} (not mentioned, cached in RingBuffer)`);
363
+ }
364
+ }
365
+
158
366
  async handleMessage(msg: any) {
159
367
  const rawData = new TextDecoder().decode(msg.data);
160
368
  let payload: any;
161
369
  try {
370
+ // S10: DoS protection
371
+ if (msg.data.length > 1024 * 1024) { msg.ack(); return; }
162
372
  payload = JSON.parse(rawData);
163
373
  } catch (e) {
164
374
  msg.ack();
165
375
  return;
166
376
  }
167
377
 
378
+ // --- Zero-Trust Hard-Block for Task Payloads (PSP 1.0) ---
379
+ if (payload.version === 'psp-1.0' && (payload.type === 'task' || payload.type === 'task_request')) {
380
+ if (payload.signature && payload.publicKey) {
381
+ const isValid = PierCrypto.verifySwarmMessage(payload);
382
+ if (!isValid) {
383
+ this.logger.warn(`[pier-connector][${this.accountId}] 🔴 HARD BLOCK: Invalid signature on task from ${payload.senderId}. Dropping.`);
384
+ msg.ack(); // ACK to remove from queue but do NOT process
385
+ return;
386
+ }
387
+ } else if (payload.signature !== undefined || payload.publicKey !== undefined) {
388
+ // Has one but not the other — tampered or malformed
389
+ this.logger.warn(`[pier-connector][${this.accountId}] 🔴 HARD BLOCK: Malformed Zero-Trust fields from ${payload.senderId}. Dropping.`);
390
+ msg.ack();
391
+ return;
392
+ } else {
393
+ // No signature — legacy / unsigned node. Soft-block: log and allow through.
394
+ this.logger.warn(`[pier-connector][${this.accountId}] ⚠️ Unsigned PSP task from ${payload.senderId}. Accepting via Soft-Block (legacy).`);
395
+ }
396
+ }
397
+
398
+ // --- Anti-Replay: Nonce deduplication for task payloads (PSP 1.0) ---
399
+ if (payload.nonce && payload.senderId) {
400
+ const isFresh = await this.checkAndStoreNonce(payload.senderId, payload.nonce);
401
+ if (!isFresh) {
402
+ this.logger.warn(`[pier-connector][${this.accountId}] ⛔ REPLAY ATTACK DETECTED: duplicate task nonce from ${payload.senderId}. Dropped.`);
403
+ msg.ack();
404
+ return;
405
+ }
406
+ }
407
+
168
408
  if (payload.type === 'wakeup') {
169
409
  const { jobId } = payload;
170
410
  if (jobId) {
@@ -296,6 +536,13 @@ export class PierRobot {
296
536
  this.jsm = await jetstreamManager(this.nc);
297
537
  this.connectionStatus = 'connected';
298
538
 
539
+ // Initialize Nonce Cache KV (S1 fix)
540
+ try {
541
+ this.nonceKv = await this.client.nats.getNonceCacheKV();
542
+ } catch (e: any) {
543
+ this.logger.warn(`[pier-connector] Failed to initialize Nonce KV: ${e.message}. Using memory only.`);
544
+ }
545
+
299
546
  const streamName = 'PIER_JOBS';
300
547
  const durableNameMarket = `pier_market_${this.config.nodeId.replace(/-/g, '_')}_${this.accountId}`;
301
548
  const durableNameDirect = `pier_node_${this.config.nodeId.replace(/-/g, '_')}_${this.accountId}`;
@@ -303,6 +550,27 @@ export class PierRobot {
303
550
  await this.setupMarketplaceConsumer(streamName, this.config.subject, durableNameMarket);
304
551
  await this.setupMarketplaceConsumer(streamName, `jobs.node.${this.config.nodeId}`, durableNameDirect);
305
552
 
553
+ // Subscribe to all multi-agent workspaces via NATS core
554
+ // Support PSP Federation namespace wildcards
555
+ this.client.nats.subscribe('psp.chat.>', (payload: any, msg: any) => {
556
+ this.handleGroupChat(msg.subject, payload);
557
+ });
558
+
559
+ // Register capability discovery responders (Subject-based Discovery, PSP 1.0)
560
+ if (this.config.capabilities && this.config.capabilities.length > 0) {
561
+ const discoveryInfo = {
562
+ nodeId: this.config.nodeId,
563
+ accountId: this.accountId,
564
+ capabilities: this.config.capabilities,
565
+ timestamp: Date.now(),
566
+ };
567
+ for (const cap of this.config.capabilities) {
568
+ const sub = await this.client.nats.subscribeDiscovery(cap, discoveryInfo, this.config.privateKey);
569
+ this.discoverySubscriptions.push(sub);
570
+ }
571
+ this.logger.info(`[pier-connector][${this.accountId}] 📟 Registered for discovery on ${this.config.capabilities.length} capabilities`);
572
+ }
573
+
306
574
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
307
575
  this.heartbeatTimer = setInterval(() => this.heartbeat(), 60000);
308
576
  } catch (err: any) {
@@ -317,6 +585,13 @@ export class PierRobot {
317
585
  clearInterval(this.heartbeatTimer);
318
586
  this.heartbeatTimer = null;
319
587
  }
588
+
589
+ // Cleanup discovery subscriptions
590
+ for (const sub of this.discoverySubscriptions) {
591
+ try { sub.unsubscribe(); } catch (e) {}
592
+ }
593
+ this.discoverySubscriptions = [];
594
+
320
595
  if (this.nc) {
321
596
  await this.client.drainNats();
322
597
  this.nc = null;
package/src/types.ts CHANGED
@@ -32,8 +32,10 @@ export interface InboundMessage {
32
32
  senderId: string;
33
33
  body: string;
34
34
  jobId: string;
35
- /** 每条 NATS 消息的唯一 ID(msgPayload.id),用于 SDK 去重,必须每条消息不同 */
36
- messageId: string;
35
+ /** UUID for unique SDK deduplication across turns */
36
+ messageId?: string;
37
+ /** True if the agent was explicitly mentioned in the group chat payload */
38
+ WasMentioned?: boolean;
37
39
  }
38
40
 
39
41
  export type PierPluginApi = OpenClawPluginApi;