@omnixal/openclaw-nats-plugin 0.2.9 → 0.2.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/PLUGIN.md +5 -1
- package/dashboard/src/lib/EventsReferenceModal.svelte +59 -0
- package/dashboard/src/lib/MetricsPanel.svelte +12 -1
- package/dashboard/src/lib/components/ui/modal/modal.svelte +3 -1
- package/package.json +1 -1
- package/plugins/nats-context-engine/index.ts +46 -0
- package/sidecar/src/config.ts +1 -1
- package/sidecar/src/consumer/consumer.controller.ts +2 -7
- package/sidecar/src/gateway/device-identity.ts +146 -0
- package/sidecar/src/gateway/gateway-client.service.ts +67 -34
- package/sidecar/src/logs/log.repository.ts +1 -1
- package/sidecar/src/logs/log.service.ts +1 -1
- package/sidecar/src/pending/pending.repository.ts +1 -1
- package/sidecar/src/router/router.repository.ts +1 -1
package/PLUGIN.md
CHANGED
|
@@ -53,6 +53,7 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
|
|
|
53
53
|
| Subject | Trigger |
|
|
54
54
|
|---|---|
|
|
55
55
|
| `agent.events.gateway.startup` | Gateway starts |
|
|
56
|
+
| `agent.events.gateway.stopped` | Gateway stops |
|
|
56
57
|
| `agent.events.session.new` | `/new` command |
|
|
57
58
|
| `agent.events.session.reset` | `/reset` command |
|
|
58
59
|
| `agent.events.session.stop` | `/stop` command |
|
|
@@ -60,10 +61,13 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
|
|
|
60
61
|
| `agent.events.session.ended` | Session ends |
|
|
61
62
|
| `agent.events.tool.{name}.completed` | Tool succeeds |
|
|
62
63
|
| `agent.events.tool.{name}.failed` | Tool fails |
|
|
64
|
+
| `agent.events.message.received` | Inbound message received |
|
|
65
|
+
| `agent.events.message.sent` | Message delivered |
|
|
66
|
+
| `agent.events.llm.output` | LLM response received |
|
|
67
|
+
| `agent.events.subagent.spawning` | Subagent about to spawn |
|
|
63
68
|
| `agent.events.subagent.spawned` | Subagent created |
|
|
64
69
|
| `agent.events.subagent.ended` | Subagent finished |
|
|
65
70
|
| `agent.events.agent.run_ended` | Agent run completes |
|
|
66
|
-
| `agent.events.message.sent` | Message delivered |
|
|
67
71
|
| `agent.events.context.compacted` | Context history compressed |
|
|
68
72
|
|
|
69
73
|
## Dashboard
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Modal } from '$lib/components/ui/modal';
|
|
3
|
+
import * as Table from '$lib/components/ui/table';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { open, onClose }: Props = $props();
|
|
11
|
+
|
|
12
|
+
const events = [
|
|
13
|
+
{ subject: 'agent.events.gateway.startup', trigger: 'Gateway started', payload: '{}' },
|
|
14
|
+
{ subject: 'agent.events.gateway.stopped', trigger: 'Gateway stopped', payload: '{ reason }' },
|
|
15
|
+
{ subject: 'agent.events.session.new', trigger: '/new command', payload: '{ sessionKey, command }' },
|
|
16
|
+
{ subject: 'agent.events.session.reset', trigger: '/reset command', payload: '{ sessionKey, command }' },
|
|
17
|
+
{ subject: 'agent.events.session.stop', trigger: '/stop command', payload: '{ sessionKey, command }' },
|
|
18
|
+
{ subject: 'agent.events.session.started', trigger: 'Session began', payload: '{ sessionKey, sessionId, channel }' },
|
|
19
|
+
{ subject: 'agent.events.session.ended', trigger: 'Session ended', payload: '{ sessionKey, sessionId, channel }' },
|
|
20
|
+
{ subject: 'agent.events.agent.run_ended', trigger: 'Agent run completed', payload: '{ sessionKey, runId, messageCount }' },
|
|
21
|
+
{ subject: 'agent.events.tool.{name}.completed', trigger: 'Tool succeeded', payload: '{ sessionKey, toolName, durationMs }' },
|
|
22
|
+
{ subject: 'agent.events.tool.{name}.failed', trigger: 'Tool failed', payload: '{ sessionKey, toolName, durationMs }' },
|
|
23
|
+
{ subject: 'agent.events.message.received', trigger: 'Inbound message', payload: '{ from, content, metadata }' },
|
|
24
|
+
{ subject: 'agent.events.message.sent', trigger: 'Message delivered', payload: '{ sessionKey, to, success, error }' },
|
|
25
|
+
{ subject: 'agent.events.llm.output', trigger: 'LLM response', payload: '{ provider, model, usage }' },
|
|
26
|
+
{ subject: 'agent.events.subagent.spawning', trigger: 'Subagent creating', payload: '{ childSessionKey, agentId, label, mode }' },
|
|
27
|
+
{ subject: 'agent.events.subagent.spawned', trigger: 'Subagent created', payload: '{ sessionKey, subagentId, task }' },
|
|
28
|
+
{ subject: 'agent.events.subagent.ended', trigger: 'Subagent finished', payload: '{ sessionKey, subagentId, result, durationMs }' },
|
|
29
|
+
{ subject: 'agent.events.context.compacted', trigger: 'Context compressed', payload: '{ sessionKey }' },
|
|
30
|
+
{ subject: 'agent.events.cron.*', trigger: 'Cron trigger', payload: '(user-defined)' },
|
|
31
|
+
{ subject: 'agent.events.custom.*', trigger: 'Custom event', payload: '(user-defined)' },
|
|
32
|
+
];
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<Modal {open} title="Events Reference" {onClose} class="max-w-3xl">
|
|
36
|
+
<div class="max-h-[60vh] overflow-y-auto -mx-6 px-6">
|
|
37
|
+
<p class="text-xs text-muted-foreground mb-3">
|
|
38
|
+
All events published by the NATS plugin. Each event also includes a <code class="text-xs">timestamp</code> field.
|
|
39
|
+
</p>
|
|
40
|
+
<Table.Root>
|
|
41
|
+
<Table.Header>
|
|
42
|
+
<Table.Row>
|
|
43
|
+
<Table.Head>Subject</Table.Head>
|
|
44
|
+
<Table.Head>Trigger</Table.Head>
|
|
45
|
+
<Table.Head>Payload</Table.Head>
|
|
46
|
+
</Table.Row>
|
|
47
|
+
</Table.Header>
|
|
48
|
+
<Table.Body>
|
|
49
|
+
{#each events as ev}
|
|
50
|
+
<Table.Row>
|
|
51
|
+
<Table.Cell class="font-mono text-xs whitespace-nowrap">{ev.subject}</Table.Cell>
|
|
52
|
+
<Table.Cell class="text-xs">{ev.trigger}</Table.Cell>
|
|
53
|
+
<Table.Cell class="font-mono text-xs text-muted-foreground">{ev.payload}</Table.Cell>
|
|
54
|
+
</Table.Row>
|
|
55
|
+
{/each}
|
|
56
|
+
</Table.Body>
|
|
57
|
+
</Table.Root>
|
|
58
|
+
</div>
|
|
59
|
+
</Modal>
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import * as Table from '$lib/components/ui/table';
|
|
3
3
|
import { Badge } from '$lib/components/ui/badge';
|
|
4
|
+
import { Button } from '$lib/components/ui/button';
|
|
4
5
|
import * as Card from '$lib/components/ui/card';
|
|
5
6
|
import type { SubjectMetric } from '$lib/api';
|
|
6
7
|
import { relativeAge } from '$lib/utils';
|
|
8
|
+
import CircleHelp from '@lucide/svelte/icons/circle-help';
|
|
9
|
+
import EventsReferenceModal from './EventsReferenceModal.svelte';
|
|
7
10
|
|
|
8
11
|
interface Props {
|
|
9
12
|
metrics: SubjectMetric[];
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
let { metrics }: Props = $props();
|
|
16
|
+
let showEventsRef = $state(false);
|
|
13
17
|
|
|
14
18
|
let totalPublished = $derived(metrics.reduce((acc, m) => acc + m.published, 0));
|
|
15
19
|
let totalConsumed = $derived(metrics.reduce((acc, m) => acc + m.consumed, 0));
|
|
@@ -17,7 +21,12 @@
|
|
|
17
21
|
|
|
18
22
|
<Card.Root>
|
|
19
23
|
<Card.Header class="pb-2">
|
|
20
|
-
<
|
|
24
|
+
<div class="flex items-center justify-between">
|
|
25
|
+
<Card.Title class="text-sm font-medium">Queue Metrics</Card.Title>
|
|
26
|
+
<Button variant="ghost" size="icon-sm" onclick={() => showEventsRef = true} title="Events reference">
|
|
27
|
+
<CircleHelp size={14} />
|
|
28
|
+
</Button>
|
|
29
|
+
</div>
|
|
21
30
|
</Card.Header>
|
|
22
31
|
<Card.Content>
|
|
23
32
|
<div class="flex items-center gap-2 mb-3">
|
|
@@ -58,3 +67,5 @@
|
|
|
58
67
|
{/if}
|
|
59
68
|
</Card.Content>
|
|
60
69
|
</Card.Root>
|
|
70
|
+
|
|
71
|
+
<EventsReferenceModal open={showEventsRef} onClose={() => showEventsRef = false} />
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
onClose: () => void;
|
|
9
9
|
children: Snippet;
|
|
10
10
|
actions?: Snippet;
|
|
11
|
+
class?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
let {
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
onClose,
|
|
17
18
|
children,
|
|
18
19
|
actions,
|
|
20
|
+
class: className,
|
|
19
21
|
}: Props = $props();
|
|
20
22
|
|
|
21
23
|
function handleKeydown(e: KeyboardEvent) {
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
>
|
|
32
34
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
33
35
|
<div class="fixed inset-0" onclick={onClose}></div>
|
|
34
|
-
<div class="relative z-10 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
|
|
36
|
+
<div class="relative z-10 w-full {className ?? 'max-w-lg'} rounded-lg border bg-background p-6 shadow-lg">
|
|
35
37
|
<div class="flex items-center justify-between mb-4">
|
|
36
38
|
<h3 class="text-lg font-semibold">{title}</h3>
|
|
37
39
|
<Button variant="ghost" size="icon-sm" onclick={onClose}>
|
package/package.json
CHANGED
|
@@ -78,6 +78,52 @@ export default function (api: any) {
|
|
|
78
78
|
}, { priority: 8 });
|
|
79
79
|
}, { priority: 99 });
|
|
80
80
|
|
|
81
|
+
// ── Gateway stop ───────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
api.on('gateway_stop', (event: any) => {
|
|
84
|
+
void publishToSidecar('agent.events.gateway.stopped', {
|
|
85
|
+
reason: event.reason,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
}, { priority: 99 });
|
|
89
|
+
|
|
90
|
+
// ── Message received ─────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
api.on('message_received', (event: any) => {
|
|
93
|
+
void publishToSidecar('agent.events.message.received', {
|
|
94
|
+
from: event.from,
|
|
95
|
+
content: event.content,
|
|
96
|
+
metadata: event.metadata,
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
});
|
|
99
|
+
}, { priority: 99 });
|
|
100
|
+
|
|
101
|
+
// ── LLM output ───────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
api.on('llm_output', (event: any) => {
|
|
104
|
+
void publishToSidecar('agent.events.llm.output', {
|
|
105
|
+
sessionKey: event.sessionId,
|
|
106
|
+
runId: event.runId,
|
|
107
|
+
provider: event.provider,
|
|
108
|
+
model: event.model,
|
|
109
|
+
usage: event.usage,
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
});
|
|
112
|
+
}, { priority: 99 });
|
|
113
|
+
|
|
114
|
+
// ── Subagent spawning ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
api.on('subagent_spawning', (event: any) => {
|
|
117
|
+
void publishToSidecar('agent.events.subagent.spawning', {
|
|
118
|
+
childSessionKey: event.childSessionKey,
|
|
119
|
+
agentId: event.agentId,
|
|
120
|
+
label: event.label,
|
|
121
|
+
mode: event.mode,
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
return { status: 'ok' };
|
|
125
|
+
}, { priority: 99 });
|
|
126
|
+
|
|
81
127
|
// ── Agent Tools ─────────────────────────────────────────────────
|
|
82
128
|
|
|
83
129
|
const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
|
package/sidecar/src/config.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const envSchema = {
|
|
|
17
17
|
},
|
|
18
18
|
gateway: {
|
|
19
19
|
wsUrl: Env.string({ default: 'ws://localhost:18789', env: 'OPENCLAW_WS_URL' }),
|
|
20
|
-
token: Env.string({ default: '', env: '
|
|
20
|
+
token: Env.string({ default: '', env: 'OPENCLAW_DEVICE_TOKEN' }),
|
|
21
21
|
},
|
|
22
22
|
consumer: {
|
|
23
23
|
name: Env.string({ default: 'openclaw-main', env: 'NATS_CONSUMER_NAME' }),
|
|
@@ -58,14 +58,9 @@ export class ConsumerController extends BaseController {
|
|
|
58
58
|
try {
|
|
59
59
|
const injectStart = performance.now();
|
|
60
60
|
await this.gatewayClient.inject({
|
|
61
|
-
|
|
61
|
+
to: route.target,
|
|
62
62
|
message: this.formatMessage(envelope),
|
|
63
|
-
|
|
64
|
-
source: 'nats',
|
|
65
|
-
eventId: envelope.id,
|
|
66
|
-
subject: envelope.subject,
|
|
67
|
-
priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
|
|
68
|
-
},
|
|
63
|
+
eventId: envelope.id,
|
|
69
64
|
});
|
|
70
65
|
const lagMs = Math.round(performance.now() - injectStart);
|
|
71
66
|
await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface DeviceIdentity {
|
|
6
|
+
deviceId: string;
|
|
7
|
+
publicKeyPem: string;
|
|
8
|
+
privateKeyPem: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StoredIdentity {
|
|
12
|
+
version: 1;
|
|
13
|
+
deviceId: string;
|
|
14
|
+
publicKeyPem: string;
|
|
15
|
+
privateKeyPem: string;
|
|
16
|
+
createdAtMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SignChallengeParams {
|
|
20
|
+
deviceId: string;
|
|
21
|
+
clientId: string;
|
|
22
|
+
clientMode: string;
|
|
23
|
+
role: string;
|
|
24
|
+
scopes: string[];
|
|
25
|
+
signedAtMs: number;
|
|
26
|
+
token: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
platform?: string;
|
|
29
|
+
deviceFamily?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ed25519 SPKI DER prefix (12 bytes) before the 32-byte raw public key */
|
|
33
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
34
|
+
|
|
35
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
36
|
+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Extract raw 32-byte Ed25519 public key from PEM-encoded SPKI */
|
|
40
|
+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
41
|
+
const key = crypto.createPublicKey(publicKeyPem);
|
|
42
|
+
const spki = key.export({ type: 'spki', format: 'der' }) as Buffer;
|
|
43
|
+
if (
|
|
44
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
45
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
46
|
+
) {
|
|
47
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
48
|
+
}
|
|
49
|
+
return spki;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Derive deviceId as SHA-256(rawPublicKey).hex — matches OpenClaw's deriveDeviceIdFromPublicKey */
|
|
53
|
+
function fingerprintPublicKey(publicKeyPem: string): string {
|
|
54
|
+
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
55
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Normalize metadata for auth payload — ASCII lowercase + trim (matches OpenClaw's normalizeDeviceMetadataForAuth) */
|
|
59
|
+
function normalizeMetadata(value?: string | null): string {
|
|
60
|
+
if (typeof value !== 'string') return '';
|
|
61
|
+
const trimmed = value.trim();
|
|
62
|
+
if (!trimmed) return '';
|
|
63
|
+
return trimmed.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Generate a new Ed25519 keypair and derive device identity */
|
|
67
|
+
export function generateIdentity(): DeviceIdentity {
|
|
68
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
69
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
70
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
71
|
+
const deviceId = fingerprintPublicKey(publicKeyPem);
|
|
72
|
+
return { deviceId, publicKeyPem, privateKeyPem };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load existing device identity from file, or generate and persist a new one */
|
|
76
|
+
export function loadOrCreateIdentity(filePath: string): DeviceIdentity {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(filePath)) {
|
|
79
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
80
|
+
const parsed = JSON.parse(raw) as StoredIdentity;
|
|
81
|
+
if (
|
|
82
|
+
parsed?.version === 1 &&
|
|
83
|
+
typeof parsed.deviceId === 'string' &&
|
|
84
|
+
typeof parsed.publicKeyPem === 'string' &&
|
|
85
|
+
typeof parsed.privateKeyPem === 'string'
|
|
86
|
+
) {
|
|
87
|
+
// Re-derive deviceId to handle corrupted stored IDs
|
|
88
|
+
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
|
89
|
+
return {
|
|
90
|
+
deviceId: derivedId,
|
|
91
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
92
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// fall through to regenerate
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const identity = generateIdentity();
|
|
101
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
102
|
+
const stored: StoredIdentity = {
|
|
103
|
+
version: 1,
|
|
104
|
+
deviceId: identity.deviceId,
|
|
105
|
+
publicKeyPem: identity.publicKeyPem,
|
|
106
|
+
privateKeyPem: identity.privateKeyPem,
|
|
107
|
+
createdAtMs: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
|
110
|
+
return identity;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get base64url-encoded raw public key (for the connect frame device.publicKey field) */
|
|
114
|
+
export function publicKeyToBase64Url(publicKeyPem: string): string {
|
|
115
|
+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build v3 signature payload and sign it with Ed25519 private key.
|
|
120
|
+
* Returns base64url-encoded signature.
|
|
121
|
+
*
|
|
122
|
+
* v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
|
123
|
+
*/
|
|
124
|
+
export function signChallenge(privateKeyPem: string, params: SignChallengeParams): string {
|
|
125
|
+
const scopes = params.scopes.join(',');
|
|
126
|
+
const token = params.token ?? '';
|
|
127
|
+
const platform = normalizeMetadata(params.platform);
|
|
128
|
+
const deviceFamily = normalizeMetadata(params.deviceFamily);
|
|
129
|
+
const payload = [
|
|
130
|
+
'v3',
|
|
131
|
+
params.deviceId,
|
|
132
|
+
params.clientId,
|
|
133
|
+
params.clientMode,
|
|
134
|
+
params.role,
|
|
135
|
+
scopes,
|
|
136
|
+
String(params.signedAtMs),
|
|
137
|
+
token,
|
|
138
|
+
params.nonce,
|
|
139
|
+
platform,
|
|
140
|
+
deviceFamily,
|
|
141
|
+
].join('|');
|
|
142
|
+
|
|
143
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
144
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
|
|
145
|
+
return base64UrlEncode(sig);
|
|
146
|
+
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
|
|
2
4
|
|
|
3
5
|
export interface GatewayInjectPayload {
|
|
4
|
-
|
|
6
|
+
/** OpenClaw session key or recipient address (maps to `to` in the send frame) */
|
|
7
|
+
to: string;
|
|
5
8
|
message: string;
|
|
6
|
-
metadata
|
|
7
|
-
|
|
8
|
-
eventId: string;
|
|
9
|
-
subject: string;
|
|
10
|
-
priority: number;
|
|
11
|
-
};
|
|
9
|
+
/** Internal tracking metadata — NOT sent to gateway (additionalProperties: false) */
|
|
10
|
+
eventId?: string;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
export class GatewayRpcError extends Error {
|
|
@@ -40,12 +39,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
40
39
|
private requestId = 0;
|
|
41
40
|
private wsUrl!: string;
|
|
42
41
|
private token!: string;
|
|
42
|
+
private identity!: DeviceIdentity;
|
|
43
|
+
private publicKeyBase64Url!: string;
|
|
44
|
+
private challengeNonce = '';
|
|
43
45
|
private pendingRequests = new Map<string, PendingRequest>();
|
|
44
46
|
|
|
45
47
|
async onModuleInit(): Promise<void> {
|
|
46
48
|
this.wsUrl = this.config.get('gateway.wsUrl');
|
|
47
49
|
this.token = this.config.get('gateway.token');
|
|
48
50
|
if (this.wsUrl && this.token) {
|
|
51
|
+
const dbPath = this.config.get('database.url');
|
|
52
|
+
const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
|
|
53
|
+
this.identity = loadOrCreateIdentity(identityPath);
|
|
54
|
+
this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
|
|
55
|
+
this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
|
|
49
56
|
this.connect();
|
|
50
57
|
} else {
|
|
51
58
|
this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
|
|
@@ -99,7 +106,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
99
106
|
|
|
100
107
|
// Server challenge — respond with connect frame
|
|
101
108
|
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
|
102
|
-
this.
|
|
109
|
+
this.challengeNonce = frame.payload?.nonce ?? '';
|
|
110
|
+
this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
|
|
103
111
|
this.sendConnectFrame();
|
|
104
112
|
return;
|
|
105
113
|
}
|
|
@@ -162,33 +170,59 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
162
170
|
private sendConnectFrame(): void {
|
|
163
171
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
|
|
164
172
|
this.connectSent = true;
|
|
165
|
-
|
|
173
|
+
|
|
174
|
+
const signedAt = Date.now();
|
|
175
|
+
const scopes = ['operator.read', 'operator.write'];
|
|
176
|
+
const client = {
|
|
177
|
+
id: 'gateway-client' as const,
|
|
178
|
+
displayName: 'nats-sidecar',
|
|
179
|
+
version: '1.0.0',
|
|
180
|
+
platform: 'linux',
|
|
181
|
+
mode: 'backend' as const,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const signature = signChallenge(this.identity.privateKeyPem, {
|
|
185
|
+
deviceId: this.identity.deviceId,
|
|
186
|
+
clientId: client.id,
|
|
187
|
+
clientMode: client.mode,
|
|
188
|
+
role: 'operator',
|
|
189
|
+
scopes,
|
|
190
|
+
signedAtMs: signedAt,
|
|
191
|
+
token: this.token,
|
|
192
|
+
nonce: this.challengeNonce,
|
|
193
|
+
platform: client.platform,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
this.logger.info('Sending connect frame with device identity', {
|
|
197
|
+
deviceId: this.identity.deviceId,
|
|
198
|
+
});
|
|
166
199
|
|
|
167
200
|
try {
|
|
168
201
|
this.send({
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
202
|
+
type: 'req',
|
|
203
|
+
id: `connect-${++this.requestId}`,
|
|
204
|
+
method: 'connect',
|
|
205
|
+
params: {
|
|
206
|
+
minProtocol: 3,
|
|
207
|
+
maxProtocol: 3,
|
|
208
|
+
client,
|
|
209
|
+
role: 'operator',
|
|
210
|
+
scopes,
|
|
211
|
+
caps: [],
|
|
212
|
+
commands: [],
|
|
213
|
+
permissions: {},
|
|
214
|
+
auth: { token: this.token },
|
|
215
|
+
device: {
|
|
216
|
+
id: this.identity.deviceId,
|
|
217
|
+
publicKey: this.publicKeyBase64Url,
|
|
218
|
+
signature,
|
|
219
|
+
signedAt,
|
|
220
|
+
nonce: this.challengeNonce,
|
|
221
|
+
},
|
|
222
|
+
locale: 'en-US',
|
|
223
|
+
userAgent: 'nats-sidecar/1.0.0',
|
|
181
224
|
},
|
|
182
|
-
|
|
183
|
-
scopes: ['operator.read', 'operator.write'],
|
|
184
|
-
caps: [],
|
|
185
|
-
commands: [],
|
|
186
|
-
permissions: {},
|
|
187
|
-
auth: { token: this.token },
|
|
188
|
-
locale: 'en-US',
|
|
189
|
-
userAgent: 'nats-sidecar/1.0.0',
|
|
190
|
-
},
|
|
191
|
-
});
|
|
225
|
+
});
|
|
192
226
|
} catch (err) {
|
|
193
227
|
this.logger.error('Failed to send connect frame', err);
|
|
194
228
|
this.connectSent = false;
|
|
@@ -229,10 +263,9 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
229
263
|
id,
|
|
230
264
|
method: 'send',
|
|
231
265
|
params: {
|
|
232
|
-
|
|
266
|
+
to: payload.to,
|
|
233
267
|
message: payload.message,
|
|
234
|
-
|
|
235
|
-
idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
|
|
268
|
+
idempotencyKey: payload.eventId ?? String(this.requestId),
|
|
236
269
|
},
|
|
237
270
|
});
|
|
238
271
|
return promise;
|
|
@@ -56,7 +56,7 @@ export class LogRepository extends BaseService {
|
|
|
56
56
|
.select({ count: sql<number>`count(*)` })
|
|
57
57
|
.from(executionLogs)
|
|
58
58
|
.where(this.buildWhereConditions(filters));
|
|
59
|
-
return result[0]?.count ?? 0;
|
|
59
|
+
return (result[0] as unknown as { count: number })?.count ?? 0;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
async findRecent(limit: number = 20): Promise<DbExecutionLog[]> {
|
|
@@ -58,7 +58,7 @@ export class LogService extends BaseService {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async logError(entityType: 'route' | 'cron', entityId: string, subject: string, error: unknown): Promise<void> {
|
|
61
|
+
async logError(entityType: 'route' | 'cron' | 'timer', entityId: string, subject: string, error: unknown): Promise<void> {
|
|
62
62
|
try {
|
|
63
63
|
const detail = error instanceof Error
|
|
64
64
|
? JSON.stringify({ message: error.message, stack: error.stack })
|
|
@@ -49,7 +49,7 @@ export class PendingRepository extends BaseService {
|
|
|
49
49
|
.select({ count: sql<number>`count(*)` })
|
|
50
50
|
.from(pendingEvents)
|
|
51
51
|
.where(isNull(pendingEvents.deliveredAt));
|
|
52
|
-
return result[0]?.count ?? 0;
|
|
52
|
+
return (result[0] as unknown as { count: number })?.count ?? 0;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
async cleanup(ttlSeconds: number): Promise<number> {
|