diff --git a/.github/workflows/docs-external-links.yml b/.github/workflows/docs-external-links.yml
index 203e85950fbe..59f808a4f96b 100644
--- a/.github/workflows/docs-external-links.yml
+++ b/.github/workflows/docs-external-links.yml
@@ -29,7 +29,7 @@ jobs:
--no-progress
--base-url https://docs.openclaw.ai
--exclude-all-private
- --accept '100..=399,401,403,429'
+ --accept '100..=399,401,403,429,500..=599'
--accept-timeouts
--max-retries 2
--retry-wait-time 2
diff --git a/README.md b/README.md
index 7cfdb4baec22..20c543a23bc3 100644
--- a/README.md
+++ b/README.md
@@ -330,23 +330,23 @@ Thanks to all clawtributors:
-
+
-
+
-
+
-
+
@@ -357,7 +357,7 @@ Thanks to all clawtributors:
-
+
@@ -373,19 +373,19 @@ Thanks to all clawtributors:
-
+
-
+
-
+
diff --git a/docs/providers/deepgram.md b/docs/providers/deepgram.md
index dd53a71eb180..3d050e1a5470 100644
--- a/docs/providers/deepgram.md
+++ b/docs/providers/deepgram.md
@@ -19,7 +19,6 @@ returns them.
| Detail | Value |
| ------------- | ---------------------------------------------------------- |
-| Website | [deepgram.com](https://deepgram.com) |
| Docs | [developers.deepgram.com](https://developers.deepgram.com) |
| Auth | `DEEPGRAM_API_KEY` |
| Default model | `nova-3` |
diff --git a/docs/providers/ollama-cloud.md b/docs/providers/ollama-cloud.md
index 99bbb828b75c..014190fe0861 100644
--- a/docs/providers/ollama-cloud.md
+++ b/docs/providers/ollama-cloud.md
@@ -18,7 +18,7 @@ embeddings, and custom host details, see [Ollama](/providers/ollama).
## Setup
-Create an Ollama Cloud API key at [ollama.com/settings/keys](https://ollama.com/settings/keys), then run:
+Follow [Ollama's API key instructions](https://docs.ollama.com/api/authentication#api-keys), then run:
```bash
openclaw onboard --auth-choice ollama-cloud
diff --git a/docs/start/showcase.md b/docs/start/showcase.md
index e5ad60c81cd8..c7f2fa82d6ad 100644
--- a/docs/start/showcase.md
+++ b/docs/start/showcase.md
@@ -119,7 +119,7 @@ Desktop app to manage skills and commands across Agents, Claude, Codex, and Open
-
+
**Community** • `voice` `tts` `telegram`
Wraps papla.media TTS and sends results as Telegram voice notes (no annoying autoplay).
diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md
index 9385aec00302..80d91dd11bc0 100644
--- a/docs/tools/acp-agents-setup.md
+++ b/docs/tools/acp-agents-setup.md
@@ -32,7 +32,7 @@ Built-in acpx harness aliases (from the pinned `acpx` dependency):
| Alias | Wraps |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| `claude` | [Claude Code](https://claude.ai/code) |
-| `codex` | [Codex CLI](https://codex.openai.com) |
+| `codex` | [Codex CLI](https://developers.openai.com/codex/cli) |
| `copilot` | [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli) |
| `cursor` | [Cursor CLI](https://cursor.com/docs/cli/acp) (`cursor-agent acp`) |
| `droid` | [Factory Droid](https://www.factory.ai) |
diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json
index f04fd4b61a54..9b7cc85a7daa 100644
--- a/scripts/clawtributors-map.json
+++ b/scripts/clawtributors-map.json
@@ -16,7 +16,7 @@
"atalovesyou",
"0xJonHoldsCrypto",
"hougangdev",
- "jiulingyun"
+ "mf-yang"
],
"seedCommit": "d6863f87",
"displayName": {
diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts
index 90e51f515b31..29a43fd57cbd 100644
--- a/scripts/update-clawtributors.ts
+++ b/scripts/update-clawtributors.ts
@@ -48,6 +48,7 @@ for (const item of contributors) {
contributionsByLogin.set(item.login.toLowerCase(), item.contributions);
}
apiByLogin.set(item.login.toLowerCase(), {
+ id: item.id,
login: item.login,
html_url: item.html_url,
avatar_url: normalizeAvatar(item.avatar_url),
@@ -183,16 +184,36 @@ const entriesByKey = new Map();
for (const seed of seedEntries) {
const login =
- loginFromUrl(seed.html_url) ??
+ (seed.html_url ? loginFromUrl(seed.html_url) : null) ??
resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin);
- if (!login) {
+ const accountId = accountIdFromAvatarUrl(seed.avatar_url);
+ if (!login && !accountId) {
continue;
}
- const key = login.toLowerCase();
- const user = apiByLogin.get(key) ?? fetchUser(login);
+ const loginKey = login?.toLowerCase();
+ const userByLogin = loginKey ? apiByLogin.get(loginKey) : undefined;
+ // Avatar account IDs survive renames and cannot be claimed by a handle squatter.
+ const user = accountId
+ ? userByLogin?.id === accountId
+ ? userByLogin
+ : fetchUserByAccountId(accountId)
+ : (userByLogin ?? (login ? fetchUser(login) : null));
if (!user) {
+ const key = accountId ? `github-id:${accountId}` : expectDefined(loginKey, "seed login key");
+ entriesByKey.set(key, {
+ key,
+ display: seed.display,
+ html_url: null,
+ avatar_url: normalizeAvatar(seed.avatar_url),
+ lines: 0,
+ commits: 0,
+ prs: 0,
+ score: 0,
+ firstCommitDate: loginKey ? (firstCommitByLogin.get(loginKey) ?? "") : "",
+ });
continue;
}
+ const key = user.login.toLowerCase();
apiByLogin.set(key, user);
const existing = entriesByKey.get(key);
if (!existing) {
@@ -318,7 +339,8 @@ for (let i = 0; i < visibleEntries.length; i += PER_LINE) {
// (default identicons come back 420px) and never upscales tiny source
// avatars, so markdown images render off-grid without explicit sizing.
const alt = escapeHtmlAttribute(entry.display);
- return `
`;
+ const image = `
`;
+ return entry.html_url ? `${image}` : image;
});
markdownLines.push(parts.join(" "));
}
@@ -446,27 +468,70 @@ function normalizeAvatar(url: string): string {
}
}
+function accountIdFromAvatarUrl(url: string): number | null {
+ try {
+ const parsed = new URL(url);
+ if (parsed.hostname !== "avatars.githubusercontent.com") {
+ return null;
+ }
+ const match = /^\/u\/(\d+)\/?$/u.exec(parsed.pathname);
+ const accountId = match?.[1] ? Number(match[1]) : Number.NaN;
+ return Number.isSafeInteger(accountId) && accountId > 0 ? accountId : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseUser(responseText: string): User | null {
+ const parsed = JSON.parse(responseText);
+ if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) {
+ return null;
+ }
+ return {
+ id: typeof parsed.id === "number" ? parsed.id : undefined,
+ login: parsed.login,
+ html_url: parsed.html_url,
+ avatar_url: normalizeAvatar(parsed.avatar_url),
+ };
+}
+
function fetchUser(login: string): User | null {
const normalized = normalizeLogin(login);
if (!normalized) {
return null;
}
try {
- const data = runGh(["api", `users/${normalized}`]);
- const parsed = JSON.parse(data);
- if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) {
+ return parseUser(runGh(["api", `users/${normalized}`]));
+ } catch (error) {
+ if (isGitHubMissing(error)) {
return null;
}
- return {
- login: parsed.login,
- html_url: parsed.html_url,
- avatar_url: normalizeAvatar(parsed.avatar_url),
- };
- } catch {
- return null;
+ throw error;
}
}
+function fetchUserByAccountId(accountId: number): User | null {
+ try {
+ return parseUser(runGh(["api", `user/${accountId}`]));
+ } catch (error) {
+ if (isGitHubMissing(error)) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function isGitHubMissing(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : "";
+ const stderr = (error as { stderr?: unknown } | null)?.stderr;
+ const stderrText = Buffer.isBuffer(stderr)
+ ? stderr.toString("utf8")
+ : typeof stderr === "string"
+ ? stderr
+ : "";
+ return /\bHTTP (?:404|410)\b/u.test(`${message}\n${stderrText}`);
+}
+
function isDefaultGitHubAvatar(login: string): Promise {
const normalized = normalizeLogin(login)?.toLowerCase();
if (!normalized) {
@@ -850,13 +915,17 @@ function escapeHtmlAttribute(value: string): string {
function parseReadmeEntries(
content: string,
-): Array<{ display: string; html_url: string; avatar_url: string }> {
+): Array<{ display: string; html_url: string | null; avatar_url: string }> {
const rangeValue = findClawtributorsRange(content);
if (!rangeValue) {
return [];
}
const blockValue = content.slice(rangeValue.start, rangeValue.end);
- const entriesValue: Array<{ display: string; html_url: string; avatar_url: string }> = [];
+ const entriesValue: Array<{
+ display: string;
+ html_url: string | null;
+ avatar_url: string;
+ }> = [];
const markdown = /\[!\[([^\]]+)\]\(([^)]+)\)\]\(([^)]+)\)/g;
for (const match of blockValue.matchAll(markdown)) {
const [, alt, src, href] = match;
@@ -886,7 +955,7 @@ function parseReadmeEntries(
if (entriesValue.some((entry) => entry.display === alt && entry.avatar_url === src)) {
continue;
}
- entriesValue.push({ html_url: fallbackHref(alt), avatar_url: src, display: alt });
+ entriesValue.push({ html_url: null, avatar_url: src, display: alt });
}
return entriesValue;
}
@@ -967,11 +1036,6 @@ function loginFromUrl(url: string): string | null {
return login;
}
-function fallbackHref(value: string): string {
- const encoded = encodeURIComponent(value.trim());
- return encoded ? `https://github.com/search?q=${encoded}` : "https://github.com";
-}
-
function pickDisplay(
baseName: string | null | undefined,
login: string,
diff --git a/scripts/update-clawtributors.types.ts b/scripts/update-clawtributors.types.ts
index f2f1d6138b12..948c2275e699 100644
--- a/scripts/update-clawtributors.types.ts
+++ b/scripts/update-clawtributors.types.ts
@@ -8,6 +8,7 @@ export type MapConfig = {
};
export type ApiContributor = {
+ id?: number;
login?: string;
html_url?: string;
avatar_url?: string;
@@ -17,6 +18,7 @@ export type ApiContributor = {
};
export type User = {
+ id?: number;
login: string;
html_url: string;
avatar_url: string;
@@ -26,7 +28,7 @@ export type Entry = {
key: string;
login?: string;
display: string;
- html_url: string;
+ html_url: string | null;
avatar_url: string;
lines: number;
commits: number;
diff --git a/test/scripts/update-clawtributors.test.ts b/test/scripts/update-clawtributors.test.ts
index a09c8efdd7d8..f20e6dde07c8 100644
--- a/test/scripts/update-clawtributors.test.ts
+++ b/test/scripts/update-clawtributors.test.ts
@@ -20,9 +20,24 @@ afterEach(() => {
function mockClawtributorsFixture({
ensureLogins = [],
+ seedEntry,
+ accountLookup,
+ accountLookupError,
+ accountLookupStatus = 404,
+ loginLookupError,
runGh,
}: {
ensureLogins?: string[];
+ seedEntry?: string;
+ accountLookup?: {
+ id: number;
+ login: string;
+ html_url: string;
+ avatar_url: string;
+ } | null;
+ accountLookupError?: Error;
+ accountLookupStatus?: 404 | 410;
+ loginLookupError?: Error;
runGh?: GhRunner;
} = {}) {
const readme = [
@@ -38,7 +53,7 @@ function mockClawtributorsFixture({
vi.doMock("node:fs", () => ({
readFileSync: vi.fn((path: string) => {
if (path.endsWith("scripts/clawtributors-map.json")) {
- return `${JSON.stringify({ ensureLogins })}\n`;
+ return `${JSON.stringify({ ensureLogins, seedCommit: seedEntry ? "seed-sha" : undefined })}\n`;
}
if (path.endsWith("README.md")) {
return readme;
@@ -54,6 +69,7 @@ function mockClawtributorsFixture({
}),
}));
const contributor = {
+ id: 1,
login: "octo",
name: "Octo",
html_url: "https://github.com/octo",
@@ -64,6 +80,12 @@ function mockClawtributorsFixture({
if (cmd === "git log --reverse --format=%aN%x1f%aE%x1f%aI --numstat") {
return "";
}
+ if (cmd === "git show seed-sha:README.md" && seedEntry) {
+ return readme.replace(
+ "\n",
+ `\n${seedEntry}\n`,
+ );
+ }
if (cmd === "git rev-list --max-parents=0 HEAD") {
return "root-sha\n";
}
@@ -103,13 +125,28 @@ function mockClawtributorsFixture({
return "";
}
if (args[0] === "api" && args[1]?.startsWith("users/")) {
+ if (loginLookupError) {
+ throw loginLookupError;
+ }
const login = args[1].slice("users/".length);
return JSON.stringify({
+ id: 2,
login,
html_url: `https://github.com/${login}`,
avatar_url: "https://avatars.githubusercontent.com/u/2?v=4",
});
}
+ if (args[0] === "api" && args[1]?.startsWith("user/")) {
+ if (accountLookupError) {
+ throw accountLookupError;
+ }
+ if (!accountLookup) {
+ throw Object.assign(new Error("gh api failed"), {
+ stderr: Buffer.from(`gh: account missing (HTTP ${accountLookupStatus})\n`),
+ });
+ }
+ return JSON.stringify(accountLookup);
+ }
throw new Error(`unexpected gh arguments: ${args.join(" ")}`);
},
);
@@ -190,6 +227,87 @@ describe("update-clawtributors", () => {
expect(fixture.execPlainGh).toHaveBeenCalledTimes(1);
});
+ it("aborts on transient login lookup failures", async () => {
+ const fixture = mockClawtributorsFixture({
+ ensureLogins: ["extra"],
+ loginLookupError: new Error("GitHub login lookup unavailable"),
+ });
+
+ await expect(importUpdateClawtributors()).rejects.toThrow("GitHub login lookup unavailable");
+
+ expect(fixture.readWrittenReadme()).toBe("");
+ });
+
+ it("resolves renamed seed profiles by stable account id", async () => {
+ const fixture = mockClawtributorsFixture({
+ seedEntry:
+ '
',
+ accountLookup: {
+ id: 42,
+ login: "new-handle",
+ html_url: "https://github.com/new-handle",
+ avatar_url: "https://avatars.githubusercontent.com/u/42?v=4",
+ },
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(null, { status: 404 })),
+ );
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await importUpdateClawtributors();
+
+ expect(fixture.execPlainGh).toHaveBeenCalledWith(
+ ["api", "user/42"],
+ expect.objectContaining({ timeout: 120_000, killSignal: "SIGKILL" }),
+ );
+ expect(fixture.readWrittenReadme()).toContain(
+ '
',
+ );
+ expect(fixture.readWrittenReadme()).not.toContain('href="https://github.com/old-handle"');
+ });
+
+ it.each([404, 410] as const)(
+ "keeps deleted seed profiles as unlinked credit on HTTP %s",
+ async (accountLookupStatus) => {
+ const fixture = mockClawtributorsFixture({
+ seedEntry:
+ '
',
+ accountLookup: null,
+ accountLookupStatus,
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(null, { status: 404 })),
+ );
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await importUpdateClawtributors();
+
+ expect(fixture.readWrittenReadme()).toContain(
+ '
',
+ );
+ expect(fixture.readWrittenReadme()).not.toContain('href="https://github.com/gone-handle"');
+ },
+ );
+
+ it("aborts instead of unlinking credit on transient account lookup failures", async () => {
+ const fixture = mockClawtributorsFixture({
+ seedEntry:
+ '
',
+ accountLookupError: new Error("GitHub API unavailable"),
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(null, { status: 404 })),
+ );
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await expect(importUpdateClawtributors()).rejects.toThrow("GitHub API unavailable");
+
+ expect(fixture.readWrittenReadme()).toBe("");
+ });
+
it("rejects unsafe avatar probe content lengths before reading the body", async () => {
const fixture = mockClawtributorsFixture();
const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));