@clawling/clawchat-plugin-openclaw 2026.10.25-1 → 2026.10.31-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 +123 -6
- package/dist/src/host-tools-guard.js +61 -12
- package/dist/src/liveware-cli.js +219 -32
- package/dist/src/liveware-sample.js +6 -4
- package/dist/src/refresh-manager.js +10 -6
- package/dist/src/runtime.js +23 -16
- package/dist/src/skill-update.js +1 -1
- package/dist/src/storage.js +66 -34
- package/dist/src/tool-grants.js +3 -1
- package/dist/src/tool-requests.js +1 -1
- package/dist/src/tools-schema.js +5 -3
- package/dist/src/tools.js +30 -9
- package/dist/src/turn-grants.js +58 -0
- package/dist/src/widget-guide.js +69 -10
- package/dist/src/ws-client.js +10 -6
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +73 -11
- package/skills/clawchat-liveware/SKILL.md +5 -3
- package/skills/manifest.json +15 -15
- package/src/api-client.ts +133 -6
- package/src/host-tools-guard.ts +58 -12
- package/src/liveware-cli.ts +199 -37
- package/src/liveware-sample.ts +6 -4
- package/src/refresh-manager.ts +14 -8
- package/src/runtime.ts +26 -16
- package/src/skill-update.ts +1 -1
- package/src/storage.ts +80 -52
- package/src/tool-grants.ts +4 -2
- package/src/tool-requests.ts +1 -1
- package/src/tools-schema.ts +5 -3
- package/src/tools.ts +27 -9
- package/src/turn-grants.ts +58 -0
- package/src/widget-guide.ts +69 -10
- package/src/ws-client.ts +9 -6
package/dist/src/api-client.js
CHANGED
|
@@ -117,6 +117,105 @@ export async function authRefresh(opts, params) {
|
|
|
117
117
|
clearTimeout(timer);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
// A URL starts at an anchored, length-capped scheme. Its authority runs to the
|
|
121
|
+
// first whitespace or `/?#`, and userinfo is everything before the authority's
|
|
122
|
+
// last `@` (fetch accepts quotes, `<`, `>`, backticks and extra `@` there), with
|
|
123
|
+
// no length cap so long credentials fail closed. The host after that `@` ends at
|
|
124
|
+
// quotes and angle brackets as well, and only `scheme://userinfo@host[:port]` is
|
|
125
|
+
// rewritten; scanning continues right after it (or after the scheme when there
|
|
126
|
+
// is no userinfo), so adjacent and embedded URLs are still checked. Redaction is
|
|
127
|
+
// conservative: text glued to a credentialed URL up to a later `@` may be
|
|
128
|
+
// dropped with it. Every search is bounded by one authority, so server-supplied
|
|
129
|
+
// text is processed in linear time.
|
|
130
|
+
const URL_SCHEME = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\//gi;
|
|
131
|
+
const AUTHORITY_END = /[\s/?#]/g;
|
|
132
|
+
const HOST_END = /[\s"'<>`]/;
|
|
133
|
+
function stripUserinfoFromAuthority(scheme, authority) {
|
|
134
|
+
try {
|
|
135
|
+
const url = new URL(`${scheme}${authority}`);
|
|
136
|
+
return url.host ? `${scheme}${url.host}` : "[redacted-url]";
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "[redacted-url]";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function stripUrlUserinfo(text) {
|
|
143
|
+
let out = "";
|
|
144
|
+
let copied = 0;
|
|
145
|
+
for (const scheme of text.matchAll(URL_SCHEME)) {
|
|
146
|
+
const start = scheme.index ?? 0;
|
|
147
|
+
if (start < copied)
|
|
148
|
+
continue;
|
|
149
|
+
const authorityStart = start + scheme[0].length;
|
|
150
|
+
AUTHORITY_END.lastIndex = authorityStart;
|
|
151
|
+
const authorityEnd = AUTHORITY_END.exec(text)?.index ?? text.length;
|
|
152
|
+
const authority = text.slice(authorityStart, authorityEnd);
|
|
153
|
+
const at = authority.lastIndexOf("@");
|
|
154
|
+
if (at < 0)
|
|
155
|
+
continue;
|
|
156
|
+
const hostLength = authority.slice(at + 1).search(HOST_END);
|
|
157
|
+
const hostEnd = hostLength < 0 ? authorityEnd : authorityStart + at + 1 + hostLength;
|
|
158
|
+
out += text.slice(copied, start) + stripUserinfoFromAuthority(scheme[0], text.slice(authorityStart, hostEnd));
|
|
159
|
+
copied = hostEnd;
|
|
160
|
+
}
|
|
161
|
+
return out + text.slice(copied);
|
|
162
|
+
}
|
|
163
|
+
/** Replaces refresh diagnostic text when the configured base URL carries
|
|
164
|
+
* credentials: their echoes take too many forms (spaces, no scheme, escaping,
|
|
165
|
+
* percent-encoding, truncation) to remove reliably, so the free text is not
|
|
166
|
+
* shown at all. Callers keep the HTTP status and error kind around it. */
|
|
167
|
+
export const REFRESH_DIAGNOSTIC_WITHHELD = "(diagnostic text withheld: base URL contains credentials)";
|
|
168
|
+
/** Replaces refresh diagnostic text when redaction itself fails (fail closed). */
|
|
169
|
+
export const REFRESH_DIAGNOSTIC_FAILED = "(diagnostic text withheld: redaction failed)";
|
|
170
|
+
/** True when a URL carries userinfo: the parser reports a username or
|
|
171
|
+
* password, or its authority (up to the first `/?#\`) contains `@`. An
|
|
172
|
+
* unparseable URL containing `@` anywhere counts too (fail closed). */
|
|
173
|
+
export function urlHasUserinfo(url) {
|
|
174
|
+
if (!url)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
const parsed = new URL(url);
|
|
178
|
+
if (parsed.username || parsed.password)
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
if (url.includes("@"))
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const schemeEnd = url.indexOf("://");
|
|
186
|
+
const rest = schemeEnd < 0 ? url : url.slice(schemeEnd + 3);
|
|
187
|
+
const authorityLength = rest.search(/[/?#\\]/);
|
|
188
|
+
return (authorityLength < 0 ? rest : rest.slice(0, authorityLength)).includes("@");
|
|
189
|
+
}
|
|
190
|
+
/** Refresh diagnostics use the media redactor's credential patterns, plus exact
|
|
191
|
+
* in-scope tokens of 8+ characters (shorter values would mangle ordinary words;
|
|
192
|
+
* real tokens are far longer). Keep this at the first conversion to display text.
|
|
193
|
+
* URL userinfo is also removed before diagnostics reach display text. When the
|
|
194
|
+
* configured base URL carries credentials the whole text is replaced with
|
|
195
|
+
* REFRESH_DIAGNOSTIC_WITHHELD; any failure returns REFRESH_DIAGNOSTIC_FAILED.
|
|
196
|
+
* This never throws and never falls back to the original text. */
|
|
197
|
+
export function redactRefreshDiagnostics(text, tokens = [], baseUrl) {
|
|
198
|
+
try {
|
|
199
|
+
if (urlHasUserinfo(baseUrl))
|
|
200
|
+
return REFRESH_DIAGNOSTIC_WITHHELD;
|
|
201
|
+
return redactRefreshText(String(text), tokens);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return REFRESH_DIAGNOSTIC_FAILED;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function redactRefreshText(text, tokens) {
|
|
208
|
+
for (const token of tokens) {
|
|
209
|
+
const value = token?.trim();
|
|
210
|
+
if (value && value.length >= 8)
|
|
211
|
+
text = text.split(value).join("[redacted]");
|
|
212
|
+
}
|
|
213
|
+
// JSON body snippets may contain an escaped diagnostic string.
|
|
214
|
+
return stripUrlUserinfo(text.replace(/\\"/g, '"'))
|
|
215
|
+
.replace(/\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [redacted]")
|
|
216
|
+
.replace(/("?\b(?:authorization|access[_-]?token|refresh[_-]?token|token|api[_-]?key|apikey|password|passwd|secret|signature|x-amz-signature|x-amz-credential)\b"?\s*[:=]\s*)"?[^"',\s&}]+"?/gi, '$1"[redacted]"')
|
|
217
|
+
.replace(/\beyJ[A-Za-z0-9._~+/=-]{10,}/g, "[redacted]");
|
|
218
|
+
}
|
|
120
219
|
async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
121
220
|
let res;
|
|
122
221
|
try {
|
|
@@ -136,7 +235,7 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
|
136
235
|
// Network error / timeout / DNS — TRANSIENT (no rotation committed).
|
|
137
236
|
return {
|
|
138
237
|
kind: "transient",
|
|
139
|
-
message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
238
|
+
message: `refresh fetch failed: ${redactRefreshDiagnostics(err instanceof Error ? err.message : String(err), [params.refreshToken, params.accessToken], baseUrl)}`,
|
|
140
239
|
};
|
|
141
240
|
}
|
|
142
241
|
if (res.status !== 200) {
|
|
@@ -150,7 +249,12 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
|
150
249
|
const message = (typeof parsed.msg === "string" && parsed.msg) ||
|
|
151
250
|
(typeof parsed.message === "string" && parsed.message) ||
|
|
152
251
|
`refresh http ${res.status} code=10003`;
|
|
153
|
-
|
|
252
|
+
const detail = redactRefreshDiagnostics(message, [params.refreshToken, params.accessToken], baseUrl);
|
|
253
|
+
return {
|
|
254
|
+
kind: "permanent",
|
|
255
|
+
code: CODE_INVALID_REFRESH,
|
|
256
|
+
message: detail === REFRESH_DIAGNOSTIC_WITHHELD ? `refresh http ${res.status} code=10003 ${detail}` : detail,
|
|
257
|
+
};
|
|
154
258
|
}
|
|
155
259
|
}
|
|
156
260
|
catch {
|
|
@@ -159,7 +263,7 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
|
159
263
|
return {
|
|
160
264
|
kind: "transient",
|
|
161
265
|
status: res.status,
|
|
162
|
-
message: `refresh http ${res.status} ${text.slice(0, 200)}`,
|
|
266
|
+
message: `refresh http ${res.status} ${redactRefreshDiagnostics(text, [params.refreshToken, params.accessToken], baseUrl).slice(0, 200)}`,
|
|
163
267
|
};
|
|
164
268
|
}
|
|
165
269
|
const text = await res.text().catch(() => "");
|
|
@@ -171,9 +275,10 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
|
|
|
171
275
|
parsed = undefined;
|
|
172
276
|
}
|
|
173
277
|
const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
|
|
174
|
-
const
|
|
278
|
+
const detail = redactRefreshDiagnostics((typeof parsed?.msg === "string" && parsed.msg) ||
|
|
175
279
|
(typeof parsed?.message === "string" && parsed.message) ||
|
|
176
|
-
`code=${code}
|
|
280
|
+
`code=${code}`, [params.refreshToken, params.accessToken], baseUrl);
|
|
281
|
+
const message = detail === REFRESH_DIAGNOSTIC_WITHHELD ? `code=${code} ${detail}` : detail;
|
|
177
282
|
if (!Number.isFinite(code)) {
|
|
178
283
|
// 200 with no usable envelope — treat as transient (do not auto-logout).
|
|
179
284
|
return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
|
|
@@ -365,6 +470,17 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
365
470
|
}
|
|
366
471
|
return obj;
|
|
367
472
|
}
|
|
473
|
+
function parseAvatarUploadResult(data, path) {
|
|
474
|
+
const obj = data;
|
|
475
|
+
if (!obj ||
|
|
476
|
+
typeof obj.url !== "string" ||
|
|
477
|
+
obj.url === "" ||
|
|
478
|
+
typeof obj.mime !== "string" ||
|
|
479
|
+
typeof obj.size !== "number") {
|
|
480
|
+
throw new ClawlingApiError("api", "invalid upload response: missing required avatar fields", { path });
|
|
481
|
+
}
|
|
482
|
+
return { url: obj.url, size: obj.size, mime: obj.mime };
|
|
483
|
+
}
|
|
368
484
|
function isLivewareView(v) {
|
|
369
485
|
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
370
486
|
return false;
|
|
@@ -718,7 +834,8 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
718
834
|
});
|
|
719
835
|
const fd = new FormData();
|
|
720
836
|
fd.set("file", file);
|
|
721
|
-
|
|
837
|
+
const data = await call("POST", "/v1/files/upload-url", { body: fd });
|
|
838
|
+
return parseAvatarUploadResult(data, "/v1/files/upload-url");
|
|
722
839
|
},
|
|
723
840
|
async reportPlugin(input, opts) {
|
|
724
841
|
const path = opts?.authenticated
|
|
@@ -15,8 +15,8 @@ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
|
|
|
15
15
|
import { resolveOwnerIdentity } from "./owner-identity.js";
|
|
16
16
|
import { getRegisteredClawChatToolNames } from "./tools.js";
|
|
17
17
|
import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
|
|
18
|
-
import { bindOrchestrationOriginCall, bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
|
|
19
|
-
import { GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
|
|
18
|
+
import { bindOrchestrationOriginCall, bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, isNonOwnerBoundRun, hasOwnerDowngradingOverlap, ownerDowngradeTrigger, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
|
|
19
|
+
import { OWNER_DOWNGRADED_MESSAGE, GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
|
|
20
20
|
import { AUTHORIZATION_TOOLS, OWNER_ONLY_TOOLS, OWNER_TURN_STRICT_TOOLS, WIDGET_OWNER_ONLY_MESSAGE, TOOL_CAPABILITIES, canonicalToolName, hostToolTargetsCurrentSession, isCrossChatCall, resolveCrossChatGate } from "./tool-capabilities.js";
|
|
21
21
|
import { recordTurnArtifactCall, registerTurnArtifacts } from "./turn-artifacts.js";
|
|
22
22
|
export const BLOCK_MESSAGE = "This tool is not available here: only your owner's direct chat may use host tools on this machine. Do not try to reach them another way; answer without them, or tell the person to ask your owner.";
|
|
@@ -59,8 +59,22 @@ export function hasLocalPathArgument(value, field = "") {
|
|
|
59
59
|
return value.some(item => hasLocalPathArgument(item, field));
|
|
60
60
|
return Boolean(value && typeof value === "object" && Object.entries(value).some(([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key)));
|
|
61
61
|
}
|
|
62
|
+
const ownerDowngradedDecision = () => ({ block: true, blockReason: OWNER_DOWNGRADED_MESSAGE, reasonCode: "owner-downgraded" });
|
|
63
|
+
function ownerOverlapDowngraded(input, includeNotices = false) {
|
|
64
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
65
|
+
return Boolean(parsed && classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner"
|
|
66
|
+
&& input.ownerUserId?.trim() && input.senderId === input.ownerUserId
|
|
67
|
+
&& (includeNotices ? isNonOwnerSideSession(input.sessionKey)
|
|
68
|
+
: !isNonOwnerBoundRun(input.sessionKey, input.runId) && hasOwnerDowngradingOverlap(input.sessionKey)));
|
|
69
|
+
}
|
|
62
70
|
/** Pure decision for one tool call. `undefined` lets the call run. */
|
|
63
71
|
export function decideHostToolCall(input) {
|
|
72
|
+
const decision = decideHostToolCallInner(input);
|
|
73
|
+
return decision && "block" in decision && (ownerOverlapDowngraded(input)
|
|
74
|
+
|| (OWNER_ONLY_TOOLS.includes(canonicalToolName(input.toolName)) && ownerOverlapDowngraded(input, true)))
|
|
75
|
+
? ownerDowngradedDecision() : decision;
|
|
76
|
+
}
|
|
77
|
+
function decideHostToolCallInner(input) {
|
|
64
78
|
if (input.toolName === "clawchat_tool_request") {
|
|
65
79
|
const origin = input.sessionKey ? requestOriginForRun(input.sessionKey, input.runId) : undefined;
|
|
66
80
|
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
@@ -102,7 +116,12 @@ export function decideHostToolCall(input) {
|
|
|
102
116
|
// cannot start local turns in other sessions (cross-session). Under that
|
|
103
117
|
// audited premise, only the owner can initiate a snapshot-free turn here.
|
|
104
118
|
// A new host tool missing from the audit could break it; keep it current.
|
|
105
|
-
|
|
119
|
+
// Plugin notices in flight (no third-party text) keep that access only for
|
|
120
|
+
// a run the host attributes to the owner; the notice's own run never gets it.
|
|
121
|
+
const ownerExempt = ownerTurn && !isNonOwnerBoundRun(input.sessionKey, input.runId)
|
|
122
|
+
&& (!isNonOwnerSideSession(input.sessionKey) || (!hasOwnerDowngradingOverlap(input.sessionKey)
|
|
123
|
+
&& Boolean(input.ownerUserId?.trim()) && input.senderId === input.ownerUserId));
|
|
124
|
+
if (ownerExempt
|
|
106
125
|
|| (parsed.kind === "group" && isOwnerSideRun(input.sessionKey, input.runId)))
|
|
107
126
|
return undefined;
|
|
108
127
|
const publicTools = nonOwnerToolAllowlist(input.publicTools);
|
|
@@ -322,6 +341,25 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
322
341
|
return { ready: false, reason: "not-applied" };
|
|
323
342
|
}
|
|
324
343
|
};
|
|
344
|
+
const loggedOwnerDowngrades = new Set();
|
|
345
|
+
// A downgraded owner run has no request origin; tell it the truth instead of the
|
|
346
|
+
// generic refusal. Every other origin-less call keeps the plain block.
|
|
347
|
+
const ownerRequestDowngraded = (sessionKey, runId, requester) => {
|
|
348
|
+
try {
|
|
349
|
+
const accountId = clawChatSessionAccountId(sessionKey);
|
|
350
|
+
if (!accountId || !requester?.senderId || (requester.channel && requester.channel !== CHANNEL_ID))
|
|
351
|
+
return false;
|
|
352
|
+
const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
|
|
353
|
+
const ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
|
|
354
|
+
: resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
|
|
355
|
+
return ownerOverlapDowngraded({ toolName: "clawchat_tool_request", params: {}, sessionKey, runId,
|
|
356
|
+
senderId: requester.senderId, ownerUserId: ownerUserId ?? undefined,
|
|
357
|
+
ownerDirectConversationId: lookupOwnerConversation(accountId), registeredTools: new Set() });
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
325
363
|
api.on("before_tool_call", (event, sdkCtx) => {
|
|
326
364
|
// `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
|
|
327
365
|
const ctx = sdkCtx;
|
|
@@ -339,6 +377,8 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
339
377
|
}
|
|
340
378
|
if (event.toolName === "clawchat_tool_request") {
|
|
341
379
|
const origin = sessionKey ? requestOriginForRun(sessionKey, runId) : undefined;
|
|
380
|
+
if (!origin && sessionKey && ownerRequestDowngraded(sessionKey, runId, ctx.requester))
|
|
381
|
+
return ownerDowngradedDecision();
|
|
342
382
|
if (!origin || !sessionKey || !ctx.requester?.senderId
|
|
343
383
|
|| (ctx.requester.channel && ctx.requester.channel !== CHANNEL_ID)
|
|
344
384
|
|| (ctx.requester.accountId && normalizeOpenclawClawlingAccountId(ctx.requester.accountId) !== origin.accountId)
|
|
@@ -445,14 +485,12 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
445
485
|
const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
|
|
446
486
|
nonOwnerToolAllowlist(account.publicTools, api.logger);
|
|
447
487
|
let ownerUserId = null;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
: resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
|
|
452
|
-
}
|
|
453
|
-
catch { /* A missing owner identity cannot authorize grant tools. */ }
|
|
488
|
+
try {
|
|
489
|
+
ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
|
|
490
|
+
: resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
|
|
454
491
|
}
|
|
455
|
-
|
|
492
|
+
catch { /* Missing identity cannot authorize management or identify an owner downgrade. */ }
|
|
493
|
+
const input = {
|
|
456
494
|
toolName: event.toolName,
|
|
457
495
|
params: event.params ?? {},
|
|
458
496
|
sessionKey,
|
|
@@ -467,7 +505,8 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
467
505
|
ownerUserId: ownerUserId ?? undefined,
|
|
468
506
|
senderId: requester?.senderId,
|
|
469
507
|
approvalRoute: approvalRoute(accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
|
|
470
|
-
}
|
|
508
|
+
};
|
|
509
|
+
return decideHostToolCall(input);
|
|
471
510
|
}
|
|
472
511
|
catch {
|
|
473
512
|
warn("handler-exception");
|
|
@@ -475,8 +514,18 @@ export function registerHostToolsGuard(api, options = {}) {
|
|
|
475
514
|
}
|
|
476
515
|
})();
|
|
477
516
|
const result = decision && "block" in decision && OWNER_ONLY_TOOLS.includes(canonicalToolName(event.toolName))
|
|
478
|
-
&& decision.reasonCode !== "owner-turn-only"
|
|
517
|
+
&& decision.reasonCode !== "owner-turn-only" && decision.reasonCode !== "owner-downgraded"
|
|
479
518
|
? { block: true, reasonCode: "owner-turn-only", blockReason: OWNER_TURN_STRICT_TOOLS.includes(canonicalToolName(event.toolName)) ? WIDGET_OWNER_ONLY_MESSAGE : ownerOnlyMessage(false) } : decision;
|
|
519
|
+
if (result && "block" in result && result.reasonCode === "owner-downgraded" && ctx?.sessionKey) {
|
|
520
|
+
const runId = ctx.runId ?? event.runId;
|
|
521
|
+
const key = JSON.stringify([ctx.sessionKey.trim().toLowerCase(), runId]);
|
|
522
|
+
if (!loggedOwnerDowngrades.has(key)) {
|
|
523
|
+
if (loggedOwnerDowngrades.size >= 256)
|
|
524
|
+
loggedOwnerDowngrades.clear();
|
|
525
|
+
loggedOwnerDowngrades.add(key);
|
|
526
|
+
api.logger.info(`owner_downgrade reason=overlap trigger=${ownerDowngradeTrigger(ctx.sessionKey)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
480
529
|
recordTurnArtifactCall(event, ctx, result === undefined || "requireApproval" in result);
|
|
481
530
|
return result;
|
|
482
531
|
});
|
package/dist/src/liveware-cli.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
4
5
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
5
6
|
import { normalizeOpenclawClawlingAccountId } from "./config.js";
|
|
6
7
|
export const LIVEWARE_BASE_URL = "https://media.clawling.chat/liveware/";
|
|
8
|
+
// The trust anchor is this package, not the download host. Updating the CLI
|
|
9
|
+
// requires updating these hashes in a plugin release.
|
|
10
|
+
export const LIVEWARE_CLI_PIN = {
|
|
11
|
+
build: "2026-09-21",
|
|
12
|
+
sha256: {
|
|
13
|
+
"liveware-darwin-amd64": "28c63d371115889cd8ebeed916af0d5ede62e10016ae34a8984de31ec49d00fe",
|
|
14
|
+
"liveware-darwin-arm64": "eebc9f9a881f9a4da3eb81559290581f0054d15b94eb8c0d2dd21e9472169765",
|
|
15
|
+
"liveware-linux-amd64": "bbbf7c0b74e5633cf869ea3af78997d5ec0f57f3253f1fb3c3cdd75ead5cebba",
|
|
16
|
+
"liveware-linux-arm64": "691b79a8f346e10780e8270d9b759bd749d1df5a3d08bf033c66765c470d6a15",
|
|
17
|
+
"liveware-windows-amd64.exe": "b5fe6fa02cf23d0101af20cc3f8c936ac5d6f32a13896bc1c14622089e3504a1",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
7
20
|
// A non-default User-Agent is required: the CDN (Cloudflare) returns 403 for
|
|
8
21
|
// known scripting/default UAs, so download requests must identify themselves.
|
|
9
22
|
const LIVEWARE_USER_AGENT = "clawchat-liveware-installer";
|
|
@@ -92,7 +105,7 @@ export function platformAssets(plat = process.platform, arch = process.arch) {
|
|
|
92
105
|
const cpu = arch === "x64" ? "amd64" : arch === "arm64" ? "arm64" : null;
|
|
93
106
|
if (!os || !cpu)
|
|
94
107
|
return null;
|
|
95
|
-
// Windows
|
|
108
|
+
// Only the Windows amd64 build is supported here, with a .exe suffix.
|
|
96
109
|
if (os === "windows") {
|
|
97
110
|
if (cpu !== "amd64")
|
|
98
111
|
return null;
|
|
@@ -116,59 +129,233 @@ function findOnPath(name) {
|
|
|
116
129
|
}
|
|
117
130
|
return null;
|
|
118
131
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*/
|
|
132
|
+
const managedVerifications = new Map();
|
|
133
|
+
function fileIdentity(stat) {
|
|
134
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
|
|
135
|
+
}
|
|
136
|
+
/** PATH is user-managed; downloaded executables must pass the package pin.
|
|
137
|
+
* Successful hashes are reused only while the full stat identity is unchanged. */
|
|
125
138
|
export function resolveLivewarePath(stateDir) {
|
|
126
139
|
const name = livewareBinaryName();
|
|
127
140
|
if (findOnPath(name))
|
|
128
141
|
return name;
|
|
129
|
-
const
|
|
142
|
+
const assets = platformAssets();
|
|
143
|
+
const digest = assets && LIVEWARE_CLI_PIN.sha256[assets.liveware];
|
|
144
|
+
if (!digest)
|
|
145
|
+
return null;
|
|
146
|
+
const local = path.resolve(livewareDir(stateDir), name);
|
|
130
147
|
try {
|
|
131
148
|
fs.accessSync(local, fs.constants.X_OK);
|
|
149
|
+
const stat = fs.statSync(local);
|
|
150
|
+
if (!stat.isFile())
|
|
151
|
+
return null;
|
|
152
|
+
const cached = managedVerifications.get(local);
|
|
153
|
+
if (cached?.digest === digest && cached.identity === fileIdentity(stat))
|
|
154
|
+
return local;
|
|
155
|
+
managedVerifications.delete(local);
|
|
156
|
+
// Hash an open descriptor so the memo identifies the bytes actually read.
|
|
157
|
+
const file = fs.openSync(local, "r");
|
|
158
|
+
let identity;
|
|
159
|
+
let actualDigest;
|
|
160
|
+
try {
|
|
161
|
+
identity = fileIdentity(fs.fstatSync(file));
|
|
162
|
+
actualDigest = sha256(fs.readFileSync(file));
|
|
163
|
+
if (identity !== fileIdentity(fs.fstatSync(file)))
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
fs.closeSync(file);
|
|
168
|
+
}
|
|
169
|
+
if (actualDigest !== digest || identity !== fileIdentity(fs.statSync(local)))
|
|
170
|
+
return null;
|
|
171
|
+
managedVerifications.set(local, { identity, digest });
|
|
132
172
|
return local;
|
|
133
173
|
}
|
|
134
174
|
catch {
|
|
175
|
+
managedVerifications.delete(local);
|
|
135
176
|
return null;
|
|
136
177
|
}
|
|
137
178
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
179
|
+
class DownloadError extends Error {
|
|
180
|
+
retryable;
|
|
181
|
+
constructor(message, retryable) {
|
|
182
|
+
super(message);
|
|
183
|
+
this.retryable = retryable;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function sha256(bytes) {
|
|
187
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
188
|
+
}
|
|
189
|
+
// Processes can race safely: rename only publishes verified bytes, and failure
|
|
190
|
+
// cleanup re-verifies the file it actually quarantined rather than an old read.
|
|
191
|
+
const livewareInstalls = new Map();
|
|
192
|
+
async function syncDirectory(dir) {
|
|
193
|
+
try {
|
|
194
|
+
const file = await fs.promises.open(dir, "r");
|
|
195
|
+
try {
|
|
196
|
+
await file.sync();
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await file.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch { /* Best effort directory durability across platforms/filesystems. */ }
|
|
203
|
+
}
|
|
204
|
+
async function readManagedFile(target) {
|
|
205
|
+
let file;
|
|
206
|
+
try {
|
|
207
|
+
file = await fs.promises.open(target, "r");
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err.code === "ENOENT")
|
|
211
|
+
return null;
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const before = fileIdentity(await file.stat());
|
|
216
|
+
const bytes = await file.readFile();
|
|
217
|
+
const after = fileIdentity(await file.stat());
|
|
218
|
+
return { identity: after, digest: before === after ? sha256(bytes) : null };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await file.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function quarantineRejectedCache(target, digest) {
|
|
225
|
+
// Each attempt takes a fresh hash/identity, including after a failed download.
|
|
226
|
+
// A changed target is re-verified rather than moved using a stale decision.
|
|
227
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
228
|
+
const snapshot = await readManagedFile(target);
|
|
229
|
+
if (!snapshot || snapshot.digest === digest)
|
|
230
|
+
return;
|
|
231
|
+
if (snapshot.digest === null)
|
|
232
|
+
continue;
|
|
233
|
+
const rejected = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.rejected`);
|
|
234
|
+
try {
|
|
235
|
+
if (fileIdentity(await fs.promises.lstat(target)) !== snapshot.identity)
|
|
236
|
+
continue;
|
|
237
|
+
await fs.promises.rename(target, rejected);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (err.code === "ENOENT")
|
|
241
|
+
return;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
// Recheck what moved: publication can still race the final lstat/rename.
|
|
246
|
+
if ((await readManagedFile(rejected))?.digest === digest) {
|
|
247
|
+
try {
|
|
248
|
+
await fs.promises.link(rejected, target);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (err.code !== "EEXIST")
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await fs.promises.unlink(rejected);
|
|
258
|
+
await syncDirectory(path.dirname(target));
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw new Error("liveware CLI cache kept changing during quarantine; skipping removal");
|
|
263
|
+
}
|
|
264
|
+
async function downloadTo(url, dest, digest, fetchImpl, timeoutMs) {
|
|
265
|
+
const tmp = `${dest}.${randomUUID()}.tmp`;
|
|
266
|
+
try {
|
|
267
|
+
// The signal remains active during arrayBuffer(), covering the entire body.
|
|
268
|
+
let buf;
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetchImpl(url, {
|
|
271
|
+
headers: { "user-agent": LIVEWARE_USER_AGENT }, signal: AbortSignal.timeout(timeoutMs),
|
|
272
|
+
});
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
await res.body?.cancel().catch(() => { });
|
|
275
|
+
throw new DownloadError(`liveware download HTTP ${res.status}`, res.status === 429 || (res.status >= 500 && res.status < 600));
|
|
276
|
+
}
|
|
277
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (err instanceof DownloadError)
|
|
281
|
+
throw err;
|
|
282
|
+
throw new DownloadError(`liveware download request failed: ${String(err)}`, true);
|
|
283
|
+
}
|
|
284
|
+
if (sha256(buf) !== digest)
|
|
285
|
+
throw new DownloadError("liveware download SHA-256 mismatch", false);
|
|
286
|
+
const file = await fs.promises.open(tmp, "wx", 0o600);
|
|
287
|
+
try {
|
|
288
|
+
await file.writeFile(buf);
|
|
289
|
+
await file.chmod(0o755);
|
|
290
|
+
await file.sync();
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await file.close();
|
|
294
|
+
}
|
|
295
|
+
await fs.promises.rename(tmp, dest);
|
|
296
|
+
await syncDirectory(path.dirname(dest));
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await fs.promises.rm(tmp, { force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function installLivewareCli(target, asset, digest, opts) {
|
|
303
|
+
const dir = path.dirname(target);
|
|
304
|
+
const isVerified = async () => (await readManagedFile(target))?.digest === digest;
|
|
305
|
+
if (await isVerified())
|
|
306
|
+
return;
|
|
307
|
+
// Quarantine only the unchanged file we just hashed as invalid. Execution
|
|
308
|
+
// independently verifies the managed copy, even if cleanup is unavailable.
|
|
309
|
+
await quarantineRejectedCache(target, digest);
|
|
310
|
+
if (await isVerified())
|
|
311
|
+
return;
|
|
312
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
313
|
+
const delay = opts.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
314
|
+
try {
|
|
315
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
316
|
+
try {
|
|
317
|
+
await downloadTo(LIVEWARE_BASE_URL + asset, target, digest, opts.fetchImpl ?? fetch, opts.timeoutMs ?? 120_000);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
if (attempt === 1 || !(err instanceof DownloadError) || !err.retryable)
|
|
322
|
+
throw err;
|
|
323
|
+
await delay(2_000);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
await quarantineRejectedCache(target, digest);
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
opts.log?.debug?.(`clawchat-plugin-openclaw: liveware CLI downloaded to ${dir}`);
|
|
149
332
|
}
|
|
150
333
|
/**
|
|
151
|
-
* Startup
|
|
152
|
-
*
|
|
334
|
+
* Startup verifies the managed cache; per-tool resolution memoizes verified file identities.
|
|
335
|
+
* User-managed PATH executables take precedence and are not hashed.
|
|
336
|
+
* Never throws: a failed install is logged and skipped.
|
|
153
337
|
*/
|
|
154
338
|
export async function ensureLivewareCli(opts = {}) {
|
|
155
339
|
const { stateDir, log } = opts;
|
|
156
340
|
try {
|
|
157
|
-
const
|
|
341
|
+
const platform = opts.platform ?? process.platform;
|
|
342
|
+
const name = livewareBinaryName(platform);
|
|
158
343
|
if (findOnPath(name))
|
|
159
|
-
return; // PATH wins
|
|
160
|
-
const dir = livewareDir(stateDir);
|
|
161
|
-
const target = path.join(dir, name);
|
|
162
|
-
if (fs.existsSync(target))
|
|
163
344
|
return;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
345
|
+
const target = path.resolve(livewareDir(stateDir), name);
|
|
346
|
+
const assets = platformAssets(platform, opts.arch ?? process.arch);
|
|
347
|
+
const digest = assets && LIVEWARE_CLI_PIN.sha256[assets.liveware];
|
|
348
|
+
if (!assets || !digest) {
|
|
349
|
+
log?.warn?.(`clawchat-plugin-openclaw: unsupported or unpinned platform ${platform}/${opts.arch ?? process.arch}; skipping liveware download`);
|
|
167
350
|
return;
|
|
168
351
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
352
|
+
let install = livewareInstalls.get(target);
|
|
353
|
+
if (!install) {
|
|
354
|
+
install = installLivewareCli(target, assets.liveware, digest, opts)
|
|
355
|
+
.finally(() => { livewareInstalls.delete(target); });
|
|
356
|
+
livewareInstalls.set(target, install);
|
|
357
|
+
}
|
|
358
|
+
await install;
|
|
172
359
|
}
|
|
173
360
|
catch (err) {
|
|
174
361
|
log?.warn?.(`clawchat-plugin-openclaw: liveware CLI download skipped: ${String(err)}`);
|
|
@@ -690,7 +690,7 @@ export function loadIntroText(appDir, language) {
|
|
|
690
690
|
const DEFAULT_SAMPLE_PORT = 43110;
|
|
691
691
|
const RESTART_WINDOW_MS = 30 * 60 * 1000;
|
|
692
692
|
const MAX_RESTARTS_PER_WINDOW = 5;
|
|
693
|
-
const
|
|
693
|
+
const INTRO_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000];
|
|
694
694
|
const INTRO_MAX_TRIES = 20;
|
|
695
695
|
// Backoff for transient start() failures (template CDN 429s, network blips):
|
|
696
696
|
// a one-shot bootstrap would otherwise stay dormant until the next restart.
|
|
@@ -1130,8 +1130,8 @@ export class LivewareSampleSupervisor {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
}
|
|
1132
1132
|
/** Deliver the intro chat message; retries while the WS client is not ready. */
|
|
1133
|
-
async deliverIntro(tryIndex = 0) {
|
|
1134
|
-
if (this.stopped)
|
|
1133
|
+
async deliverIntro(tryIndex = 0, generation = this.generation) {
|
|
1134
|
+
if (this.stopped || generation !== this.generation)
|
|
1135
1135
|
return;
|
|
1136
1136
|
const { deps } = this;
|
|
1137
1137
|
let delivered = false;
|
|
@@ -1142,6 +1142,8 @@ export class LivewareSampleSupervisor {
|
|
|
1142
1142
|
catch (err) {
|
|
1143
1143
|
deps.log?.debug?.(`liveware-sample: intro send error: ${String(err)}`);
|
|
1144
1144
|
}
|
|
1145
|
+
if (this.stopped || generation !== this.generation)
|
|
1146
|
+
return;
|
|
1145
1147
|
if (delivered) {
|
|
1146
1148
|
deps.store.markLivewareSampleIntroSent({ platform: deps.platform, accountId: deps.accountId });
|
|
1147
1149
|
return;
|
|
@@ -1150,6 +1152,6 @@ export class LivewareSampleSupervisor {
|
|
|
1150
1152
|
deps.log?.debug?.("liveware-sample: intro not delivered; will retry next boot");
|
|
1151
1153
|
return;
|
|
1152
1154
|
}
|
|
1153
|
-
this.schedule(() => { void this.deliverIntro(tryIndex + 1); },
|
|
1155
|
+
this.schedule(() => { void this.deliverIntro(tryIndex + 1, generation); }, INTRO_RETRY_DELAYS_MS[Math.min(tryIndex, INTRO_RETRY_DELAYS_MS.length - 1)]);
|
|
1154
1156
|
}
|
|
1155
1157
|
}
|