@clawling/clawchat-plugin-openclaw 2026.7.2 → 2026.7.6-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/activation-greeting.js +34 -0
- package/dist/src/api-client.js +1 -0
- package/dist/src/config.js +1 -1
- package/dist/src/plugin-report.js +1 -0
- package/dist/src/runtime.js +99 -33
- package/dist/src/skill-update.js +139 -69
- package/package.json +1 -1
- package/src/activation-greeting.ts +35 -0
- package/src/api-client.ts +2 -0
- package/src/config.ts +1 -1
- package/src/plugin-report.ts +2 -0
- package/src/runtime.ts +103 -34
- package/src/skill-update.ts +170 -85
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// Worded to make the greeting the single required action. Earlier wording
|
|
5
|
+
// ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
|
|
6
|
+
// it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
|
|
7
|
+
// no greeting reached the user. Lead with the chat reply, forbid file/tool
|
|
8
|
+
// detours, and keep the profile update strictly optional and secondary.
|
|
9
|
+
const ACTIVATION_BOOTSTRAP_FALLBACK = [
|
|
10
|
+
"You are now connected to a ClawChat direct conversation with your user.",
|
|
11
|
+
"Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
|
|
12
|
+
"Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
|
|
13
|
+
"Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
|
|
14
|
+
"Do not ask the user for profile information.",
|
|
15
|
+
].join("\n");
|
|
16
|
+
// Cross-plugin, user-editable override read lazily so edits apply on the next
|
|
17
|
+
// first-load without a restart. Any read failure falls back to the built-in text.
|
|
18
|
+
export function buildActivationBootstrapText(homeDir = os.homedir()) {
|
|
19
|
+
const greetingPath = path.join(homeDir, "clawchat", "greeting.md");
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(greetingPath);
|
|
22
|
+
const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
|
|
23
|
+
if (override.length > 0) {
|
|
24
|
+
return override;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error.code;
|
|
29
|
+
if (code !== "ENOENT") {
|
|
30
|
+
console.warn(`clawchat.greeting failed to read override ${greetingPath}:`, error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ACTIVATION_BOOTSTRAP_FALLBACK;
|
|
34
|
+
}
|
package/dist/src/api-client.js
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -216,7 +216,7 @@ function decodeJwtStringClaim(token, claim) {
|
|
|
216
216
|
* userId that disagrees with `sub` is always wrong for the session this token
|
|
217
217
|
* creates, and it silently breaks self-echo detection: with the OWNER's id
|
|
218
218
|
* here, every owner message is dropped as the bot's own echo (2026-07-02
|
|
219
|
-
*
|
|
219
|
+
* production incident — a provisioner wrote the owner id into the config). Token
|
|
220
220
|
* wins; the mismatch is logged. Also derives userId from `sub` when nothing is
|
|
221
221
|
* configured, mirroring the `oid` fallback above.
|
|
222
222
|
*/
|
|
@@ -53,6 +53,7 @@ export async function reportPluginVersionSafe(p) {
|
|
|
53
53
|
deviceId: p.deviceId,
|
|
54
54
|
platform: "openclaw",
|
|
55
55
|
pluginVersion: p.pluginVersion,
|
|
56
|
+
agentVersion: p.agentVersion,
|
|
56
57
|
runtimeName: "node",
|
|
57
58
|
runtimeVersion: process.version,
|
|
58
59
|
}, { authenticated: p.authenticated });
|
package/dist/src/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
|
4
4
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
5
5
|
import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
|
|
6
6
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
7
|
+
import { buildActivationBootstrapText } from "./activation-greeting.js";
|
|
7
8
|
import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
|
|
8
9
|
import { ensureLivewareCli } from "./liveware-cli.js";
|
|
9
10
|
import { ClawlingApiError } from "./api-types.js";
|
|
@@ -11,7 +12,7 @@ import { RefreshManager } from "./refresh-manager.js";
|
|
|
11
12
|
import { runOpenclawClawlingLogin, } from "./login.runtime.js";
|
|
12
13
|
import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
13
14
|
import { dispatchOpenclawClawlingInbound } from "./inbound.js";
|
|
14
|
-
import { PendingConsentStore, handleOwnerConsentReply,
|
|
15
|
+
import { PendingConsentStore, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
|
|
15
16
|
import { fetchInboundMedia } from "./media-runtime.js";
|
|
16
17
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
|
|
17
18
|
import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
|
|
@@ -309,20 +310,6 @@ function withClawChatSessionScope(cfg) {
|
|
|
309
310
|
},
|
|
310
311
|
};
|
|
311
312
|
}
|
|
312
|
-
function buildActivationBootstrapText() {
|
|
313
|
-
// Worded to make the greeting the single required action. Earlier wording
|
|
314
|
-
// ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
|
|
315
|
-
// it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
|
|
316
|
-
// no greeting reached the user. Lead with the chat reply, forbid file/tool
|
|
317
|
-
// detours, and keep the profile update strictly optional and secondary.
|
|
318
|
-
return [
|
|
319
|
-
"You are now connected to a ClawChat direct conversation with your user.",
|
|
320
|
-
"Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
|
|
321
|
-
"Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
|
|
322
|
-
"Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
|
|
323
|
-
"Do not ask the user for profile information.",
|
|
324
|
-
].join("\n");
|
|
325
|
-
}
|
|
326
313
|
function buildActivationBootstrapEnvelope(params) {
|
|
327
314
|
const text = buildActivationBootstrapText();
|
|
328
315
|
const now = Date.now();
|
|
@@ -574,6 +561,26 @@ function waitForActivationPoll(abortSignal, ms) {
|
|
|
574
561
|
async function waitForActivationCredentials(params) {
|
|
575
562
|
const { abortSignal, getStatus, setStatus, store, log } = params;
|
|
576
563
|
const accountId = params.account.accountId;
|
|
564
|
+
// SQLite-first: activation and token rotation persist to the activations
|
|
565
|
+
// row, so a complete row is the durable credential source of truth even
|
|
566
|
+
// when channel config/env also carries credentials. Config/env credentials
|
|
567
|
+
// only bootstrap the no-row case (the caller seeds a row from them).
|
|
568
|
+
if (store?.getActivationCredentials) {
|
|
569
|
+
const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
|
|
570
|
+
if (stored && stored.accessToken !== params.rejectedActivationToken) {
|
|
571
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
|
|
572
|
+
return {
|
|
573
|
+
source: "sqlite",
|
|
574
|
+
account: {
|
|
575
|
+
...params.account,
|
|
576
|
+
configured: true,
|
|
577
|
+
token: stored.accessToken,
|
|
578
|
+
userId: stored.userId,
|
|
579
|
+
ownerUserId: stored.ownerUserId,
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
577
584
|
if (accountHasConnectCredentials(params.account)) {
|
|
578
585
|
return { account: params.account, source: "configured" };
|
|
579
586
|
}
|
|
@@ -647,12 +654,14 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
647
654
|
// so the backend links both to the same row. Imported from ./client.ts.
|
|
648
655
|
const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
|
|
649
656
|
const pluginVersion = resolvePluginVersion();
|
|
657
|
+
const agentVersion = runtime.version ?? "";
|
|
650
658
|
void reportPluginVersionSafe({
|
|
651
659
|
baseUrl: account.baseUrl,
|
|
652
660
|
mediaBaseUrl: account.mediaBaseUrl,
|
|
653
661
|
token: "",
|
|
654
662
|
deviceId: reportDeviceId,
|
|
655
663
|
pluginVersion,
|
|
664
|
+
agentVersion,
|
|
656
665
|
authenticated: false,
|
|
657
666
|
log,
|
|
658
667
|
});
|
|
@@ -693,6 +702,26 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
693
702
|
if (!activationAccount)
|
|
694
703
|
return;
|
|
695
704
|
account = activationAccount.account;
|
|
705
|
+
// Config/env bootstrap seeding: converge on SQLite as the single durable
|
|
706
|
+
// credential store, so token rotation always has a row to update and the
|
|
707
|
+
// next startup resolves SQLite-first to the same credentials.
|
|
708
|
+
if (activationAccount.source === "configured" && store?.upsertActivation) {
|
|
709
|
+
try {
|
|
710
|
+
store.upsertActivation({
|
|
711
|
+
platform: "openclaw",
|
|
712
|
+
accountId,
|
|
713
|
+
userId: account.userId,
|
|
714
|
+
ownerUserId: account.ownerUserId,
|
|
715
|
+
accessToken: account.token,
|
|
716
|
+
refreshToken: readConfigRefreshToken(cfg),
|
|
717
|
+
loginMethod: "config-seed",
|
|
718
|
+
deviceId: CHANNEL_ID,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
696
725
|
// Paired: link the report row via the authenticated endpoint, reusing the
|
|
697
726
|
// SAME frozen device_id so the backend upserts the existing unpaired row.
|
|
698
727
|
void reportPluginVersionSafe({
|
|
@@ -701,12 +730,15 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
701
730
|
token: account.token,
|
|
702
731
|
deviceId: reportDeviceId,
|
|
703
732
|
pluginVersion,
|
|
733
|
+
agentVersion,
|
|
704
734
|
authenticated: true,
|
|
705
735
|
log,
|
|
706
736
|
});
|
|
707
|
-
// §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null
|
|
708
|
-
//
|
|
709
|
-
//
|
|
737
|
+
// §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
|
|
738
|
+
// configured start even though the seed block above may have just created a
|
|
739
|
+
// row — this cell only ever queries `store.getActivationCredentials` when
|
|
740
|
+
// `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
|
|
741
|
+
// alone until its next SQLite-first resolution.
|
|
710
742
|
let activatedAtMs = activationAccount.source === "sqlite" && store?.getActivationCredentials
|
|
711
743
|
? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
|
|
712
744
|
: null;
|
|
@@ -872,16 +904,16 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
872
904
|
getAccessToken: () => account.token,
|
|
873
905
|
getRefreshToken: () => latestRefreshToken,
|
|
874
906
|
persistRotatedTokens: async (tokens) => {
|
|
875
|
-
// §0 — persist
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
// failure
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
907
|
+
// §0 — persist durably BEFORE the in-memory swap. SQLite is the
|
|
908
|
+
// startup credential source of truth: when a store is available its
|
|
909
|
+
// write must succeed or the refresh stays transient (no swap, keep
|
|
910
|
+
// current tokens, back off). A 0-row UPDATE (`false`) means the
|
|
911
|
+
// activations row is missing (e.g. cleared out-of-band) rather than a
|
|
912
|
+
// write failure — self-heal by re-seeding it from the in-memory
|
|
913
|
+
// identity with the ROTATED pair (mirrors Hermes's seed-on-rotate
|
|
914
|
+
// §C.2 semantics), then continue exactly as on success. `null` is a
|
|
915
|
+
// caught write exception and stays transient — that's a real failure,
|
|
916
|
+
// not a missing row.
|
|
885
917
|
if (store?.rotateActivationTokens) {
|
|
886
918
|
const rotateResult = store.rotateActivationTokens({
|
|
887
919
|
platform: "openclaw",
|
|
@@ -889,12 +921,42 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
889
921
|
accessToken: tokens.accessToken,
|
|
890
922
|
refreshToken: tokens.refreshToken,
|
|
891
923
|
});
|
|
892
|
-
if (rotateResult ===
|
|
924
|
+
if (rotateResult === false) {
|
|
925
|
+
if (!store.upsertActivation) {
|
|
926
|
+
throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
store.upsertActivation({
|
|
930
|
+
platform: "openclaw",
|
|
931
|
+
accountId,
|
|
932
|
+
userId: account.userId,
|
|
933
|
+
ownerUserId: account.ownerUserId,
|
|
934
|
+
accessToken: tokens.accessToken,
|
|
935
|
+
refreshToken: tokens.refreshToken,
|
|
936
|
+
loginMethod: "config-seed",
|
|
937
|
+
deviceId: refreshDeviceId,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
else if (rotateResult === null) {
|
|
893
945
|
throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
|
|
894
946
|
}
|
|
947
|
+
// Channel config is a human-readable mirror once SQLite holds the
|
|
948
|
+
// pair — sync best-effort, never fail the refresh over it.
|
|
949
|
+
try {
|
|
950
|
+
await persistConfigTokens(tokens);
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`);
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
895
956
|
}
|
|
896
|
-
//
|
|
897
|
-
//
|
|
957
|
+
// No SQLite store available — channel config is the only durable
|
|
958
|
+
// store, so its write failure must reject and keep the refresh
|
|
959
|
+
// transient (pre-existing behavior for store-less test transports).
|
|
898
960
|
await persistConfigTokens(tokens);
|
|
899
961
|
},
|
|
900
962
|
swapInMemoryTokens: (tokens) => {
|
|
@@ -1762,6 +1824,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1762
1824
|
// The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
|
|
1763
1825
|
// "current installed version" only — never written (it lives in node_modules
|
|
1764
1826
|
// → may be read-only and is clobbered on `openclaw plugins update`).
|
|
1827
|
+
// Update detection converges on raw sha256 (readLocalSkillState), not the
|
|
1828
|
+
// frontmatter version, so a same-version content fix still applies; a
|
|
1829
|
+
// manifest `removed` id tombstones the LOCAL managed copy (never bundled)
|
|
1830
|
+
// via removeManagedSkill, deleting only ids the official manifest names.
|
|
1765
1831
|
const skillUpdateConsent = new PendingConsentStore();
|
|
1766
1832
|
const managedSkillsDir = resolveManagedSkillsDir();
|
|
1767
1833
|
let bundledSkillsDir = null;
|
|
@@ -1805,10 +1871,10 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1805
1871
|
return;
|
|
1806
1872
|
}
|
|
1807
1873
|
// Effective current = managed copy if present, else bundled (host precedence).
|
|
1808
|
-
const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
|
|
1809
1874
|
await runSkillUpdateCheck({
|
|
1810
1875
|
ownerUserId,
|
|
1811
|
-
|
|
1876
|
+
readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
|
|
1877
|
+
hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
|
|
1812
1878
|
fetchFn: globalThis.fetch,
|
|
1813
1879
|
store: skillUpdateConsent,
|
|
1814
1880
|
sendOwnerMessage: sendOwnerSkillMessage,
|
package/dist/src/skill-update.js
CHANGED
|
@@ -29,8 +29,12 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
* implementation in `@clawling/clawchat-plugin-install-cli`
|
|
30
30
|
* (`packages/core/src/skills/check-update.ts`). That package is
|
|
31
31
|
* workspace-private, so the logic is duplicated here rather than imported.
|
|
32
|
-
* -
|
|
33
|
-
* (
|
|
32
|
+
* - Update detection converges on raw sha256 comparison: ANY byte difference
|
|
33
|
+
* from the official source (or a missing local file) is an update —
|
|
34
|
+
* upgrade, rollback, or same-version content fix alike. The strict semver
|
|
35
|
+
* compare (`parseComparableVersion` / `isVersionOlder`, still ported from
|
|
36
|
+
* the reference `parseComparableVersion`, `X.Y[.Z[.W]][-buildnum]`) is kept
|
|
37
|
+
* only for consent-prompt display wording, not for the `hasUpdate` decision.
|
|
34
38
|
* - All network IO goes through an injected `FetchLike`; tests never touch the
|
|
35
39
|
* real network.
|
|
36
40
|
*
|
|
@@ -151,7 +155,25 @@ export function parseSkillsManifest(text) {
|
|
|
151
155
|
skills[target][skillId] = asEntry(entry, `${target}.${skillId}`);
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
|
-
|
|
158
|
+
const removed = {};
|
|
159
|
+
if (data.removed !== undefined) {
|
|
160
|
+
if (!data.removed || typeof data.removed !== "object" || Array.isArray(data.removed)) {
|
|
161
|
+
throw new Error("skills manifest `removed` must be an object");
|
|
162
|
+
}
|
|
163
|
+
for (const [target, ids] of Object.entries(data.removed)) {
|
|
164
|
+
if (!Array.isArray(ids) || !ids.every((i) => typeof i === "string" && i.trim())) {
|
|
165
|
+
throw new Error(`skills manifest removed[${target}] must be a list of skill ids`);
|
|
166
|
+
}
|
|
167
|
+
const cleaned = ids.map((i) => i.trim());
|
|
168
|
+
for (const id of cleaned) {
|
|
169
|
+
if (skills[target]?.[id]) {
|
|
170
|
+
throw new Error(`skill ${target}.${id} is in both skills and removed`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
removed[target] = cleaned;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { schema: 1, skills, removed };
|
|
155
177
|
}
|
|
156
178
|
async function fetchText(url, fetchFn) {
|
|
157
179
|
let response;
|
|
@@ -167,9 +189,10 @@ async function fetchText(url, fetchFn) {
|
|
|
167
189
|
return response.text();
|
|
168
190
|
}
|
|
169
191
|
/**
|
|
170
|
-
* Read the official manifest for the `openclaw` target and compare each
|
|
171
|
-
* offered
|
|
172
|
-
*
|
|
192
|
+
* Read the official manifest for the `openclaw` target and compare each
|
|
193
|
+
* skill's offered content (sha256) against its effective local state, read
|
|
194
|
+
* on demand via `readLocalState`. Any sha256 mismatch — or no local state at
|
|
195
|
+
* all — is reported as `hasUpdate: true`.
|
|
173
196
|
*/
|
|
174
197
|
export async function checkSkillUpdate(options) {
|
|
175
198
|
const ref = options.ref ?? DEFAULT_SKILLS_REF;
|
|
@@ -181,11 +204,14 @@ export async function checkSkillUpdate(options) {
|
|
|
181
204
|
}
|
|
182
205
|
const results = [];
|
|
183
206
|
for (const [skillId, entry] of Object.entries(targetSkills)) {
|
|
184
|
-
const
|
|
185
|
-
|
|
207
|
+
const state = await options.readLocalState(skillId);
|
|
208
|
+
// sha convergence: any byte difference from the official source (or a
|
|
209
|
+
// missing local file) is an update — upgrade, rollback, or same-version
|
|
210
|
+
// content fix alike.
|
|
211
|
+
const hasUpdate = !state || state.sha256 !== entry.sha256;
|
|
186
212
|
results.push({
|
|
187
213
|
skillId,
|
|
188
|
-
current,
|
|
214
|
+
current: state?.version ?? null,
|
|
189
215
|
latest: entry.version,
|
|
190
216
|
hasUpdate,
|
|
191
217
|
path: entry.path,
|
|
@@ -193,7 +219,12 @@ export async function checkSkillUpdate(options) {
|
|
|
193
219
|
bytes: entry.bytes,
|
|
194
220
|
});
|
|
195
221
|
}
|
|
196
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
ref,
|
|
224
|
+
results,
|
|
225
|
+
hasUpdate: results.some((r) => r.hasUpdate),
|
|
226
|
+
removedIds: manifest.removed[SKILL_TARGET] ?? [],
|
|
227
|
+
};
|
|
197
228
|
}
|
|
198
229
|
/**
|
|
199
230
|
* Download one skill markdown file and integrity-check it against the manifest
|
|
@@ -230,52 +261,41 @@ export function parseSkillFrontmatterVersion(markdown) {
|
|
|
230
261
|
const raw = (versionLine[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
231
262
|
return raw || null;
|
|
232
263
|
}
|
|
233
|
-
|
|
234
|
-
async function readSingleSkillVersion(dir, skillId) {
|
|
264
|
+
async function readSkillStateFromDir(dir, skillId) {
|
|
235
265
|
try {
|
|
236
|
-
const
|
|
237
|
-
return
|
|
266
|
+
const buf = await fs.readFile(path.join(dir, skillId, "SKILL.md"));
|
|
267
|
+
return {
|
|
268
|
+
version: parseSkillFrontmatterVersion(buf.toString("utf8")),
|
|
269
|
+
sha256: crypto.createHash("sha256").update(buf).digest("hex"),
|
|
270
|
+
};
|
|
238
271
|
}
|
|
239
272
|
catch {
|
|
240
|
-
return null;
|
|
273
|
+
return null;
|
|
241
274
|
}
|
|
242
275
|
}
|
|
243
276
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
277
|
+
* Effective local state of ONE skill, mirroring the host's managed-over-bundled
|
|
278
|
+
* precedence: managed copy if present, else bundled, else null. Called per
|
|
279
|
+
* remote manifest entry so dynamically delivered extra skills are hashed too
|
|
280
|
+
* (a static bundled-id list would misreport extras as never-installed).
|
|
247
281
|
*/
|
|
248
|
-
export async function
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return versions;
|
|
282
|
+
export async function readLocalSkillState(managedDir, bundledDir, skillId) {
|
|
283
|
+
const managed = await readSkillStateFromDir(managedDir, skillId);
|
|
284
|
+
if (managed)
|
|
285
|
+
return managed;
|
|
286
|
+
if (bundledDir)
|
|
287
|
+
return readSkillStateFromDir(bundledDir, skillId);
|
|
288
|
+
return null;
|
|
256
289
|
}
|
|
257
|
-
/**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const versions = {};
|
|
266
|
-
for (const skillId of skillIds) {
|
|
267
|
-
const managed = await readSingleSkillVersion(managedDir, skillId);
|
|
268
|
-
if (managed) {
|
|
269
|
-
versions[skillId] = managed;
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
if (bundledDir) {
|
|
273
|
-
const bundled = await readSingleSkillVersion(bundledDir, skillId);
|
|
274
|
-
if (bundled)
|
|
275
|
-
versions[skillId] = bundled;
|
|
276
|
-
}
|
|
290
|
+
/** True when `<managedDir>/<skillId>/SKILL.md` exists (removal precondition). */
|
|
291
|
+
export async function managedSkillExists(managedDir, skillId) {
|
|
292
|
+
try {
|
|
293
|
+
await fs.access(path.join(managedDir, skillId, "SKILL.md"));
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return false;
|
|
277
298
|
}
|
|
278
|
-
return versions;
|
|
279
299
|
}
|
|
280
300
|
/**
|
|
281
301
|
* Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
|
|
@@ -359,6 +379,18 @@ export async function atomicWriteSkill(skillsDir, skillId, content) {
|
|
|
359
379
|
throw err;
|
|
360
380
|
}
|
|
361
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
|
|
384
|
+
* (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
|
|
385
|
+
* that drops the registration) and best-effort remove the now-empty dir.
|
|
386
|
+
* Deletion is the ONE legitimate delete of a skill path, and ONLY for ids the
|
|
387
|
+
* official manifest explicitly tombstones. Idempotent.
|
|
388
|
+
*/
|
|
389
|
+
export async function removeManagedSkill(skillsDir, skillId) {
|
|
390
|
+
const dir = path.join(skillsDir, skillId);
|
|
391
|
+
await fs.rm(path.join(dir, "SKILL.md"), { force: true });
|
|
392
|
+
await fs.rmdir(dir).catch(() => { });
|
|
393
|
+
}
|
|
362
394
|
const AFFIRM_TOKENS = new Set([
|
|
363
395
|
"更新",
|
|
364
396
|
"更新吧",
|
|
@@ -438,7 +470,20 @@ export class PendingConsentStore {
|
|
|
438
470
|
}
|
|
439
471
|
}
|
|
440
472
|
function describeUpdate(u) {
|
|
441
|
-
|
|
473
|
+
if (u.current === null)
|
|
474
|
+
return `「${u.skillId}」v无 → v${u.target}`;
|
|
475
|
+
let cmp = -1;
|
|
476
|
+
try {
|
|
477
|
+
cmp = compareVersions(u.current, u.target);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
cmp = -1; // unparsable local version — show as a plain update
|
|
481
|
+
}
|
|
482
|
+
if (cmp === 0)
|
|
483
|
+
return `「${u.skillId}」v${u.target}(内容修订)`;
|
|
484
|
+
if (cmp > 0)
|
|
485
|
+
return `「${u.skillId}」v${u.current} → v${u.target}(回滚)`;
|
|
486
|
+
return `「${u.skillId}」v${u.current} → v${u.target}`;
|
|
442
487
|
}
|
|
443
488
|
/**
|
|
444
489
|
* Step ③–⑤ (plan §2): on `clawchat.skill.update.check`, check the official
|
|
@@ -453,23 +498,25 @@ export async function runSkillUpdateCheck(options) {
|
|
|
453
498
|
return null;
|
|
454
499
|
}
|
|
455
500
|
const outcome = await checkSkillUpdate({
|
|
456
|
-
|
|
501
|
+
readLocalState: options.readLocalState,
|
|
457
502
|
...(options.ref ? { ref: options.ref } : {}),
|
|
458
503
|
...(options.base ? { base: options.base } : {}),
|
|
459
504
|
fetchFn: options.fetchFn,
|
|
460
505
|
});
|
|
461
506
|
const updates = outcome.results
|
|
462
507
|
.filter((r) => r.hasUpdate)
|
|
463
|
-
.map((r) => ({
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
508
|
+
.map((r) => ({ skillId: r.skillId, current: r.current, target: r.latest, path: r.path, sha256: r.sha256, bytes: r.bytes }));
|
|
509
|
+
const removals = [];
|
|
510
|
+
for (const skillId of outcome.removedIds) {
|
|
511
|
+
if (OPENCLAW_SKILL_IDS.includes(skillId)) {
|
|
512
|
+
options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (await options.hasManagedSkill(skillId))
|
|
516
|
+
removals.push({ skillId });
|
|
517
|
+
}
|
|
518
|
+
if (updates.length === 0 && removals.length === 0) {
|
|
519
|
+
options.log?.info?.("clawchat skill-update check: nothing to converge");
|
|
473
520
|
return null;
|
|
474
521
|
}
|
|
475
522
|
const now = (options.now ?? Date.now)();
|
|
@@ -478,13 +525,19 @@ export async function runSkillUpdateCheck(options) {
|
|
|
478
525
|
ref: outcome.ref,
|
|
479
526
|
...(options.base ? { base: options.base } : {}),
|
|
480
527
|
updates,
|
|
528
|
+
removals,
|
|
481
529
|
createdAt: now,
|
|
482
530
|
expiresAt: now + (options.timeoutMs ?? DEFAULT_CONSENT_TIMEOUT_MS),
|
|
483
531
|
};
|
|
484
532
|
options.store.set(record);
|
|
485
|
-
const
|
|
533
|
+
const segments = [];
|
|
534
|
+
if (updates.length > 0)
|
|
535
|
+
segments.push(`我的技能有更新:${updates.map(describeUpdate).join(";")}`);
|
|
536
|
+
if (removals.length > 0)
|
|
537
|
+
segments.push(`以下技能将下线移除:${removals.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
538
|
+
const text = `${segments.join(";")}。回复「更新」确认,「取消」忽略。`;
|
|
486
539
|
await options.sendOwnerMessage(text);
|
|
487
|
-
options.log?.info?.(`clawchat skill-update check: asked owner for consent on ${updates.map((u) => u.skillId).join(",")}`);
|
|
540
|
+
options.log?.info?.(`clawchat skill-update check: asked owner for consent on updates=[${updates.map((u) => u.skillId).join(",")}] removals=[${removals.map((r) => r.skillId).join(",")}]`);
|
|
488
541
|
return record;
|
|
489
542
|
}
|
|
490
543
|
/**
|
|
@@ -516,22 +569,23 @@ export async function handleOwnerConsentReply(options) {
|
|
|
516
569
|
return true;
|
|
517
570
|
}
|
|
518
571
|
// verdict === "affirm"
|
|
519
|
-
const
|
|
572
|
+
const readLocalSha = options.readLocalSha ??
|
|
520
573
|
(async (skillId) => {
|
|
521
574
|
try {
|
|
522
|
-
const
|
|
523
|
-
return
|
|
575
|
+
const buf = await fs.readFile(path.join(options.skillsDir, skillId, "SKILL.md"));
|
|
576
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
524
577
|
}
|
|
525
578
|
catch {
|
|
526
579
|
return null;
|
|
527
580
|
}
|
|
528
581
|
});
|
|
529
582
|
const applied = [];
|
|
583
|
+
const removed = [];
|
|
530
584
|
try {
|
|
531
585
|
for (const update of pending.updates) {
|
|
532
|
-
// Idempotency: skip a skill already
|
|
533
|
-
const onDisk = await
|
|
534
|
-
if (onDisk === update.
|
|
586
|
+
// Idempotency: skip a skill whose on-disk bytes already match the target.
|
|
587
|
+
const onDisk = await readLocalSha(update.skillId);
|
|
588
|
+
if (onDisk === update.sha256) {
|
|
535
589
|
applied.push(update);
|
|
536
590
|
continue;
|
|
537
591
|
}
|
|
@@ -544,6 +598,18 @@ export async function handleOwnerConsentReply(options) {
|
|
|
544
598
|
applied.push(update);
|
|
545
599
|
options.log?.info?.(`clawchat skill-update: applied ${update.skillId} -> v${update.target} (atomic overwrite)`);
|
|
546
600
|
}
|
|
601
|
+
for (const removal of pending.removals) {
|
|
602
|
+
// Defensive re-guard: bundled skills must never be deleted, even though
|
|
603
|
+
// runSkillUpdateCheck already filters them out of removal candidates.
|
|
604
|
+
// Unreachable in the normal flow; kept as a cheap safety net.
|
|
605
|
+
if (OPENCLAW_SKILL_IDS.includes(removal.skillId)) {
|
|
606
|
+
options.log?.info?.(`clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
await removeManagedSkill(options.skillsDir, removal.skillId);
|
|
610
|
+
removed.push(removal);
|
|
611
|
+
options.log?.info?.(`clawchat skill-update: removed tombstoned skill ${removal.skillId}`);
|
|
612
|
+
}
|
|
547
613
|
}
|
|
548
614
|
catch (err) {
|
|
549
615
|
// Keep the pending record so the owner can retry with another "更新"; report
|
|
@@ -553,7 +619,11 @@ export async function handleOwnerConsentReply(options) {
|
|
|
553
619
|
return true;
|
|
554
620
|
}
|
|
555
621
|
options.store.clear();
|
|
556
|
-
const
|
|
557
|
-
|
|
622
|
+
const segments = [];
|
|
623
|
+
if (applied.length > 0)
|
|
624
|
+
segments.push(`已更新到 ${applied.map((u) => `「${u.skillId}」v${u.target}`).join("、")}`);
|
|
625
|
+
if (removed.length > 0)
|
|
626
|
+
segments.push(`已移除 ${removed.map((r) => `「${r.skillId}」`).join("、")}`);
|
|
627
|
+
await options.sendOwnerMessage(`✅ ${segments.join(";")}`);
|
|
558
628
|
return true;
|
|
559
629
|
}
|