@clawling/clawchat-plugin-openclaw 2026.6.30-4 → 2026.7.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/dist/src/api-client.js +13 -0
- package/dist/src/config.js +3 -0
- package/dist/src/friend-request-turn.js +73 -0
- package/dist/src/gate-outcome.js +26 -0
- package/dist/src/inbound.js +11 -0
- package/dist/src/permission-result.js +107 -0
- package/dist/src/permissions.js +6 -0
- package/dist/src/runtime.js +103 -0
- package/dist/src/tools.js +43 -10
- package/dist/src/ws-client.js +19 -6
- package/openclaw.plugin.json +2 -0
- package/package.json +1 -1
- package/src/api-client.ts +23 -0
- package/src/api-types.ts +1 -1
- package/src/config.ts +7 -0
- package/src/friend-request-turn.ts +87 -0
- package/src/gate-outcome.ts +44 -0
- package/src/inbound.ts +28 -0
- package/src/permission-result.ts +128 -0
- package/src/permissions.ts +13 -0
- package/src/runtime.ts +114 -0
- package/src/tools.ts +44 -10
- package/src/ws-client.ts +21 -6
package/src/api-client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type UserSearchHit,
|
|
18
18
|
} from "./api-types.ts";
|
|
19
19
|
import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
|
|
20
|
+
import type { PermissionPolicy, PermState } from "./permissions.ts";
|
|
20
21
|
import { CHANNEL_ID } from "./config.ts";
|
|
21
22
|
|
|
22
23
|
export interface PluginReportInput {
|
|
@@ -179,6 +180,17 @@ export interface OpenclawClawlingApiClient {
|
|
|
179
180
|
* error still throws.
|
|
180
181
|
*/
|
|
181
182
|
getMyGroupSettings(): Promise<GroupSettingsFetchResult>;
|
|
183
|
+
/**
|
|
184
|
+
* Fetch the agent's permission policy from `GET /v1/agents/me/permissions`.
|
|
185
|
+
*
|
|
186
|
+
* The response `data` is a flat object mapping operation name → state
|
|
187
|
+
* (`"ask"|"allow"|"deny"`), with one special key `"moment.visibility"` whose
|
|
188
|
+
* value is a string array. Returns a `PermissionPolicy` with `byOperation`
|
|
189
|
+
* (all non-visibility keys) and `momentVisibility` (the array). Throws on
|
|
190
|
+
* network errors or non-2xx responses; callers must catch and keep the stale
|
|
191
|
+
* cache (never throw / never block message handling).
|
|
192
|
+
*/
|
|
193
|
+
getMyPermissions(): Promise<PermissionPolicy>;
|
|
182
194
|
registerApp(params: { name: string; appId: string; url: string }): Promise<{ app: AgentAppView }>;
|
|
183
195
|
listApps(): Promise<{ apps: AgentAppView[] }>;
|
|
184
196
|
unregisterApp(appId: string): Promise<{ deleted: boolean }>;
|
|
@@ -362,6 +374,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
362
374
|
code,
|
|
363
375
|
status: res.status,
|
|
364
376
|
path,
|
|
377
|
+
...(env.data !== undefined ? { data: env.data } : {}),
|
|
365
378
|
});
|
|
366
379
|
}
|
|
367
380
|
return env.data as T;
|
|
@@ -759,5 +772,15 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
759
772
|
}
|
|
760
773
|
return { authoritative: true, rows };
|
|
761
774
|
},
|
|
775
|
+
async getMyPermissions(): Promise<PermissionPolicy> {
|
|
776
|
+
const data = await call<Record<string, unknown>>("GET", "/v1/agents/me/permissions");
|
|
777
|
+
const byOperation: Record<string, PermState> = {};
|
|
778
|
+
let momentVisibility: string[] = [];
|
|
779
|
+
for (const [k, v] of Object.entries(data ?? {})) {
|
|
780
|
+
if (k === "moment.visibility") momentVisibility = (v as string[]) ?? [];
|
|
781
|
+
else byOperation[k] = v as PermState;
|
|
782
|
+
}
|
|
783
|
+
return { byOperation, momentVisibility };
|
|
784
|
+
},
|
|
762
785
|
};
|
|
763
786
|
}
|
package/src/api-types.ts
CHANGED
|
@@ -157,7 +157,7 @@ export class ClawlingApiError extends Error {
|
|
|
157
157
|
constructor(
|
|
158
158
|
public readonly kind: ClawlingApiErrorKind,
|
|
159
159
|
message: string,
|
|
160
|
-
public readonly meta?: { code?: number; status?: number; path?: string },
|
|
160
|
+
public readonly meta?: { code?: number; status?: number; path?: string; data?: unknown },
|
|
161
161
|
) {
|
|
162
162
|
super(message);
|
|
163
163
|
this.name = "ClawlingApiError";
|
package/src/config.ts
CHANGED
|
@@ -106,6 +106,8 @@ export type OpenclawClawlingConfig = {
|
|
|
106
106
|
forwardToolCalls?: boolean;
|
|
107
107
|
/** Emit approval/action rich fragments instead of plain fallback text. */
|
|
108
108
|
richInteractions?: boolean;
|
|
109
|
+
/** Emit ONE consolidated awareness note to the agent when friend/conversation signals arrive. */
|
|
110
|
+
awarenessNote?: boolean;
|
|
109
111
|
reconnect?: OpenclawClawlingReconnectConfig;
|
|
110
112
|
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
111
113
|
ack?: OpenclawClawlingAckConfig;
|
|
@@ -152,6 +154,7 @@ export const openclawClawlingConfigSchema = {
|
|
|
152
154
|
forwardThinking: { type: "boolean" },
|
|
153
155
|
forwardToolCalls: { type: "boolean" },
|
|
154
156
|
richInteractions: { type: "boolean" },
|
|
157
|
+
awarenessNote: { type: "boolean" },
|
|
155
158
|
reconnect: {
|
|
156
159
|
type: "object",
|
|
157
160
|
additionalProperties: false,
|
|
@@ -274,6 +277,7 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
274
277
|
forwardThinking: boolean;
|
|
275
278
|
forwardToolCalls: boolean;
|
|
276
279
|
richInteractions: boolean;
|
|
280
|
+
awarenessNote: boolean;
|
|
277
281
|
allowFrom: string[];
|
|
278
282
|
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
279
283
|
heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
|
|
@@ -482,6 +486,8 @@ export function resolveOpenclawClawlingAccount(
|
|
|
482
486
|
typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
483
487
|
const richInteractions =
|
|
484
488
|
typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
489
|
+
const awarenessNote =
|
|
490
|
+
typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
|
|
485
491
|
|
|
486
492
|
return {
|
|
487
493
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
@@ -508,6 +514,7 @@ export function resolveOpenclawClawlingAccount(
|
|
|
508
514
|
forwardThinking,
|
|
509
515
|
forwardToolCalls,
|
|
510
516
|
richInteractions,
|
|
517
|
+
awarenessNote,
|
|
511
518
|
allowFrom: [],
|
|
512
519
|
reconnect: readReconnect(channel.reconnect),
|
|
513
520
|
heartbeat: readHeartbeat(channel.heartbeat),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic reasoning-turn builder for `friend.request` notify.signal events.
|
|
3
|
+
*
|
|
4
|
+
* When the server fires a `friend.request` signal the runtime dispatches one
|
|
5
|
+
* deduped synthetic `message.send` envelope so the agent can reason about the
|
|
6
|
+
* pending request. The prompt text varies by the current "friend.add" permission
|
|
7
|
+
* policy state: a deny policy yields a decline/inform prompt; ask or allow
|
|
8
|
+
* yields a prompt that instructs the agent to review the request via the
|
|
9
|
+
* `clawchat_list_friend_requests` tool.
|
|
10
|
+
*/
|
|
11
|
+
import { EVENT, type Envelope } from "./protocol-types.ts";
|
|
12
|
+
import type { PermState } from "./permissions.ts";
|
|
13
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns canned prompt text for a friend-request reasoning turn based on the
|
|
17
|
+
* current "friend.add" permission policy state.
|
|
18
|
+
*
|
|
19
|
+
* - `deny` → inform the agent it should decline; no accept instruction.
|
|
20
|
+
* - `ask` / `allow` → instruct the agent to review the pending request.
|
|
21
|
+
*/
|
|
22
|
+
export function friendRequestPromptFor(state: PermState): string {
|
|
23
|
+
if (state === "deny") {
|
|
24
|
+
return [
|
|
25
|
+
"A new friend request has arrived.",
|
|
26
|
+
"Your current friend-add policy is set to deny.",
|
|
27
|
+
"Do not add this contact.",
|
|
28
|
+
"You may inform the requester that you cannot add them at this time.",
|
|
29
|
+
].join(" ");
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
"A new friend request has arrived.",
|
|
33
|
+
"Please review the pending request by calling `clawchat_list_friend_requests`",
|
|
34
|
+
"and decide whether to accept it.",
|
|
35
|
+
].join(" ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildFriendRequestEnvelopeParams {
|
|
39
|
+
account: ResolvedOpenclawClawlingAccount;
|
|
40
|
+
/** Current "friend.add" permission policy state. */
|
|
41
|
+
state: PermState;
|
|
42
|
+
/** entity_id from the notify.signal payload (the requesting user). */
|
|
43
|
+
entityId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a synthetic `message.send` envelope that triggers one agent reasoning
|
|
48
|
+
* turn for a pending friend request. The envelope shape mirrors the
|
|
49
|
+
* activation-bootstrap envelope; it targets the owner's direct conversation so
|
|
50
|
+
* the agent has context about who it is reasoning for.
|
|
51
|
+
*/
|
|
52
|
+
export function buildFriendRequestEnvelope(
|
|
53
|
+
params: BuildFriendRequestEnvelopeParams,
|
|
54
|
+
): Envelope {
|
|
55
|
+
const { account, state, entityId } = params;
|
|
56
|
+
const text = friendRequestPromptFor(state);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
return {
|
|
59
|
+
version: "2",
|
|
60
|
+
event: EVENT.MESSAGE_SEND,
|
|
61
|
+
trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
62
|
+
emitted_at: now,
|
|
63
|
+
chat_id: account.ownerUserId,
|
|
64
|
+
chat_type: "direct",
|
|
65
|
+
to: { id: account.userId, type: "direct" },
|
|
66
|
+
sender: {
|
|
67
|
+
id: "clawchat-friend-request",
|
|
68
|
+
type: "direct",
|
|
69
|
+
nick_name: "ClawChat",
|
|
70
|
+
},
|
|
71
|
+
payload: {
|
|
72
|
+
message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
73
|
+
message_mode: "normal",
|
|
74
|
+
message: {
|
|
75
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
76
|
+
context: { mentions: [], reply: null },
|
|
77
|
+
streaming: {
|
|
78
|
+
status: "static",
|
|
79
|
+
sequence: 0,
|
|
80
|
+
mutation_policy: "sealed",
|
|
81
|
+
started_at: null,
|
|
82
|
+
completed_at: null,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps backend owner-approval gate codes to structured tool outcomes.
|
|
3
|
+
*
|
|
4
|
+
* Backend gate codes (confirmed live contract):
|
|
5
|
+
* 21001 — pending owner approval (payload: { request_id, operation, expires_at })
|
|
6
|
+
* 21003 — policy forbidden (payload: { operation })
|
|
7
|
+
*
|
|
8
|
+
* Returns null for any other code so callers can fall through to normal handling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type PendingApprovalOutcome = {
|
|
12
|
+
status: "pending_owner_approval";
|
|
13
|
+
request_id: string;
|
|
14
|
+
operation: string;
|
|
15
|
+
expires_at: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ForbiddenOutcome = {
|
|
19
|
+
status: "forbidden_by_owner";
|
|
20
|
+
operation: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type GateOutcome = PendingApprovalOutcome | ForbiddenOutcome;
|
|
24
|
+
|
|
25
|
+
export function mapGateOutcome(
|
|
26
|
+
code: number,
|
|
27
|
+
data: Record<string, unknown>,
|
|
28
|
+
): GateOutcome | null {
|
|
29
|
+
if (code === 21001) {
|
|
30
|
+
return {
|
|
31
|
+
status: "pending_owner_approval",
|
|
32
|
+
request_id: typeof data.request_id === "string" ? data.request_id : "",
|
|
33
|
+
operation: typeof data.operation === "string" ? data.operation : "",
|
|
34
|
+
expires_at: typeof data.expires_at === "number" ? data.expires_at : 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (code === 21003) {
|
|
38
|
+
return {
|
|
39
|
+
status: "forbidden_by_owner",
|
|
40
|
+
operation: typeof data.operation === "string" ? data.operation : "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
package/src/inbound.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
|
8
8
|
import type { MediaItem } from "./media-runtime.ts";
|
|
9
9
|
import { extractMediaFragments, fragmentsToText, type MentionedUser } from "./message-mapper.ts";
|
|
10
10
|
import { hasRenderableText, isInboundMessagePayload } from "./protocol.ts";
|
|
11
|
+
import type { PermissionResultMetadata } from "./permission-result.ts";
|
|
11
12
|
|
|
12
13
|
export interface IngestTurnParams {
|
|
13
14
|
channel: "clawchat-plugin-openclaw";
|
|
@@ -81,6 +82,20 @@ export interface DispatchInboundParams {
|
|
|
81
82
|
text: string;
|
|
82
83
|
chatId: string;
|
|
83
84
|
}) => Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Optional consumer for `permission_result` system-message receipts.
|
|
87
|
+
*
|
|
88
|
+
* When `sender.id === "system"` and `payload.metadata.kind ===
|
|
89
|
+
* "permission_result"`, the guard calls this callback with the metadata
|
|
90
|
+
* object instead of performing the generic system-message drop. The
|
|
91
|
+
* callback is responsible for deduplication and synthetic-turn dispatch
|
|
92
|
+
* (see `src/permission-result.ts`).
|
|
93
|
+
*
|
|
94
|
+
* When absent, `permission_result` frames fall through to the existing
|
|
95
|
+
* system-message drop (safe default — no regression for callers that have
|
|
96
|
+
* not yet wired the consumer).
|
|
97
|
+
*/
|
|
98
|
+
consumePermissionResult?: (metadata: PermissionResultMetadata) => Promise<void>;
|
|
84
99
|
log?: { info?: (m: string) => void; error?: (m: string) => void };
|
|
85
100
|
}
|
|
86
101
|
|
|
@@ -241,6 +256,19 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
241
256
|
return;
|
|
242
257
|
}
|
|
243
258
|
if (sender.id === "system") {
|
|
259
|
+
// Intercept `permission_result` receipts before the generic system-message
|
|
260
|
+
// drop. The discriminator is `payload.metadata.kind === "permission_result"`;
|
|
261
|
+
// all other system messages (group-join notifications, etc.) still drop.
|
|
262
|
+
const rawPayload = envelope.payload as Record<string, unknown>;
|
|
263
|
+
const meta = rawPayload.metadata as Record<string, unknown> | undefined;
|
|
264
|
+
if (
|
|
265
|
+
params.consumePermissionResult &&
|
|
266
|
+
meta != null &&
|
|
267
|
+
meta.kind === "permission_result"
|
|
268
|
+
) {
|
|
269
|
+
await params.consumePermissionResult(meta as unknown as PermissionResultMetadata);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
244
272
|
log?.info?.(
|
|
245
273
|
`[${account.accountId}] clawchat-plugin-openclaw skip system message msg=${payload.message_id}`,
|
|
246
274
|
);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer for `permission_result` system-message receipts.
|
|
3
|
+
*
|
|
4
|
+
* When the backend delivers a `permission_result` outcome as a
|
|
5
|
+
* `message.send` system frame (`sender.id="system"`, `payload.metadata.kind
|
|
6
|
+
* ="permission_result"`), this module deduplicates by `request_id` and
|
|
7
|
+
* produces a synthetic context-note turn so the agent can close the loop
|
|
8
|
+
* on the original permission request.
|
|
9
|
+
*
|
|
10
|
+
* Wire shape (authoritative):
|
|
11
|
+
* ```
|
|
12
|
+
* payload.metadata = {
|
|
13
|
+
* kind: "permission_result",
|
|
14
|
+
* operation: "friend.add",
|
|
15
|
+
* outcome: "approved" | "denied" | "expired" | "failed",
|
|
16
|
+
* reason: "owner_allowed" | …,
|
|
17
|
+
* request_id: "prq_…",
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
* The discriminator is `payload.metadata.kind === "permission_result"`.
|
|
21
|
+
*/
|
|
22
|
+
import { EVENT, type Envelope } from "./protocol-types.ts";
|
|
23
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
24
|
+
|
|
25
|
+
export interface PermissionResultMetadata {
|
|
26
|
+
kind: "permission_result";
|
|
27
|
+
operation: string;
|
|
28
|
+
outcome: "approved" | "denied" | "expired" | "failed";
|
|
29
|
+
reason: string;
|
|
30
|
+
request_id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Module-level dedup set — `request_id` values are stable backend identifiers.
|
|
34
|
+
// A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
|
|
35
|
+
// an agent turn.
|
|
36
|
+
const _seenRequestIds = new Set<string>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @internal Test helper — reset the dedup set between isolated test runs.
|
|
40
|
+
* Never call this in production code.
|
|
41
|
+
*/
|
|
42
|
+
export function _resetPermissionResultDedup(): void {
|
|
43
|
+
_seenRequestIds.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOutcomeNote(metadata: PermissionResultMetadata): string {
|
|
47
|
+
const { operation, outcome, reason } = metadata;
|
|
48
|
+
const outcomeLabel =
|
|
49
|
+
outcome === "approved"
|
|
50
|
+
? "approved"
|
|
51
|
+
: outcome === "denied"
|
|
52
|
+
? "denied"
|
|
53
|
+
: outcome === "expired"
|
|
54
|
+
? "expired"
|
|
55
|
+
: "failed";
|
|
56
|
+
const resolution =
|
|
57
|
+
outcome === "approved"
|
|
58
|
+
? "The requested action has been completed."
|
|
59
|
+
: "No further action was taken.";
|
|
60
|
+
return [
|
|
61
|
+
`Permission request for operation "${operation}" has been ${outcomeLabel}.`,
|
|
62
|
+
`Reason: ${reason}.`,
|
|
63
|
+
resolution,
|
|
64
|
+
].join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds a synthetic `message.send` envelope that delivers a
|
|
69
|
+
* `permission_result` context note to the agent.
|
|
70
|
+
*
|
|
71
|
+
* Mirrors the `buildFriendRequestEnvelope` pattern: a synthetic inbound
|
|
72
|
+
* `message.send` targeting the owner's direct conversation, routed through
|
|
73
|
+
* `handleInboundEnvelope` so the agent can reason about the outcome.
|
|
74
|
+
*/
|
|
75
|
+
export function buildPermissionResultEnvelope(params: {
|
|
76
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">;
|
|
77
|
+
metadata: PermissionResultMetadata;
|
|
78
|
+
}): Envelope {
|
|
79
|
+
const { account, metadata } = params;
|
|
80
|
+
const text = buildOutcomeNote(metadata);
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
return {
|
|
83
|
+
version: "2",
|
|
84
|
+
event: EVENT.MESSAGE_SEND,
|
|
85
|
+
trace_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
|
|
86
|
+
emitted_at: now,
|
|
87
|
+
chat_id: account.ownerUserId,
|
|
88
|
+
chat_type: "direct",
|
|
89
|
+
to: { id: account.userId, type: "direct" },
|
|
90
|
+
sender: {
|
|
91
|
+
id: "clawchat-permission-result",
|
|
92
|
+
type: "direct",
|
|
93
|
+
nick_name: "ClawChat",
|
|
94
|
+
},
|
|
95
|
+
payload: {
|
|
96
|
+
message_id: `clawchat-plugin-openclaw-permission-result-${metadata.request_id}-${now}`,
|
|
97
|
+
message_mode: "normal",
|
|
98
|
+
message: {
|
|
99
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
100
|
+
context: { mentions: [], reply: null },
|
|
101
|
+
streaming: {
|
|
102
|
+
status: "static",
|
|
103
|
+
sequence: 0,
|
|
104
|
+
mutation_policy: "sealed",
|
|
105
|
+
started_at: null,
|
|
106
|
+
completed_at: null,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Handles a `permission_result` receipt. Deduplicates by `request_id`
|
|
115
|
+
* (idempotent — WS replay on reconnect may deliver the same receipt again).
|
|
116
|
+
*
|
|
117
|
+
* Returns a synthetic context-note {@link Envelope} on a fresh receipt so
|
|
118
|
+
* the caller can feed it to `handleInboundEnvelope`; returns `null` when
|
|
119
|
+
* the `request_id` has already been processed.
|
|
120
|
+
*/
|
|
121
|
+
export function handlePermissionResult(
|
|
122
|
+
metadata: PermissionResultMetadata,
|
|
123
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
|
|
124
|
+
): Envelope | null {
|
|
125
|
+
if (_seenRequestIds.has(metadata.request_id)) return null;
|
|
126
|
+
_seenRequestIds.add(metadata.request_id);
|
|
127
|
+
return buildPermissionResultEnvelope({ account, metadata });
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PermState = "ask" | "allow" | "deny";
|
|
2
|
+
|
|
3
|
+
export interface PermissionPolicy {
|
|
4
|
+
byOperation: Record<string, PermState>;
|
|
5
|
+
momentVisibility: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class PermissionCache {
|
|
9
|
+
private p: PermissionPolicy = { byOperation: {}, momentVisibility: [] };
|
|
10
|
+
get(): PermissionPolicy { return this.p; }
|
|
11
|
+
set(p: PermissionPolicy): void { this.p = p; }
|
|
12
|
+
stateOf(op: string): PermState { return this.p.byOperation[op] ?? "ask"; }
|
|
13
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -78,6 +78,9 @@ import {
|
|
|
78
78
|
import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
|
|
79
79
|
import { openclawLlmContextDebug } from "./llm-context-debug.ts";
|
|
80
80
|
import { GroupSettingsCache } from "./group-settings.ts";
|
|
81
|
+
import { PermissionCache } from "./permissions.ts";
|
|
82
|
+
import { buildFriendRequestEnvelope } from "./friend-request-turn.ts";
|
|
83
|
+
import { handlePermissionResult } from "./permission-result.ts";
|
|
81
84
|
|
|
82
85
|
type Log = { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
|
|
83
86
|
type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
|
|
@@ -1752,6 +1755,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1752
1755
|
context: wsLogContext,
|
|
1753
1756
|
});
|
|
1754
1757
|
const groupSettingsCache = new GroupSettingsCache();
|
|
1758
|
+
const permissionCache = new PermissionCache();
|
|
1755
1759
|
// Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
|
|
1756
1760
|
// the first one. The pull is fire-and-forget, so replayed/live group messages
|
|
1757
1761
|
// could otherwise enter the governance gate against a stale/empty cache
|
|
@@ -1829,6 +1833,28 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1829
1833
|
}
|
|
1830
1834
|
})();
|
|
1831
1835
|
};
|
|
1836
|
+
// Best-effort pull of the agent's permission policy from the backend.
|
|
1837
|
+
// Called at startup (bypassRefresh: true, mirrors pullGroupSettings startup)
|
|
1838
|
+
// and on every successful (re)connect after hello-ok. On failure, logs and
|
|
1839
|
+
// keeps the stale cache — never throws, never blocks message handling.
|
|
1840
|
+
const pullPermissions = (opts?: { bypassRefresh?: boolean }): void => {
|
|
1841
|
+
void (async () => {
|
|
1842
|
+
try {
|
|
1843
|
+
const policy = opts?.bypassRefresh
|
|
1844
|
+
? await (conversationApiClient ??= buildConversationApiClient()).getMyPermissions()
|
|
1845
|
+
: await getConversationApiClient().getMyPermissions();
|
|
1846
|
+
permissionCache.set(policy);
|
|
1847
|
+
log?.info?.(
|
|
1848
|
+
`[${accountId}] clawchat-plugin-openclaw permissions pull ok ops=${Object.keys(policy.byOperation).length}`,
|
|
1849
|
+
);
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
log?.error?.(
|
|
1852
|
+
`[${accountId}] clawchat-plugin-openclaw permissions pull failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1853
|
+
);
|
|
1854
|
+
// Keep stale cache; never throw.
|
|
1855
|
+
}
|
|
1856
|
+
})();
|
|
1857
|
+
};
|
|
1832
1858
|
const logAuthFailure = (reason: string) => {
|
|
1833
1859
|
if (authFailureLogged) return;
|
|
1834
1860
|
authFailureLogged = true;
|
|
@@ -1962,6 +1988,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1962
1988
|
// Re-pull per-group agent settings on every successful (re)connect so the
|
|
1963
1989
|
// cache stays current after a network interruption or token refresh.
|
|
1964
1990
|
pullGroupSettings();
|
|
1991
|
+
// Re-pull permission policy alongside group settings on every (re)connect.
|
|
1992
|
+
pullPermissions();
|
|
1965
1993
|
// §A.1 — arm the proactive refresh timer from the live token's `exp`
|
|
1966
1994
|
// every time a connection becomes ready (re-armed after every refresh via
|
|
1967
1995
|
// the gateway re-enter).
|
|
@@ -2193,6 +2221,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2193
2221
|
});
|
|
2194
2222
|
};
|
|
2195
2223
|
|
|
2224
|
+
// Debounce flag for lighter-awareness consolidated notes.
|
|
2225
|
+
// A single queueMicrotask coalesces all awareness signals that arrive in
|
|
2226
|
+
// the same synchronous turn into exactly one handleInboundEnvelope call.
|
|
2227
|
+
let pendingAwarenessNote = false;
|
|
2228
|
+
|
|
2196
2229
|
client.on("notify:signal", (env: Envelope) => {
|
|
2197
2230
|
// §9.4 reliable system notification. The plugin holds no friend/roster
|
|
2198
2231
|
// cache (friends are fetched on demand via REST tools), so there is nothing
|
|
@@ -2204,11 +2237,17 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2204
2237
|
? (env.payload as Record<string, unknown>)
|
|
2205
2238
|
: undefined;
|
|
2206
2239
|
const type = typeof payload?.type === "string" ? payload.type : "";
|
|
2240
|
+
const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
2207
2241
|
if (type === "agent.config.changed") {
|
|
2208
2242
|
log?.info?.(
|
|
2209
2243
|
`[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
|
|
2210
2244
|
);
|
|
2211
2245
|
pullGroupSettings();
|
|
2246
|
+
} else if (type === "agent.permission.changed") {
|
|
2247
|
+
log?.info?.(
|
|
2248
|
+
`[${accountId}] clawchat-plugin-openclaw agent.permission.changed signal received; re-pulling permissions`,
|
|
2249
|
+
);
|
|
2250
|
+
pullPermissions();
|
|
2212
2251
|
} else if (type === "clawchat.skill.update.check") {
|
|
2213
2252
|
// Content-free trigger: check the official skill source in code and, if
|
|
2214
2253
|
// there is an update, ask the owner in chat for consent. The atomic
|
|
@@ -2223,6 +2262,66 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2223
2262
|
}`,
|
|
2224
2263
|
);
|
|
2225
2264
|
});
|
|
2265
|
+
} else if (type === "friend.request") {
|
|
2266
|
+
// Dedup is already applied above by notifySignalObserver.observe (the live
|
|
2267
|
+
// frame and its reliable-inbox replay share the same event_id and collapse
|
|
2268
|
+
// to one observation). Build one synthetic reasoning turn so the agent can
|
|
2269
|
+
// act on the pending request; the prompt text varies by policy state.
|
|
2270
|
+
const state = permissionCache.stateOf("friend.add");
|
|
2271
|
+
log?.info?.(
|
|
2272
|
+
`[${accountId}] clawchat-plugin-openclaw friend.request signal received; dispatching reasoning turn entity_id=${entityId} policy=${state}`,
|
|
2273
|
+
);
|
|
2274
|
+
void handleInboundEnvelope(buildFriendRequestEnvelope({ account, state, entityId }));
|
|
2275
|
+
} else if (
|
|
2276
|
+
type === "friend.added" ||
|
|
2277
|
+
type === "friend.removed" ||
|
|
2278
|
+
type === "friend.profile_updated" ||
|
|
2279
|
+
type.startsWith("conversation.")
|
|
2280
|
+
) {
|
|
2281
|
+
// Lighter-awareness: log + optional single consolidated note. Never
|
|
2282
|
+
// injects a full reasoning turn per event (unlike friend.request).
|
|
2283
|
+
log?.info?.(
|
|
2284
|
+
`[${accountId}] clawchat-plugin-openclaw ${type} signal received; lighter-awareness handling`,
|
|
2285
|
+
);
|
|
2286
|
+
if (account.awarenessNote && !pendingAwarenessNote) {
|
|
2287
|
+
pendingAwarenessNote = true;
|
|
2288
|
+
queueMicrotask(() => {
|
|
2289
|
+
pendingAwarenessNote = false;
|
|
2290
|
+
const now = Date.now();
|
|
2291
|
+
void handleInboundEnvelope({
|
|
2292
|
+
version: "2",
|
|
2293
|
+
event: EVENT.MESSAGE_SEND,
|
|
2294
|
+
trace_id: `clawchat-plugin-openclaw-awareness-${now}`,
|
|
2295
|
+
emitted_at: now,
|
|
2296
|
+
chat_id: account.ownerUserId,
|
|
2297
|
+
chat_type: "direct",
|
|
2298
|
+
to: { id: account.userId, type: "direct" },
|
|
2299
|
+
sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
|
|
2300
|
+
payload: {
|
|
2301
|
+
message_id: `clawchat-plugin-openclaw-awareness-${now}`,
|
|
2302
|
+
message_mode: "normal",
|
|
2303
|
+
message: {
|
|
2304
|
+
body: {
|
|
2305
|
+
fragments: [
|
|
2306
|
+
{
|
|
2307
|
+
kind: "text",
|
|
2308
|
+
text: "ClawChat awareness: your contacts or conversations have been updated.",
|
|
2309
|
+
},
|
|
2310
|
+
],
|
|
2311
|
+
},
|
|
2312
|
+
context: { mentions: [], reply: null },
|
|
2313
|
+
streaming: {
|
|
2314
|
+
status: "static",
|
|
2315
|
+
sequence: 0,
|
|
2316
|
+
mutation_policy: "sealed",
|
|
2317
|
+
started_at: null,
|
|
2318
|
+
completed_at: null,
|
|
2319
|
+
},
|
|
2320
|
+
},
|
|
2321
|
+
},
|
|
2322
|
+
} as unknown as Envelope);
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2226
2325
|
}
|
|
2227
2326
|
}
|
|
2228
2327
|
});
|
|
@@ -2924,6 +3023,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2924
3023
|
account,
|
|
2925
3024
|
log,
|
|
2926
3025
|
consumeOwnerConsent: consumeOwnerSkillConsent,
|
|
3026
|
+
consumePermissionResult: async (metadata) => {
|
|
3027
|
+
// A permission_result receipt closes the loop on an earlier request.
|
|
3028
|
+
// Dedup by request_id; on a fresh receipt feed the synthetic context
|
|
3029
|
+
// note back to the agent so it can react to the outcome.
|
|
3030
|
+
const note = handlePermissionResult(metadata, account);
|
|
3031
|
+
if (note) {
|
|
3032
|
+
log?.info?.(
|
|
3033
|
+
`[${accountId}] clawchat-plugin-openclaw permission_result receipt op=${metadata.operation} outcome=${metadata.outcome} request_id=${metadata.request_id}; dispatching context note`,
|
|
3034
|
+
);
|
|
3035
|
+
void handleInboundEnvelope(note);
|
|
3036
|
+
}
|
|
3037
|
+
},
|
|
2927
3038
|
ingest: async (turn) => {
|
|
2928
3039
|
ingestResult = await ingestTurn(turn);
|
|
2929
3040
|
},
|
|
@@ -3020,6 +3131,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3020
3131
|
// rotating `account.token` out from under the already-constructed WS client.
|
|
3021
3132
|
// Settling this releases the Finding-2 group-dispatch gate.
|
|
3022
3133
|
pullGroupSettings({ bypassRefresh: true });
|
|
3134
|
+
// Initial pull of permission policy at startup (best-effort; errors logged,
|
|
3135
|
+
// stale cache kept). Uses bypassRefresh for the same reason as pullGroupSettings.
|
|
3136
|
+
pullPermissions({ bypassRefresh: true });
|
|
3023
3137
|
// Safety: never strand a held group message if the runtime is torn down while a
|
|
3024
3138
|
// pull is in flight. `waitWithTimeout` already releases group waiters on abort,
|
|
3025
3139
|
// but force the gate open too so any later `wait()` resolves immediately.
|