@gholl-studio/pier-connector 0.3.26 → 0.6.1
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/package.json +2 -2
- package/src/inbound.ts +1 -1
- package/src/index.ts +251 -1
- package/src/job-handler.ts +6 -0
- package/src/robot.ts +278 -3
- package/src/types.ts +4 -2
- package/src/types/pier-sdk.d.ts +0 -25
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gholl-studio/pier-connector",
|
|
3
3
|
"author": "gholl",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.1",
|
|
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": "
|
|
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';
|
|
@@ -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',
|
package/src/job-handler.ts
CHANGED
|
@@ -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) {
|
|
@@ -214,7 +454,7 @@ export class PierRobot {
|
|
|
214
454
|
return;
|
|
215
455
|
}
|
|
216
456
|
|
|
217
|
-
const
|
|
457
|
+
const job = parsed.job!;
|
|
218
458
|
const senderCore = job.meta?.sender ?? 'anonymous';
|
|
219
459
|
|
|
220
460
|
this.activeNodeJobs.set(job.id, {
|
|
@@ -225,7 +465,7 @@ export class PierRobot {
|
|
|
225
465
|
isTargeted: isTargeted
|
|
226
466
|
});
|
|
227
467
|
|
|
228
|
-
let finalText = job.task;
|
|
468
|
+
let finalText = job.task || '';
|
|
229
469
|
if (!isTargeted) {
|
|
230
470
|
finalText = `【PIER MARKETPLACE OPEN JOB】\nJob ID: ${job.id}\nTask: ${job.task}\n\n=== CRITICAL ===\nYou MUST use \`pier_bid_task\` to bid. Do not solve directly.`;
|
|
231
471
|
} else {
|
|
@@ -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
|
-
/**
|
|
36
|
-
messageId
|
|
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;
|
package/src/types/pier-sdk.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file pier-sdk.d.ts
|
|
3
|
-
* @description TypeScript declaration file for the @gholl-studio/pier-sdk to support plugin compilation.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
declare module '@gholl-studio/pier-sdk' {
|
|
7
|
-
export class PierClient {
|
|
8
|
-
constructor(config: { apiUrl: string; natsUrl?: string; logger?: any });
|
|
9
|
-
connectNats(): Promise<any>;
|
|
10
|
-
getNatsConnection(): Promise<any>;
|
|
11
|
-
heartbeat(nodeId: string, secretKey: string, capabilities: string[]): Promise<any>;
|
|
12
|
-
claimJob(jobId: string, nodeId: string, secretKey: string): Promise<any>;
|
|
13
|
-
autoRegister(privateKey: string, hostName: string): Promise<{ nodeId: string; secretKey: string; walletAddress: string }>;
|
|
14
|
-
getUserProfile(secretKey: string): Promise<any>;
|
|
15
|
-
drainNats(): Promise<void>;
|
|
16
|
-
nats: { url: string };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const protocol: {
|
|
20
|
-
normalizeInboundPayload(payload: any): { ok: boolean; job?: any; error?: string };
|
|
21
|
-
createRequestPayload(data: any): any;
|
|
22
|
-
createResultPayload(data: any): any;
|
|
23
|
-
createErrorPayload(data: any): any;
|
|
24
|
-
};
|
|
25
|
-
}
|