@alfe.ai/openclaw-identity 0.1.0 → 0.1.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.
- package/dist/plugin2.cjs +150 -95
- package/dist/plugin2.js +150 -95
- package/package.json +3 -3
package/dist/plugin2.cjs
CHANGED
|
@@ -132,6 +132,7 @@ function evaluateAgentChat(permissions, args) {
|
|
|
132
132
|
const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
|
|
133
133
|
const RESOLVE_CACHE_TTL_MS = 6e4;
|
|
134
134
|
const resolveCache = /* @__PURE__ */ new Map();
|
|
135
|
+
let lastInboundConversationId;
|
|
135
136
|
function getCachedResolve(key) {
|
|
136
137
|
const entry = resolveCache.get(key);
|
|
137
138
|
if (!entry) return null;
|
|
@@ -147,74 +148,74 @@ function setCachedResolve(key, value) {
|
|
|
147
148
|
expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
|
|
148
149
|
});
|
|
149
150
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
function invalidateCachedResolve(key) {
|
|
152
|
+
resolveCache.delete(key);
|
|
153
|
+
}
|
|
154
|
+
function findMostRelevantCacheEntry(probeKeys) {
|
|
155
|
+
for (const k of probeKeys) {
|
|
156
|
+
if (!k) continue;
|
|
157
|
+
const hit = getCachedResolve(k);
|
|
158
|
+
if (hit) return hit;
|
|
159
|
+
}
|
|
160
|
+
let mostRecent = null;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
for (const entry of resolveCache.values()) {
|
|
163
|
+
if (entry.expiresAt < now) continue;
|
|
164
|
+
if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
|
|
165
|
+
}
|
|
166
|
+
return mostRecent;
|
|
167
|
+
}
|
|
168
|
+
const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
|
|
169
|
+
function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
|
|
170
|
+
if (!cacheKey) return false;
|
|
171
|
+
const cached = getCachedResolve(cacheKey);
|
|
172
|
+
if (!cached) return false;
|
|
173
|
+
const argProvider = toolArgs?.provider;
|
|
174
|
+
const argPlatformId = toolArgs?.platformId;
|
|
175
|
+
if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
|
|
176
|
+
return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the inbound session identity. Reads from `lastInboundConversationId`
|
|
180
|
+
* (set by `message_received`) — the LLM-supplied `params.conversationId` is
|
|
181
|
+
* ignored because in practice the LLM copies the visible-metadata
|
|
182
|
+
* `conversationId` field, which is currently the WS request envelope id
|
|
183
|
+
* (`req_…`), not the real session key the cache is keyed on. Falls back to
|
|
184
|
+
* `params.conversationId` only when no inbound message has been observed
|
|
185
|
+
* this process (autonomous / scheduled triggers).
|
|
186
|
+
*/
|
|
187
|
+
function resolveSessionIdentity(params) {
|
|
188
|
+
const conversationId = lastInboundConversationId ?? params.conversationId;
|
|
153
189
|
if (!conversationId) return {
|
|
154
|
-
|
|
155
|
-
|
|
190
|
+
ok: false,
|
|
191
|
+
error: "no_inbound_session: identity verification can only be initiated from a user-message turn. The verify tools auto-bind to the inbound sender's session and cannot be called from autonomous / scheduled / tool-chain triggers."
|
|
156
192
|
};
|
|
157
193
|
const cached = getCachedResolve(conversationId);
|
|
158
194
|
if (cached?.identityId == null) return {
|
|
159
|
-
|
|
160
|
-
conversationId
|
|
161
|
-
error: "session_identity_unavailable: inbound identity cache miss for this conversationId. Pass claimedIdentityId explicitly, or wait for the next inbound message."
|
|
162
|
-
};
|
|
163
|
-
if (explicitClaimed && explicitClaimed !== cached.identityId) return {
|
|
164
|
-
hit: false,
|
|
165
|
-
conversationId,
|
|
166
|
-
error: "claimedIdentityId conflicts with the inbound session identity. Either omit claimedIdentityId (recommended) or remove conversationId."
|
|
195
|
+
ok: false,
|
|
196
|
+
error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
|
|
167
197
|
};
|
|
168
198
|
return {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
199
|
+
ok: true,
|
|
200
|
+
identityId: cached.identityId,
|
|
201
|
+
senderProvider: cached.senderProvider,
|
|
202
|
+
senderPlatformId: cached.senderPlatformId
|
|
174
203
|
};
|
|
175
204
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Resolution order for the request flow (needs the full requester tuple):
|
|
178
|
-
* 1. conversationId hits cache → derive all four from cached entry.
|
|
179
|
-
* 2. conversationId set but cache miss → reject session_identity_unavailable.
|
|
180
|
-
* 3. No conversationId AND no claimedIdentityId → reject missing_identity.
|
|
181
|
-
* 4. claimedIdentityId + full requester tuple supplied → legacy explicit
|
|
182
|
-
* flow. Layer 1's server-side `no_op_already_verified` guard catches
|
|
183
|
-
* the common misuse case (looking up an existing identity by name).
|
|
184
|
-
*/
|
|
185
205
|
function resolveRequesterContext(params) {
|
|
186
|
-
const session =
|
|
187
|
-
if (session.
|
|
188
|
-
claimedIdentityId: session.cached.identityId,
|
|
189
|
-
requestingIdentityId: session.cached.identityId,
|
|
190
|
-
requestingProvider: session.cached.senderProvider,
|
|
191
|
-
requestingPlatformId: session.cached.senderPlatformId
|
|
192
|
-
};
|
|
193
|
-
if (session.conversationId) return { error: session.error };
|
|
194
|
-
const claimedIdentityId = params.claimedIdentityId;
|
|
195
|
-
const requestingIdentityId = params.requestingIdentityId;
|
|
196
|
-
const requestingProvider = params.requestingProvider;
|
|
197
|
-
const requestingPlatformId = params.requestingPlatformId;
|
|
198
|
-
if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId + requestingIdentityId + requestingProvider + requestingPlatformId explicitly." };
|
|
199
|
-
if (!requestingIdentityId || !requestingProvider || !requestingPlatformId) return { error: "missing_identity: when claimedIdentityId is supplied without conversationId, requestingIdentityId / requestingProvider / requestingPlatformId are also required. Prefer passing conversationId so all four are auto-derived." };
|
|
206
|
+
const session = resolveSessionIdentity(params);
|
|
207
|
+
if (!session.ok) return { error: session.error };
|
|
200
208
|
return {
|
|
201
|
-
claimedIdentityId,
|
|
202
|
-
requestingIdentityId,
|
|
203
|
-
requestingProvider,
|
|
204
|
-
requestingPlatformId
|
|
209
|
+
claimedIdentityId: session.identityId,
|
|
210
|
+
requestingIdentityId: session.identityId,
|
|
211
|
+
requestingProvider: session.senderProvider,
|
|
212
|
+
requestingPlatformId: session.senderPlatformId
|
|
205
213
|
};
|
|
206
214
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Confirm flow only needs `claimedIdentityId` — same conflict / cache-miss
|
|
209
|
-
* branches as the requester resolver, but no requester tuple.
|
|
210
|
-
*/
|
|
211
215
|
function resolveClaimedIdentity(params) {
|
|
212
|
-
const session =
|
|
213
|
-
if (session.
|
|
214
|
-
|
|
215
|
-
const claimedIdentityId = params.claimedIdentityId;
|
|
216
|
-
if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId explicitly." };
|
|
217
|
-
return { claimedIdentityId };
|
|
216
|
+
const session = resolveSessionIdentity(params);
|
|
217
|
+
if (!session.ok) return { error: session.error };
|
|
218
|
+
return { claimedIdentityId: session.identityId };
|
|
218
219
|
}
|
|
219
220
|
function ok(data) {
|
|
220
221
|
return {
|
|
@@ -259,6 +260,27 @@ function getClient() {
|
|
|
259
260
|
});
|
|
260
261
|
return cachedClient;
|
|
261
262
|
}
|
|
263
|
+
let cachedAgentContext = null;
|
|
264
|
+
let inflightWhoami = null;
|
|
265
|
+
async function getAgentContext(client) {
|
|
266
|
+
if (cachedAgentContext) return cachedAgentContext;
|
|
267
|
+
if (inflightWhoami) return inflightWhoami;
|
|
268
|
+
inflightWhoami = (async () => {
|
|
269
|
+
try {
|
|
270
|
+
const r = await client.whoami();
|
|
271
|
+
cachedAgentContext = {
|
|
272
|
+
agentId: r.agentId,
|
|
273
|
+
tenantId: r.tenantId
|
|
274
|
+
};
|
|
275
|
+
return cachedAgentContext;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
} finally {
|
|
279
|
+
inflightWhoami = null;
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
return inflightWhoami;
|
|
283
|
+
}
|
|
262
284
|
const plugin = {
|
|
263
285
|
id: "@alfe.ai/openclaw-identity",
|
|
264
286
|
name: "Alfe Identity",
|
|
@@ -399,13 +421,9 @@ const plugin = {
|
|
|
399
421
|
}),
|
|
400
422
|
defineTool({
|
|
401
423
|
name: "request_identity_verification",
|
|
402
|
-
description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the user has just told you their email or mobile number and you want them to confirm it.
|
|
424
|
+
description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. The verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke) — identity is server-authoritative, auto-derived from the current inbound session. There is no way to verify a different identity via this tool. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
|
|
403
425
|
parameters: _sinclair_typebox.Type.Object({
|
|
404
|
-
conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "
|
|
405
|
-
claimedIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Override for the inbound sender's identity. NEVER set this from a lookup_identity result — that's a footgun (the verification will no-op). Leave unset and pass conversationId so the inbound identity is used automatically. Only set this for explicit non-inbound verification flows (rare — dashboard / admin only)." })),
|
|
406
|
-
requestingIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Identity of the person making the claim. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
407
|
-
requestingProvider: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Provider the requester is on (discord, slack, whatsapp, etc.). Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
408
|
-
requestingPlatformId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Requester's provider-specific user ID. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
426
|
+
conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Deprecated and ignored — the tool auto-binds to the current inbound session. Retained as optional for back-compat with older agent prompts." })),
|
|
409
427
|
contactEmail: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
|
|
410
428
|
contactMobile: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
|
|
411
429
|
preferredChannel: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
|
|
@@ -435,21 +453,25 @@ const plugin = {
|
|
|
435
453
|
}),
|
|
436
454
|
defineTool({
|
|
437
455
|
name: "confirm_identity_verification",
|
|
438
|
-
description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the
|
|
456
|
+
description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). Identity is server-authoritative, auto-derived from the current inbound session — no override available.",
|
|
439
457
|
parameters: _sinclair_typebox.Type.Object({
|
|
440
|
-
conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "
|
|
441
|
-
claimedIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Override for the inbound sender's identity. Must match the value used on the request. Leave unset and pass conversationId." })),
|
|
458
|
+
conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Deprecated and ignored — the tool auto-binds to the current inbound session. Retained as optional for back-compat with older agent prompts." })),
|
|
442
459
|
verificationId: _sinclair_typebox.Type.String({ description: "Verification ID returned from request_identity_verification" }),
|
|
443
|
-
phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the
|
|
460
|
+
phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
|
|
444
461
|
}),
|
|
445
|
-
handler: (params) => {
|
|
462
|
+
handler: async (params) => {
|
|
446
463
|
const resolved = resolveClaimedIdentity(params);
|
|
447
|
-
if ("error" in resolved) return
|
|
448
|
-
|
|
464
|
+
if ("error" in resolved) return { error: resolved.error };
|
|
465
|
+
const result = await client.confirmIdentityVerification({
|
|
449
466
|
claimedIdentityId: resolved.claimedIdentityId,
|
|
450
467
|
verificationId: params.verificationId,
|
|
451
468
|
phrase: params.phrase
|
|
452
469
|
});
|
|
470
|
+
if (result.verified === true) {
|
|
471
|
+
const conversationId = lastInboundConversationId ?? params.conversationId;
|
|
472
|
+
if (conversationId) invalidateCachedResolve(conversationId);
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
453
475
|
}
|
|
454
476
|
})
|
|
455
477
|
];
|
|
@@ -461,13 +483,18 @@ const plugin = {
|
|
|
461
483
|
const provider = ctx.channelId ?? "unknown";
|
|
462
484
|
const senderId = event.metadata?.UserId ?? event.from;
|
|
463
485
|
if (!senderId) return;
|
|
464
|
-
|
|
486
|
+
if (ctx.conversationId) lastInboundConversationId = ctx.conversationId;
|
|
487
|
+
let agentId = ctx.agentId;
|
|
465
488
|
if (!agentId) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
489
|
+
const fallback = await getAgentContext(client);
|
|
490
|
+
if (fallback) agentId = fallback.agentId;
|
|
491
|
+
else {
|
|
492
|
+
log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
|
|
493
|
+
return {
|
|
494
|
+
block: true,
|
|
495
|
+
blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
|
|
496
|
+
};
|
|
497
|
+
}
|
|
471
498
|
}
|
|
472
499
|
const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
|
|
473
500
|
const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
|
|
@@ -516,40 +543,68 @@ const plugin = {
|
|
|
516
543
|
};
|
|
517
544
|
}
|
|
518
545
|
}, { priority: 100 });
|
|
519
|
-
const beforeToolCallStub = (event, ctx) => {
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
toolArgs: event.toolArgs
|
|
525
|
-
}), resolveToolGatingMode(), event.toolName);
|
|
526
|
-
return Promise.resolve(blocked);
|
|
546
|
+
const beforeToolCallStub = async (event, ctx) => {
|
|
547
|
+
if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
|
|
548
|
+
if (event.toolName === "who_is_this") {
|
|
549
|
+
const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
|
|
550
|
+
if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
|
|
527
551
|
}
|
|
552
|
+
let agentId = ctx.agentId;
|
|
553
|
+
if (!agentId) {
|
|
554
|
+
const fallback = await getAgentContext(client);
|
|
555
|
+
if (fallback) agentId = fallback.agentId;
|
|
556
|
+
}
|
|
557
|
+
if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
|
|
558
|
+
agentId,
|
|
559
|
+
toolName: event.toolName,
|
|
560
|
+
toolArgs: event.toolArgs
|
|
561
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
528
562
|
const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
|
|
529
563
|
if (cacheKey) {
|
|
530
564
|
const cached = getCachedResolve(cacheKey);
|
|
531
|
-
if (cached && cached.permissions.length > 0) {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}), resolveToolGatingMode(), event.toolName);
|
|
537
|
-
return Promise.resolve(blocked);
|
|
538
|
-
}
|
|
565
|
+
if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
|
|
566
|
+
agentId,
|
|
567
|
+
toolName: event.toolName,
|
|
568
|
+
toolArgs: event.toolArgs
|
|
569
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
539
570
|
}
|
|
540
|
-
|
|
541
|
-
agentId
|
|
571
|
+
return applyGateMode(evaluateAgentExec([], {
|
|
572
|
+
agentId,
|
|
542
573
|
toolName: event.toolName,
|
|
543
574
|
toolArgs: event.toolArgs
|
|
544
|
-
});
|
|
545
|
-
const mode = resolveToolGatingMode();
|
|
546
|
-
return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
|
|
575
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
547
576
|
};
|
|
548
577
|
api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
|
|
578
|
+
api.on("before_agent_start", (...args) => {
|
|
579
|
+
const ctx = args[1];
|
|
580
|
+
const cached = findMostRelevantCacheEntry([
|
|
581
|
+
ctx.conversationId,
|
|
582
|
+
ctx.sessionKey,
|
|
583
|
+
ctx.sessionId,
|
|
584
|
+
ctx.channelId
|
|
585
|
+
]);
|
|
586
|
+
if (!cached) return Promise.resolve(void 0);
|
|
587
|
+
if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
|
|
588
|
+
return Promise.resolve({ prependSystemContext: [
|
|
589
|
+
"Identity status: the user has not yet verified their identity.",
|
|
590
|
+
"Tools available now: request_identity_verification,",
|
|
591
|
+
"confirm_identity_verification, who_is_this.",
|
|
592
|
+
"All other tools are restricted until the user verifies.",
|
|
593
|
+
"If the user asks for a capability you cannot perform, briefly",
|
|
594
|
+
"explain that they need to verify first and offer to start the",
|
|
595
|
+
"verification flow."
|
|
596
|
+
].join(" ") });
|
|
597
|
+
}, { priority: 100 });
|
|
598
|
+
if (!cachedAgentContext) getAgentContext(client).then((c) => {
|
|
599
|
+
if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
|
|
600
|
+
else log.warn("Plugin context warm failed; will retry on next hook fire");
|
|
601
|
+
});
|
|
549
602
|
log.info("Alfe Identity plugin activated");
|
|
550
603
|
},
|
|
551
604
|
deactivate(api) {
|
|
552
605
|
cachedClient = null;
|
|
606
|
+
cachedAgentContext = null;
|
|
607
|
+
inflightWhoami = null;
|
|
553
608
|
api.logger.info("Alfe Identity plugin deactivated");
|
|
554
609
|
}
|
|
555
610
|
};
|
package/dist/plugin2.js
CHANGED
|
@@ -132,6 +132,7 @@ function evaluateAgentChat(permissions, args) {
|
|
|
132
132
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
133
133
|
const RESOLVE_CACHE_TTL_MS = 6e4;
|
|
134
134
|
const resolveCache = /* @__PURE__ */ new Map();
|
|
135
|
+
let lastInboundConversationId;
|
|
135
136
|
function getCachedResolve(key) {
|
|
136
137
|
const entry = resolveCache.get(key);
|
|
137
138
|
if (!entry) return null;
|
|
@@ -147,74 +148,74 @@ function setCachedResolve(key, value) {
|
|
|
147
148
|
expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
|
|
148
149
|
});
|
|
149
150
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
function invalidateCachedResolve(key) {
|
|
152
|
+
resolveCache.delete(key);
|
|
153
|
+
}
|
|
154
|
+
function findMostRelevantCacheEntry(probeKeys) {
|
|
155
|
+
for (const k of probeKeys) {
|
|
156
|
+
if (!k) continue;
|
|
157
|
+
const hit = getCachedResolve(k);
|
|
158
|
+
if (hit) return hit;
|
|
159
|
+
}
|
|
160
|
+
let mostRecent = null;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
for (const entry of resolveCache.values()) {
|
|
163
|
+
if (entry.expiresAt < now) continue;
|
|
164
|
+
if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
|
|
165
|
+
}
|
|
166
|
+
return mostRecent;
|
|
167
|
+
}
|
|
168
|
+
const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
|
|
169
|
+
function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
|
|
170
|
+
if (!cacheKey) return false;
|
|
171
|
+
const cached = getCachedResolve(cacheKey);
|
|
172
|
+
if (!cached) return false;
|
|
173
|
+
const argProvider = toolArgs?.provider;
|
|
174
|
+
const argPlatformId = toolArgs?.platformId;
|
|
175
|
+
if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
|
|
176
|
+
return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the inbound session identity. Reads from `lastInboundConversationId`
|
|
180
|
+
* (set by `message_received`) — the LLM-supplied `params.conversationId` is
|
|
181
|
+
* ignored because in practice the LLM copies the visible-metadata
|
|
182
|
+
* `conversationId` field, which is currently the WS request envelope id
|
|
183
|
+
* (`req_…`), not the real session key the cache is keyed on. Falls back to
|
|
184
|
+
* `params.conversationId` only when no inbound message has been observed
|
|
185
|
+
* this process (autonomous / scheduled triggers).
|
|
186
|
+
*/
|
|
187
|
+
function resolveSessionIdentity(params) {
|
|
188
|
+
const conversationId = lastInboundConversationId ?? params.conversationId;
|
|
153
189
|
if (!conversationId) return {
|
|
154
|
-
|
|
155
|
-
|
|
190
|
+
ok: false,
|
|
191
|
+
error: "no_inbound_session: identity verification can only be initiated from a user-message turn. The verify tools auto-bind to the inbound sender's session and cannot be called from autonomous / scheduled / tool-chain triggers."
|
|
156
192
|
};
|
|
157
193
|
const cached = getCachedResolve(conversationId);
|
|
158
194
|
if (cached?.identityId == null) return {
|
|
159
|
-
|
|
160
|
-
conversationId
|
|
161
|
-
error: "session_identity_unavailable: inbound identity cache miss for this conversationId. Pass claimedIdentityId explicitly, or wait for the next inbound message."
|
|
162
|
-
};
|
|
163
|
-
if (explicitClaimed && explicitClaimed !== cached.identityId) return {
|
|
164
|
-
hit: false,
|
|
165
|
-
conversationId,
|
|
166
|
-
error: "claimedIdentityId conflicts with the inbound session identity. Either omit claimedIdentityId (recommended) or remove conversationId."
|
|
195
|
+
ok: false,
|
|
196
|
+
error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
|
|
167
197
|
};
|
|
168
198
|
return {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
199
|
+
ok: true,
|
|
200
|
+
identityId: cached.identityId,
|
|
201
|
+
senderProvider: cached.senderProvider,
|
|
202
|
+
senderPlatformId: cached.senderPlatformId
|
|
174
203
|
};
|
|
175
204
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Resolution order for the request flow (needs the full requester tuple):
|
|
178
|
-
* 1. conversationId hits cache → derive all four from cached entry.
|
|
179
|
-
* 2. conversationId set but cache miss → reject session_identity_unavailable.
|
|
180
|
-
* 3. No conversationId AND no claimedIdentityId → reject missing_identity.
|
|
181
|
-
* 4. claimedIdentityId + full requester tuple supplied → legacy explicit
|
|
182
|
-
* flow. Layer 1's server-side `no_op_already_verified` guard catches
|
|
183
|
-
* the common misuse case (looking up an existing identity by name).
|
|
184
|
-
*/
|
|
185
205
|
function resolveRequesterContext(params) {
|
|
186
|
-
const session =
|
|
187
|
-
if (session.
|
|
188
|
-
claimedIdentityId: session.cached.identityId,
|
|
189
|
-
requestingIdentityId: session.cached.identityId,
|
|
190
|
-
requestingProvider: session.cached.senderProvider,
|
|
191
|
-
requestingPlatformId: session.cached.senderPlatformId
|
|
192
|
-
};
|
|
193
|
-
if (session.conversationId) return { error: session.error };
|
|
194
|
-
const claimedIdentityId = params.claimedIdentityId;
|
|
195
|
-
const requestingIdentityId = params.requestingIdentityId;
|
|
196
|
-
const requestingProvider = params.requestingProvider;
|
|
197
|
-
const requestingPlatformId = params.requestingPlatformId;
|
|
198
|
-
if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId + requestingIdentityId + requestingProvider + requestingPlatformId explicitly." };
|
|
199
|
-
if (!requestingIdentityId || !requestingProvider || !requestingPlatformId) return { error: "missing_identity: when claimedIdentityId is supplied without conversationId, requestingIdentityId / requestingProvider / requestingPlatformId are also required. Prefer passing conversationId so all four are auto-derived." };
|
|
206
|
+
const session = resolveSessionIdentity(params);
|
|
207
|
+
if (!session.ok) return { error: session.error };
|
|
200
208
|
return {
|
|
201
|
-
claimedIdentityId,
|
|
202
|
-
requestingIdentityId,
|
|
203
|
-
requestingProvider,
|
|
204
|
-
requestingPlatformId
|
|
209
|
+
claimedIdentityId: session.identityId,
|
|
210
|
+
requestingIdentityId: session.identityId,
|
|
211
|
+
requestingProvider: session.senderProvider,
|
|
212
|
+
requestingPlatformId: session.senderPlatformId
|
|
205
213
|
};
|
|
206
214
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Confirm flow only needs `claimedIdentityId` — same conflict / cache-miss
|
|
209
|
-
* branches as the requester resolver, but no requester tuple.
|
|
210
|
-
*/
|
|
211
215
|
function resolveClaimedIdentity(params) {
|
|
212
|
-
const session =
|
|
213
|
-
if (session.
|
|
214
|
-
|
|
215
|
-
const claimedIdentityId = params.claimedIdentityId;
|
|
216
|
-
if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId explicitly." };
|
|
217
|
-
return { claimedIdentityId };
|
|
216
|
+
const session = resolveSessionIdentity(params);
|
|
217
|
+
if (!session.ok) return { error: session.error };
|
|
218
|
+
return { claimedIdentityId: session.identityId };
|
|
218
219
|
}
|
|
219
220
|
function ok(data) {
|
|
220
221
|
return {
|
|
@@ -259,6 +260,27 @@ function getClient() {
|
|
|
259
260
|
});
|
|
260
261
|
return cachedClient;
|
|
261
262
|
}
|
|
263
|
+
let cachedAgentContext = null;
|
|
264
|
+
let inflightWhoami = null;
|
|
265
|
+
async function getAgentContext(client) {
|
|
266
|
+
if (cachedAgentContext) return cachedAgentContext;
|
|
267
|
+
if (inflightWhoami) return inflightWhoami;
|
|
268
|
+
inflightWhoami = (async () => {
|
|
269
|
+
try {
|
|
270
|
+
const r = await client.whoami();
|
|
271
|
+
cachedAgentContext = {
|
|
272
|
+
agentId: r.agentId,
|
|
273
|
+
tenantId: r.tenantId
|
|
274
|
+
};
|
|
275
|
+
return cachedAgentContext;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
} finally {
|
|
279
|
+
inflightWhoami = null;
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
return inflightWhoami;
|
|
283
|
+
}
|
|
262
284
|
const plugin = {
|
|
263
285
|
id: "@alfe.ai/openclaw-identity",
|
|
264
286
|
name: "Alfe Identity",
|
|
@@ -399,13 +421,9 @@ const plugin = {
|
|
|
399
421
|
}),
|
|
400
422
|
defineTool({
|
|
401
423
|
name: "request_identity_verification",
|
|
402
|
-
description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the user has just told you their email or mobile number and you want them to confirm it.
|
|
424
|
+
description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. The verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke) — identity is server-authoritative, auto-derived from the current inbound session. There is no way to verify a different identity via this tool. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
|
|
403
425
|
parameters: Type.Object({
|
|
404
|
-
conversationId: Type.Optional(Type.String({ description: "
|
|
405
|
-
claimedIdentityId: Type.Optional(Type.String({ description: "Override for the inbound sender's identity. NEVER set this from a lookup_identity result — that's a footgun (the verification will no-op). Leave unset and pass conversationId so the inbound identity is used automatically. Only set this for explicit non-inbound verification flows (rare — dashboard / admin only)." })),
|
|
406
|
-
requestingIdentityId: Type.Optional(Type.String({ description: "Identity of the person making the claim. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
407
|
-
requestingProvider: Type.Optional(Type.String({ description: "Provider the requester is on (discord, slack, whatsapp, etc.). Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
408
|
-
requestingPlatformId: Type.Optional(Type.String({ description: "Requester's provider-specific user ID. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
|
|
426
|
+
conversationId: Type.Optional(Type.String({ description: "Deprecated and ignored — the tool auto-binds to the current inbound session. Retained as optional for back-compat with older agent prompts." })),
|
|
409
427
|
contactEmail: Type.Optional(Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
|
|
410
428
|
contactMobile: Type.Optional(Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
|
|
411
429
|
preferredChannel: Type.Optional(Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
|
|
@@ -435,21 +453,25 @@ const plugin = {
|
|
|
435
453
|
}),
|
|
436
454
|
defineTool({
|
|
437
455
|
name: "confirm_identity_verification",
|
|
438
|
-
description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the
|
|
456
|
+
description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). Identity is server-authoritative, auto-derived from the current inbound session — no override available.",
|
|
439
457
|
parameters: Type.Object({
|
|
440
|
-
conversationId: Type.Optional(Type.String({ description: "
|
|
441
|
-
claimedIdentityId: Type.Optional(Type.String({ description: "Override for the inbound sender's identity. Must match the value used on the request. Leave unset and pass conversationId." })),
|
|
458
|
+
conversationId: Type.Optional(Type.String({ description: "Deprecated and ignored — the tool auto-binds to the current inbound session. Retained as optional for back-compat with older agent prompts." })),
|
|
442
459
|
verificationId: Type.String({ description: "Verification ID returned from request_identity_verification" }),
|
|
443
|
-
phrase: Type.String({ description: "Three-word phrase the
|
|
460
|
+
phrase: Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
|
|
444
461
|
}),
|
|
445
|
-
handler: (params) => {
|
|
462
|
+
handler: async (params) => {
|
|
446
463
|
const resolved = resolveClaimedIdentity(params);
|
|
447
|
-
if ("error" in resolved) return
|
|
448
|
-
|
|
464
|
+
if ("error" in resolved) return { error: resolved.error };
|
|
465
|
+
const result = await client.confirmIdentityVerification({
|
|
449
466
|
claimedIdentityId: resolved.claimedIdentityId,
|
|
450
467
|
verificationId: params.verificationId,
|
|
451
468
|
phrase: params.phrase
|
|
452
469
|
});
|
|
470
|
+
if (result.verified === true) {
|
|
471
|
+
const conversationId = lastInboundConversationId ?? params.conversationId;
|
|
472
|
+
if (conversationId) invalidateCachedResolve(conversationId);
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
453
475
|
}
|
|
454
476
|
})
|
|
455
477
|
];
|
|
@@ -461,13 +483,18 @@ const plugin = {
|
|
|
461
483
|
const provider = ctx.channelId ?? "unknown";
|
|
462
484
|
const senderId = event.metadata?.UserId ?? event.from;
|
|
463
485
|
if (!senderId) return;
|
|
464
|
-
|
|
486
|
+
if (ctx.conversationId) lastInboundConversationId = ctx.conversationId;
|
|
487
|
+
let agentId = ctx.agentId;
|
|
465
488
|
if (!agentId) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
489
|
+
const fallback = await getAgentContext(client);
|
|
490
|
+
if (fallback) agentId = fallback.agentId;
|
|
491
|
+
else {
|
|
492
|
+
log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
|
|
493
|
+
return {
|
|
494
|
+
block: true,
|
|
495
|
+
blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
|
|
496
|
+
};
|
|
497
|
+
}
|
|
471
498
|
}
|
|
472
499
|
const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
|
|
473
500
|
const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
|
|
@@ -516,40 +543,68 @@ const plugin = {
|
|
|
516
543
|
};
|
|
517
544
|
}
|
|
518
545
|
}, { priority: 100 });
|
|
519
|
-
const beforeToolCallStub = (event, ctx) => {
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
toolArgs: event.toolArgs
|
|
525
|
-
}), resolveToolGatingMode(), event.toolName);
|
|
526
|
-
return Promise.resolve(blocked);
|
|
546
|
+
const beforeToolCallStub = async (event, ctx) => {
|
|
547
|
+
if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
|
|
548
|
+
if (event.toolName === "who_is_this") {
|
|
549
|
+
const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
|
|
550
|
+
if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
|
|
527
551
|
}
|
|
552
|
+
let agentId = ctx.agentId;
|
|
553
|
+
if (!agentId) {
|
|
554
|
+
const fallback = await getAgentContext(client);
|
|
555
|
+
if (fallback) agentId = fallback.agentId;
|
|
556
|
+
}
|
|
557
|
+
if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
|
|
558
|
+
agentId,
|
|
559
|
+
toolName: event.toolName,
|
|
560
|
+
toolArgs: event.toolArgs
|
|
561
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
528
562
|
const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
|
|
529
563
|
if (cacheKey) {
|
|
530
564
|
const cached = getCachedResolve(cacheKey);
|
|
531
|
-
if (cached && cached.permissions.length > 0) {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}), resolveToolGatingMode(), event.toolName);
|
|
537
|
-
return Promise.resolve(blocked);
|
|
538
|
-
}
|
|
565
|
+
if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
|
|
566
|
+
agentId,
|
|
567
|
+
toolName: event.toolName,
|
|
568
|
+
toolArgs: event.toolArgs
|
|
569
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
539
570
|
}
|
|
540
|
-
|
|
541
|
-
agentId
|
|
571
|
+
return applyGateMode(evaluateAgentExec([], {
|
|
572
|
+
agentId,
|
|
542
573
|
toolName: event.toolName,
|
|
543
574
|
toolArgs: event.toolArgs
|
|
544
|
-
});
|
|
545
|
-
const mode = resolveToolGatingMode();
|
|
546
|
-
return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
|
|
575
|
+
}), resolveToolGatingMode(), event.toolName);
|
|
547
576
|
};
|
|
548
577
|
api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
|
|
578
|
+
api.on("before_agent_start", (...args) => {
|
|
579
|
+
const ctx = args[1];
|
|
580
|
+
const cached = findMostRelevantCacheEntry([
|
|
581
|
+
ctx.conversationId,
|
|
582
|
+
ctx.sessionKey,
|
|
583
|
+
ctx.sessionId,
|
|
584
|
+
ctx.channelId
|
|
585
|
+
]);
|
|
586
|
+
if (!cached) return Promise.resolve(void 0);
|
|
587
|
+
if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
|
|
588
|
+
return Promise.resolve({ prependSystemContext: [
|
|
589
|
+
"Identity status: the user has not yet verified their identity.",
|
|
590
|
+
"Tools available now: request_identity_verification,",
|
|
591
|
+
"confirm_identity_verification, who_is_this.",
|
|
592
|
+
"All other tools are restricted until the user verifies.",
|
|
593
|
+
"If the user asks for a capability you cannot perform, briefly",
|
|
594
|
+
"explain that they need to verify first and offer to start the",
|
|
595
|
+
"verification flow."
|
|
596
|
+
].join(" ") });
|
|
597
|
+
}, { priority: 100 });
|
|
598
|
+
if (!cachedAgentContext) getAgentContext(client).then((c) => {
|
|
599
|
+
if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
|
|
600
|
+
else log.warn("Plugin context warm failed; will retry on next hook fire");
|
|
601
|
+
});
|
|
549
602
|
log.info("Alfe Identity plugin activated");
|
|
550
603
|
},
|
|
551
604
|
deactivate(api) {
|
|
552
605
|
cachedClient = null;
|
|
606
|
+
cachedAgentContext = null;
|
|
607
|
+
inflightWhoami = null;
|
|
553
608
|
api.logger.info("Alfe Identity plugin deactivated");
|
|
554
609
|
}
|
|
555
610
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/openclaw-identity",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/plugin.js",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@auriclabs/roles": "0.1.1",
|
|
31
31
|
"@sinclair/typebox": "^0.34.48",
|
|
32
|
-
"@alfe.ai/
|
|
33
|
-
"@alfe.ai/
|
|
32
|
+
"@alfe.ai/agent-api-client": "0.1.3",
|
|
33
|
+
"@alfe.ai/config": "0.0.8"
|
|
34
34
|
},
|
|
35
35
|
"license": "UNLICENSED",
|
|
36
36
|
"scripts": {
|