+
onOpenChange(false)} />
+
{children}
+
+ );
+}
+
+export function DialogContent({ children, className }: { children: React.ReactNode; className?: string }) {
+ return
{children}
;
+}
+
+export function DialogHeader({ children, className }: { children: React.ReactNode; className?: string }) {
+ return
{children}
;
+}
+
+export function DialogTitle({ children, className }: { children: React.ReactNode; className?: string }) {
+ return
{children}
;
+}
diff --git a/src/gateway/adapter.ts.diff b/src/gateway/adapter.ts.diff
new file mode 100644
index 00000000..18f070b2
--- /dev/null
+++ b/src/gateway/adapter.ts.diff
@@ -0,0 +1,9 @@
+--- src/gateway/adapter.ts
++++ src/gateway/adapter.ts
+@@ -73,6 +73,8 @@
+ skillsStatus(agentId?: string): Promise
;
+ skillsInstall(name: string, installId: string): Promise;
+ skillsUpdate(skillKey: string, patch: SkillUpdatePatch): Promise<{ ok: boolean }>;
++
++ agentFiles(agentId: string): Promise<{ name: string, content: string }[]>;
+ }
diff --git a/src/gateway/types.ts b/src/gateway/types.ts
index 896ec773..68eddbf1 100644
--- a/src/gateway/types.ts
+++ b/src/gateway/types.ts
@@ -274,7 +274,8 @@ export type PageId =
| "channels"
| "skills"
| "cron"
- | "settings";
+ | "settings"
+ | "setupGcp";
export interface TokenSnapshot {
timestamp: number;
diff --git a/src/i18n/locales/en/console.json b/src/i18n/locales/en/console.json
index 7533087f..0f7c8f8c 100644
--- a/src/i18n/locales/en/console.json
+++ b/src/i18n/locales/en/console.json
@@ -6,6 +6,7 @@
"stats": {
"channels": "Channels",
"skills": "Skills",
+ "local_skills": "Local Skills",
"usage": "Provider Usage",
"uptime": "Uptime",
"uptimeOnline": "Online"
@@ -72,6 +73,7 @@
},
"fields": {
"botToken": "Bot Token",
+ "chatId": "Chat ID",
"applicationId": "Application ID",
"phoneNumber": "Phone Number",
"appId": "App ID",
@@ -86,6 +88,7 @@
},
"placeholders": {
"botToken": "Enter Bot Token",
+ "chatId": "e.g. -1001234567890",
"applicationId": "Enter Application ID",
"phoneNumber": "+1...",
"appId": "Enter App ID",
@@ -115,6 +118,11 @@
"logout": {
"title": "Logout Channel",
"description": "Are you sure you want to logout \"{{name}}\"? You will need to re-pair."
+ },
+ "gcpNotice": {
+ "title": "Looking for Telegram alerts and broadcast channels?",
+ "body": "Notification channels backed by GCP Secret Manager (admin Telegram bot, branch broadcast bots) are managed in Setup GCP, with Chat ID, secret rotation, and a built-in test button.",
+ "cta": "Open Setup GCP"
}
},
"skills": {
@@ -522,6 +530,7 @@
"files": "Files",
"tools": "Tools",
"skills": "Skills",
+ "local_skills": "Local Skills",
"channels": "Channels",
"cronJobs": "Cron Jobs"
},
@@ -667,5 +676,154 @@
"delete": "Delete",
"deleting": "Deleting..."
}
+ },
+ "setupGcp": {
+ "title": "Setup GCP",
+ "description": "Approve new branch nodes joining the fleet and manage notification channels (Telegram, etc.) backed by GCP Secret Manager.",
+ "tabs": {
+ "pairing": "Pairing Requests",
+ "channels": "Notification Channels",
+ "openhands": "OpenHands"
+ },
+ "secretsNotice": {
+ "title": "Secrets stay in GCP Secret Manager",
+ "body": "Bot tokens and other credentials are stored in GCP Secret Manager via the fleet-registry-api. Plaintext values are never persisted in the browser or in the registry database."
+ },
+ "notConfigured": {
+ "title": "Registry API not configured",
+ "body": "Set VITE_REGISTRY_API_URL or inject window.__OPENCLAW_CONFIG__.registryApiUrl at runtime. The default targets the HQ sidecar over Tailscale (http://openclaw-hq:8781) and trusts the VPN as the perimeter — no bearer token is required."
+ },
+ "pairing": {
+ "title": "Pending pairing requests",
+ "description": "Branch nodes that have published themselves over Tailscale and are waiting for HQ to authorize delegation.",
+ "coordinator": "coordinator",
+ "rejectReason": "Rejection reason",
+ "rejectReasonPlaceholder": "Why is this request being rejected?",
+ "empty": {
+ "title": "No requests in this state",
+ "description": "Branches that contact the registry will appear here."
+ },
+ "statusFilter": {
+ "pending": "Pending",
+ "approved": "Approved",
+ "rejected": "Rejected",
+ "smoke_passed": "Smoke OK",
+ "smoke_failed": "Smoke failed",
+ "all": "All"
+ },
+ "status": {
+ "pending": "pending",
+ "approved": "approved",
+ "rejected": "rejected",
+ "smoke_passed": "smoke ok",
+ "smoke_failed": "smoke failed"
+ },
+ "fields": {
+ "delegateUrl": "Delegate URL",
+ "allowedAgents": "Allowed agents",
+ "tokenFingerprint": "Token fingerprint",
+ "decidedBy": "Decided by",
+ "decidedAt": "Decided at",
+ "metadata": "Metadata"
+ }
+ },
+ "channels": {
+ "title": "Notification channels",
+ "description": "Channels used by HQ-Cloud and branches to send alerts. Telegram is the first supported transport.",
+ "enabled": "enabled",
+ "disabled": "disabled",
+ "noSecret": "no secret set",
+ "lastTest": "last tested",
+ "testOk": "Test message delivered.",
+ "testFailed": "Test failed: {{detail}}",
+ "secretValue": "Secret value",
+ "secretPlaceholder": "Paste bot token or webhook secret",
+ "secretHint": "Stored as a new GCP Secret Manager version. Earlier versions are kept for rollback.",
+ "testMessage": "Test message",
+ "testMessagePlaceholder": "Optional — leave blank for default ping",
+ "empty": {
+ "title": "No channels configured",
+ "description": "Add a channel to start receiving fleet notifications."
+ },
+ "createDialog": {
+ "title": "Add notification channel"
+ },
+ "fields": {
+ "kind": "Kind",
+ "name": "Display name",
+ "namePlaceholder": "e.g. Admin Bot",
+ "scope": "Scope",
+ "chatId": "Chat ID",
+ "chatIdPlaceholder": "e.g. -1001234567890",
+ "botToken": "Bot Token (initial secret)",
+ "botTokenPlaceholder": "1234567890:ABC..."
+ },
+ "deleteDialog": {
+ "title": "Delete channel",
+ "description": "Delete \"{{name}}\"? The associated GCP secret will also be removed."
+ }
+ },
+ "actions": {
+ "refresh": "Refresh",
+ "approve": "Approve",
+ "reject": "Reject",
+ "confirmReject": "Reject",
+ "cancel": "Cancel",
+ "addChannel": "Add Channel",
+ "save": "Save",
+ "create": "Create",
+ "creating": "Creating...",
+ "test": "Test",
+ "sendTest": "Send test",
+ "setSecret": "Set Secret",
+ "enable": "Enable",
+ "disable": "Disable"
+ },
+ "openhands": {
+ "title": "OpenHands LLM profiles",
+ "description": "Provider profiles consumed by Alekhine's OpenHands wrapper. The active profile drives every OpenHands run on this fleet.",
+ "createProfile": "Create profile",
+ "defaultBadge": "default",
+ "secretSet": "Secret set",
+ "noSecret": "No secret",
+ "lastTest": "last tested",
+ "testOk": "LLM round-trip OK.",
+ "testFailed": "Test failed: {{detail}}",
+ "banner": {
+ "title": "Active profile drives OpenHands",
+ "body": "The profile marked default is what Alekhine's OpenHands wrapper uses. API keys are persisted in GCP Secret Manager — never in the browser or registry DB."
+ },
+ "empty": {
+ "title": "No profiles configured",
+ "description": "Create a profile to bind an LLM provider/model + API key to OpenHands."
+ },
+ "createDialog": {
+ "title": "Create LLM profile"
+ },
+ "deleteDialog": {
+ "title": "Delete profile",
+ "description": "Delete \"{{label}}\"? The associated GCP secret will also be removed."
+ },
+ "fields": {
+ "provider": "Provider",
+ "model": "Model",
+ "modelPlaceholder": "e.g. claude-sonnet-4-5, gpt-4o, gemini-2.5-pro",
+ "label": "Label",
+ "labelPlaceholder": "e.g. Sonnet primary",
+ "apiKey": "API key",
+ "apiKeyOptional": "API key (optional, can be set later)",
+ "apiKeyPlaceholder": "sk-... / paste raw secret",
+ "apiKeyHint": "Stored as a new GCP Secret Manager version. Earlier versions are kept for rollback.",
+ "testPrompt": "Test prompt",
+ "testPromptPlaceholder": "Optional — leave blank for default ping"
+ },
+ "actions": {
+ "activate": "Activate",
+ "setSecret": "Set Secret",
+ "test": "Test",
+ "edit": "Edit",
+ "runTest": "Run test"
+ }
+ }
}
}
diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json
index c7ec91d6..071bcf1d 100644
--- a/src/i18n/locales/en/layout.json
+++ b/src/i18n/locales/en/layout.json
@@ -16,6 +16,7 @@
"cron": "Cron Tasks",
"agents": "Agents",
"settings": "Settings",
+ "setupGcp": "Setup GCP",
"fallback": "Console"
},
"theme": {
@@ -53,6 +54,7 @@
"channels": "Channels",
"skills": "Skills",
"cron": "Cron Tasks",
+ "setupGcp": "Setup GCP",
"settings": "Settings"
}
}
diff --git a/src/i18n/locales/zh/console.json b/src/i18n/locales/zh/console.json
index 39e53716..052c3b45 100644
--- a/src/i18n/locales/zh/console.json
+++ b/src/i18n/locales/zh/console.json
@@ -72,6 +72,7 @@
},
"fields": {
"botToken": "Bot Token",
+ "chatId": "Chat ID",
"applicationId": "Application ID",
"phoneNumber": "手机号",
"appId": "App ID",
@@ -86,6 +87,7 @@
},
"placeholders": {
"botToken": "输入 Bot Token",
+ "chatId": "例如 -1001234567890",
"applicationId": "输入 Application ID",
"phoneNumber": "+86...",
"appId": "输入 App ID",
@@ -115,6 +117,11 @@
"logout": {
"title": "登出渠道",
"description": "确认要登出「{{name}}」吗?登出后需要重新配对。"
+ },
+ "gcpNotice": {
+ "title": "需要配置 Telegram 告警或广播通道?",
+ "body": "由 GCP Secret Manager 托管的通知渠道(管理员 Telegram Bot、分支广播 Bot 等)在「GCP 配置」中统一管理,支持 Chat ID、凭据轮换和内置测试。",
+ "cta": "打开 GCP 配置"
}
},
"skills": {
@@ -667,5 +674,154 @@
"delete": "删除",
"deleting": "删除中..."
}
+ },
+ "setupGcp": {
+ "title": "GCP 配置",
+ "description": "审批新接入的分支节点,并管理由 GCP Secret Manager 托管的通知渠道(Telegram 等)。",
+ "tabs": {
+ "pairing": "配对请求",
+ "channels": "通知渠道",
+ "openhands": "OpenHands"
+ },
+ "secretsNotice": {
+ "title": "凭据保存在 GCP Secret Manager",
+ "body": "Bot Token 等凭据由 fleet-registry-api 写入 GCP Secret Manager。明文不会保存在浏览器或注册中心数据库中。"
+ },
+ "notConfigured": {
+ "title": "Registry API 未配置",
+ "body": "请设置 VITE_REGISTRY_API_URL,或在运行时注入 window.__OPENCLAW_CONFIG__.registryApiUrl。默认指向 Tailscale 上的 HQ 边车(http://openclaw-hq:8781),信任边界即 VPN — 无需 Bearer 令牌。"
+ },
+ "pairing": {
+ "title": "待审批的配对请求",
+ "description": "通过 Tailscale 注册并等待 HQ 授权的分支节点。",
+ "coordinator": "协调智能体",
+ "rejectReason": "拒绝原因",
+ "rejectReasonPlaceholder": "请说明拒绝该请求的原因",
+ "empty": {
+ "title": "当前没有该状态的请求",
+ "description": "新分支接入时会显示在这里。"
+ },
+ "statusFilter": {
+ "pending": "待审批",
+ "approved": "已批准",
+ "rejected": "已拒绝",
+ "smoke_passed": "Smoke 通过",
+ "smoke_failed": "Smoke 失败",
+ "all": "全部"
+ },
+ "status": {
+ "pending": "待审批",
+ "approved": "已批准",
+ "rejected": "已拒绝",
+ "smoke_passed": "smoke 通过",
+ "smoke_failed": "smoke 失败"
+ },
+ "fields": {
+ "delegateUrl": "Delegate URL",
+ "allowedAgents": "允许的 Agent",
+ "tokenFingerprint": "委托 Token 指纹",
+ "decidedBy": "审批人",
+ "decidedAt": "审批时间",
+ "metadata": "Metadata"
+ }
+ },
+ "channels": {
+ "title": "通知渠道",
+ "description": "HQ-Cloud 与分支节点用于发送告警的渠道。当前优先支持 Telegram。",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "noSecret": "未设置凭据",
+ "lastTest": "上次测试",
+ "testOk": "测试消息已送达。",
+ "testFailed": "测试失败:{{detail}}",
+ "secretValue": "凭据值",
+ "secretPlaceholder": "粘贴 Bot Token 或 Webhook 凭据",
+ "secretHint": "保存为 GCP Secret Manager 的新版本,旧版本会保留以便回滚。",
+ "testMessage": "测试消息",
+ "testMessagePlaceholder": "可选 — 留空则发送默认 ping",
+ "empty": {
+ "title": "尚未配置通知渠道",
+ "description": "添加渠道以开始接收 fleet 通知。"
+ },
+ "createDialog": {
+ "title": "添加通知渠道"
+ },
+ "fields": {
+ "kind": "类型",
+ "name": "显示名称",
+ "namePlaceholder": "例如 Admin Bot",
+ "scope": "Scope",
+ "chatId": "Chat ID",
+ "chatIdPlaceholder": "例如 -1001234567890",
+ "botToken": "Bot Token(初始凭据)",
+ "botTokenPlaceholder": "1234567890:ABC..."
+ },
+ "deleteDialog": {
+ "title": "删除渠道",
+ "description": "确认删除「{{name}}」?关联的 GCP Secret 也会被删除。"
+ }
+ },
+ "actions": {
+ "refresh": "刷新",
+ "approve": "批准",
+ "reject": "拒绝",
+ "confirmReject": "确认拒绝",
+ "cancel": "取消",
+ "addChannel": "添加渠道",
+ "save": "保存",
+ "create": "创建",
+ "creating": "创建中...",
+ "test": "测试",
+ "sendTest": "发送测试",
+ "setSecret": "设置凭据",
+ "enable": "启用",
+ "disable": "禁用"
+ },
+ "openhands": {
+ "title": "OpenHands LLM 配置",
+ "description": "Alekhine OpenHands 封装使用的 LLM 提供方配置。被设为默认的配置将驱动 fleet 中所有 OpenHands 运行。",
+ "createProfile": "新建配置",
+ "defaultBadge": "默认",
+ "secretSet": "已设置凭据",
+ "noSecret": "未设置凭据",
+ "lastTest": "上次测试",
+ "testOk": "LLM 往返测试成功。",
+ "testFailed": "测试失败:{{detail}}",
+ "banner": {
+ "title": "默认配置驱动 OpenHands",
+ "body": "被标记为默认的配置即 Alekhine OpenHands 封装实际使用的配置。API Key 保存在 GCP Secret Manager —— 永不写入浏览器或注册中心数据库。"
+ },
+ "empty": {
+ "title": "尚未配置任何 LLM 配置",
+ "description": "创建一个配置,将 LLM 提供方/模型 + API Key 绑定到 OpenHands。"
+ },
+ "createDialog": {
+ "title": "创建 LLM 配置"
+ },
+ "deleteDialog": {
+ "title": "删除配置",
+ "description": "确认删除「{{label}}」?关联的 GCP Secret 也会被删除。"
+ },
+ "fields": {
+ "provider": "提供方",
+ "model": "模型",
+ "modelPlaceholder": "例如 claude-sonnet-4-5, gpt-4o, gemini-2.5-pro",
+ "label": "名称",
+ "labelPlaceholder": "例如 Sonnet 主用",
+ "apiKey": "API Key",
+ "apiKeyOptional": "API Key(可选,稍后再设置)",
+ "apiKeyPlaceholder": "sk-... / 粘贴原始 Secret",
+ "apiKeyHint": "保存为 GCP Secret Manager 的新版本,旧版本会保留以便回滚。",
+ "testPrompt": "测试提示词",
+ "testPromptPlaceholder": "可选 — 留空则发送默认 ping"
+ },
+ "actions": {
+ "activate": "激活",
+ "setSecret": "设置凭据",
+ "test": "测试",
+ "edit": "编辑",
+ "runTest": "运行测试"
+ }
+ }
}
}
diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json
index 5099ae0d..435aaba7 100644
--- a/src/i18n/locales/zh/layout.json
+++ b/src/i18n/locales/zh/layout.json
@@ -16,6 +16,7 @@
"cron": "定时任务",
"agents": "智能体管理",
"settings": "设置",
+ "setupGcp": "GCP 配置",
"fallback": "控制台"
},
"theme": {
@@ -53,6 +54,7 @@
"channels": "渠道",
"skills": "技能",
"cron": "定时任务",
+ "setupGcp": "GCP 配置",
"settings": "设置"
}
}
diff --git a/src/lib/__tests__/registry-api-client.test.ts b/src/lib/__tests__/registry-api-client.test.ts
new file mode 100644
index 00000000..ceda2ddc
--- /dev/null
+++ b/src/lib/__tests__/registry-api-client.test.ts
@@ -0,0 +1,266 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ channels,
+ DEFAULT_REGISTRY_API_URL,
+ notify,
+ openhands,
+ pairing,
+ resolveRegistryApiConfig,
+} from "@/lib/registry-api-client";
+
+const originalFetch = global.fetch;
+
+function setRuntimeUrl(url: string | undefined): void {
+ const win = window as unknown as Record;
+ if (url === undefined) {
+ delete win.__OPENCLAW_CONFIG__;
+ return;
+ }
+ win.__OPENCLAW_CONFIG__ = { registryApiUrl: url };
+}
+
+describe("registry-api-client", () => {
+ beforeEach(() => {
+ setRuntimeUrl(undefined);
+ });
+
+ afterEach(() => {
+ setRuntimeUrl(undefined);
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ it("falls back to the Tailscale HQ default when nothing is configured", () => {
+ setRuntimeUrl(undefined);
+ const cfg = resolveRegistryApiConfig();
+ expect(cfg.baseUrl).toBe(DEFAULT_REGISTRY_API_URL);
+ expect(cfg.baseUrl).toBe("http://openclaw-hq:8781");
+ expect(cfg.configured).toBe(true);
+ });
+
+ it("resolves config from runtime injection and trims trailing slash", () => {
+ setRuntimeUrl("http://openclaw-hq:8781/");
+ const cfg = resolveRegistryApiConfig();
+ expect(cfg.baseUrl).toBe("http://openclaw-hq:8781");
+ expect(cfg.configured).toBe(true);
+ });
+
+ it("issues GET WITHOUT an Authorization header for pairing.list", async () => {
+ setRuntimeUrl("http://openclaw-hq:8781");
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify([{ id: 1, branch_id: "miami" }]), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ const items = await pairing.list("pending");
+ expect(items).toEqual([{ id: 1, branch_id: "miami" }]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
+ expect(calledUrl).toBe("http://openclaw-hq:8781/v1/pairing/requests?status_filter=pending");
+ expect((init as RequestInit).method).toBe("GET");
+ const headers = (init as RequestInit).headers as Record;
+ expect(headers).toMatchObject({ Accept: "application/json" });
+ // VPN is the trust boundary — no bearer token in browser bundle.
+ expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization");
+ });
+
+ it("posts JSON body for channels.create without an Authorization header", async () => {
+ setRuntimeUrl("http://openclaw-hq:8781");
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ id: 7, kind: "telegram", name: "Admin Bot" }), {
+ status: 201,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ const created = await channels.create({
+ kind: "telegram",
+ name: "Admin Bot",
+ config: { chat_id: "-1001" },
+ scope: "admin",
+ });
+ expect(created.id).toBe(7);
+ const [, init] = fetchMock.mock.calls[0]!;
+ expect((init as RequestInit).method).toBe("POST");
+ const body = JSON.parse((init as RequestInit).body as string);
+ expect(body).toEqual({
+ kind: "telegram",
+ name: "Admin Bot",
+ config: { chat_id: "-1001" },
+ scope: "admin",
+ });
+ const headers = (init as RequestInit).headers as Record;
+ expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization");
+ });
+
+ it("posts to /v1/notify for notify.broadcast", async () => {
+ setRuntimeUrl("http://openclaw-hq:8781");
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ ok: true, delivered: 3 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ const result = await notify.broadcast({ scope: "admin", text: "hello" });
+ expect(result).toEqual({ ok: true, delivered: 3 });
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
+ expect(calledUrl).toBe("http://openclaw-hq:8781/v1/notify");
+ expect((init as RequestInit).method).toBe("POST");
+ const body = JSON.parse((init as RequestInit).body as string);
+ expect(body).toEqual({ scope: "admin", text: "hello" });
+ const headers = (init as RequestInit).headers as Record;
+ expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization");
+ });
+
+ it("rejects with RegistryApiError on non-2xx with detail", async () => {
+ setRuntimeUrl("http://openclaw-hq:8781");
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ detail: "nope" }), {
+ status: 403,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await expect(pairing.approve(1, { decided_by: "x" })).rejects.toMatchObject({
+ name: "RegistryApiError",
+ status: 403,
+ message: "nope",
+ });
+ });
+});
+
+describe("envelope extraction (sidecar shape)", () => {
+ beforeEach(() => {
+ vi.stubEnv("VITE_REGISTRY_API_URL", "http://openclaw-hq:8781");
+ });
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it("pairing.list extracts requests[] from {requests: [...]} envelope", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
+ JSON.stringify({ requests: [{ id: 2, branch_id: "miami", status: "pending" }] }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ )));
+ const { pairing: pairingApi } = await import("../registry-api-client");
+ const items = await pairingApi.list("pending");
+ expect(items).toHaveLength(1);
+ expect(items[0].id).toBe(2);
+ });
+
+ it("channels.list extracts channels[] from {channels: [...]} envelope", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
+ JSON.stringify({ channels: [{ id: 1, kind: "telegram", name: "Bot" }] }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ )));
+ const { channels: channelsApi } = await import("../registry-api-client");
+ const items = await channelsApi.list();
+ expect(items).toHaveLength(1);
+ expect(items[0].kind).toBe("telegram");
+ });
+
+ it("openhands.profiles.list extracts profiles[] from {profiles: [...]} envelope and hits /v1/openhands/profiles", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ profiles: [
+ {
+ id: 7,
+ provider: "anthropic",
+ model: "claude-sonnet-4-5",
+ label: "Sonnet primary",
+ is_default: true,
+ has_secret: true,
+ last_test_at: null,
+ last_test_result: null,
+ },
+ ],
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const { openhands: openhandsApi } = await import("../registry-api-client");
+ const items = await openhandsApi.profiles.list();
+ expect(items).toHaveLength(1);
+ expect(items[0]?.provider).toBe("anthropic");
+ expect(items[0]?.is_default).toBe(true);
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
+ expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles");
+ expect((init as RequestInit).method).toBe("GET");
+ const headers = (init as RequestInit).headers as Record;
+ // Trust boundary is the VPN — no Authorization header.
+ expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization");
+ });
+});
+
+describe("openhands client", () => {
+ beforeEach(() => {
+ setRuntimeUrl("http://openclaw-hq:8781");
+ });
+ afterEach(() => {
+ setRuntimeUrl(undefined);
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ it("posts to /v1/openhands/profiles/{id}/activate without an Authorization header", async () => {
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ id: 3, provider: "openai", is_default: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+ const result = await openhands.profiles.activate(3);
+ expect((result as { is_default?: boolean }).is_default).toBe(true);
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
+ expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles/3/activate");
+ expect((init as RequestInit).method).toBe("POST");
+ const headers = (init as RequestInit).headers as Record;
+ expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization");
+ });
+
+ it("posts JSON body for openhands.profiles.create", async () => {
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ id: 11,
+ provider: "deepseek",
+ model: "deepseek-chat",
+ label: "DS",
+ is_default: false,
+ has_secret: false,
+ last_test_at: null,
+ last_test_result: null,
+ }),
+ { status: 201, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+ const created = await openhands.profiles.create({
+ provider: "deepseek",
+ model: "deepseek-chat",
+ label: "DS",
+ });
+ expect(created.id).toBe(11);
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
+ expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles");
+ const body = JSON.parse((init as RequestInit).body as string);
+ expect(body).toEqual({ provider: "deepseek", model: "deepseek-chat", label: "DS" });
+ });
+});
diff --git a/src/lib/channel-schemas.ts b/src/lib/channel-schemas.ts
index bdf4f322..bc8fdf9f 100644
--- a/src/lib/channel-schemas.ts
+++ b/src/lib/channel-schemas.ts
@@ -30,6 +30,13 @@ export const CHANNEL_SCHEMAS: Record = {
required: true,
placeholderKey: "console:channels.placeholders.botToken",
},
+ {
+ key: "chatId",
+ labelKey: "console:channels.fields.chatId",
+ type: "text",
+ required: true,
+ placeholderKey: "console:channels.placeholders.chatId",
+ },
],
},
discord: {
diff --git a/src/lib/registry-api-client.ts b/src/lib/registry-api-client.ts
new file mode 100644
index 00000000..0ce260d2
--- /dev/null
+++ b/src/lib/registry-api-client.ts
@@ -0,0 +1,362 @@
+/**
+ * Registry API client for the local HQ sidecar.
+ *
+ * The "Setup GCP" console page uses this client to:
+ * - approve/reject pending pairing requests from new branch nodes (Tailscale)
+ * - manage GCP Secret Manager-backed notification channels (Telegram first)
+ * - broadcast notifications via the HQ notify endpoint
+ *
+ * Trust model:
+ * The HQ sidecar listens on the Tailscale tailnet (default
+ * `http://openclaw-hq:8781`). The trust boundary is the VPN itself: the
+ * sidecar validates the source identity server-side via `tailscale whois`.
+ * This client therefore intentionally has NO bearer token, NO Authorization
+ * header, and NO admin-credential plumbing. Putting a long-lived token in
+ * the browser bundle was a regression — the network is the perimeter.
+ *
+ * Configuration precedence (URL only):
+ * 1. Runtime injection: window.__OPENCLAW_CONFIG__.registryApiUrl
+ * (mirrors the gateway pattern used in src/App.tsx)
+ * 2. Build-time env: VITE_REGISTRY_API_URL
+ * 3. Default: http://openclaw-hq:8781 (Tailscale hostname for HQ)
+ */
+
+// ---------- Types ----------
+
+export type PairingStatus = "pending" | "approved" | "rejected" | "smoke_passed" | "smoke_failed";
+
+export interface PairingRequest {
+ id: number;
+ branch_id: string;
+ hostname: string;
+ tailscale_ip: string;
+ delegate_url: string;
+ coordinator_agent_id: string;
+ allowed_agents: string[];
+ status: PairingStatus;
+ delegation_token_fingerprint: string;
+ metadata?: Record | null;
+ requested_at: string;
+ decided_at: string | null;
+ decided_by: string | null;
+}
+
+export interface PairingApproveBody {
+ decided_by: string;
+}
+
+export interface PairingRejectBody {
+ decided_by: string;
+ reason: string;
+}
+
+export interface PairingSmokeTestBody {
+ delegation_token: string;
+}
+
+export interface PairingSmokeTestResult {
+ ok: boolean;
+ status?: string;
+ http?: number;
+ detail?: Record | string | null;
+}
+
+export type ChannelKind = "telegram" | "discord" | "slack" | string;
+export type ChannelScope = "admin" | "branch" | string;
+
+export interface NotificationChannel {
+ id: number;
+ kind: ChannelKind;
+ name: string;
+ config: Record;
+ secret_ref: string | null;
+ enabled: boolean;
+ scope: ChannelScope;
+ last_test_at: string | null;
+ last_test_result: Record | null;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface CreateChannelBody {
+ kind: ChannelKind;
+ name: string;
+ config: Record;
+ scope?: ChannelScope;
+}
+
+export interface UpdateChannelBody {
+ config?: Record;
+ name?: string;
+ enabled?: boolean;
+}
+
+export interface SetSecretBody {
+ secret_value: string;
+}
+
+export interface ChannelTestBody {
+ message?: string;
+}
+
+export interface ChannelTestResult {
+ ok: boolean;
+ http?: number;
+ detail?: Record | string | null;
+}
+
+export interface NotifyBroadcastBody {
+ scope: string;
+ text: string;
+}
+
+export interface NotifyBroadcastResult {
+ ok: boolean;
+ delivered?: number;
+ detail?: Record | string | null;
+}
+
+// ---------- OpenHands (LLM provider profiles) ----------
+
+export type OpenHandsProvider =
+ | "google"
+ | "anthropic"
+ | "openai"
+ | "deepseek"
+ | "minimax"
+ | "ollama";
+
+export interface OpenHandsProfile {
+ id: number;
+ provider: OpenHandsProvider;
+ model: string;
+ label: string;
+ is_default: boolean;
+ has_secret: boolean;
+ last_test_at: string | null;
+ last_test_result: Record | string | null;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface CreateOpenHandsProfileBody {
+ provider: OpenHandsProvider;
+ model: string;
+ label: string;
+}
+
+export interface UpdateOpenHandsProfileBody {
+ provider?: OpenHandsProvider;
+ model?: string;
+ label?: string;
+}
+
+export interface OpenHandsTestBody {
+ prompt?: string;
+}
+
+export interface OpenHandsTestResult {
+ ok: boolean;
+ detail?: Record | string | null;
+ latency_ms?: number;
+}
+
+// ---------- Config resolution ----------
+
+/**
+ * Default base URL for the HQ sidecar on Tailscale. The sidecar listens on
+ * the tailnet and uses `tailscale whois` for source identity — no bearer.
+ */
+export const DEFAULT_REGISTRY_API_URL = "http://openclaw-hq:8781";
+
+interface RuntimeConfig {
+ registryApiUrl?: string;
+}
+
+function readRuntimeConfig(): RuntimeConfig {
+ if (typeof window === "undefined") return {};
+ const injected = (window as unknown as Record).__OPENCLAW_CONFIG__ as
+ | RuntimeConfig
+ | undefined;
+ return injected ?? {};
+}
+
+export interface RegistryApiResolvedConfig {
+ baseUrl: string;
+ configured: boolean;
+}
+
+export function resolveRegistryApiConfig(): RegistryApiResolvedConfig {
+ const runtime = readRuntimeConfig();
+ const fromRuntime = (runtime.registryApiUrl ?? "").trim();
+ const fromEnv = (
+ (import.meta.env as unknown as Record).VITE_REGISTRY_API_URL ?? ""
+ ).trim();
+ const baseUrl = (fromRuntime || fromEnv || DEFAULT_REGISTRY_API_URL).replace(/\/+$/u, "");
+ return { baseUrl, configured: Boolean(baseUrl) };
+}
+
+// ---------- Errors ----------
+
+export class RegistryApiError extends Error {
+ readonly status: number;
+ readonly body: unknown;
+ constructor(status: number, message: string, body?: unknown) {
+ super(message);
+ this.name = "RegistryApiError";
+ this.status = status;
+ this.body = body;
+ }
+}
+
+export class RegistryApiNotConfiguredError extends Error {
+ constructor() {
+ super(
+ "Registry API base URL is empty. Set VITE_REGISTRY_API_URL or inject " +
+ "window.__OPENCLAW_CONFIG__.registryApiUrl at runtime (default is " +
+ `${DEFAULT_REGISTRY_API_URL}).`,
+ );
+ this.name = "RegistryApiNotConfiguredError";
+ }
+}
+
+// ---------- HTTP core ----------
+
+interface RequestOptions {
+ method?: "GET" | "POST" | "PATCH" | "DELETE";
+ body?: unknown;
+ signal?: AbortSignal;
+}
+
+async function request(path: string, opts: RequestOptions = {}): Promise {
+ const { baseUrl, configured } = resolveRegistryApiConfig();
+ if (!configured) throw new RegistryApiNotConfiguredError();
+
+ const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
+ // Trust boundary is the VPN — no Authorization header by design.
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ let body: BodyInit | undefined;
+ if (opts.body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ body = JSON.stringify(opts.body);
+ }
+
+ const res = await fetch(url, {
+ method: opts.method ?? "GET",
+ headers,
+ body,
+ signal: opts.signal,
+ });
+
+ let payload: unknown = null;
+ const text = await res.text();
+ if (text) {
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ payload = text;
+ }
+ }
+
+ if (!res.ok) {
+ const message =
+ (payload && typeof payload === "object" && "detail" in payload
+ ? String((payload as { detail?: unknown }).detail)
+ : null) ?? `Registry API ${res.status} on ${opts.method ?? "GET"} ${path}`;
+ throw new RegistryApiError(res.status, message, payload);
+ }
+
+ return payload as T;
+}
+
+// ---------- Resource APIs ----------
+
+function ensureArray(value: unknown): T[] {
+ return Array.isArray(value) ? (value as T[]) : [];
+}
+
+export const pairing = {
+ list: async (statusFilter: PairingStatus | "all" = "pending") => {
+ const qs = statusFilter === "all" ? "" : `?status_filter=${encodeURIComponent(statusFilter)}`;
+ // Sidecar returns `{requests: [...]}`. Tolerate plain-array fallback too.
+ const raw = await request<{ requests?: PairingRequest[] } | PairingRequest[]>(
+ `/v1/pairing/requests${qs}`
+ );
+ if (Array.isArray(raw)) return raw;
+ return ensureArray(raw?.requests);
+ },
+ get: (id: number) => request(`/v1/pairing/requests/${id}`),
+ approve: (id: number, body: PairingApproveBody) =>
+ request(`/v1/pairing/requests/${id}/approve`, { method: "POST", body }),
+ reject: (id: number, body: PairingRejectBody) =>
+ request(`/v1/pairing/requests/${id}/reject`, { method: "POST", body }),
+ smokeTest: (id: number, body: PairingSmokeTestBody) =>
+ request(`/v1/pairing/requests/${id}/smoke-test`, {
+ method: "POST",
+ body,
+ }),
+};
+
+export const channels = {
+ list: async () => {
+ // Sidecar returns `{channels: [...]}`. Tolerate plain-array fallback too.
+ const raw = await request<{ channels?: NotificationChannel[] } | NotificationChannel[]>(
+ "/v1/channels"
+ );
+ if (Array.isArray(raw)) return raw;
+ return ensureArray(raw?.channels);
+ },
+ get: (id: number) => request(`/v1/channels/${id}`),
+ create: (body: CreateChannelBody) =>
+ request("/v1/channels", { method: "POST", body }),
+ update: (id: number, body: UpdateChannelBody) =>
+ request(`/v1/channels/${id}`, { method: "PATCH", body }),
+ delete: (id: number) => request<{ ok: boolean }>(`/v1/channels/${id}`, { method: "DELETE" }),
+ setSecret: (id: number, body: SetSecretBody) =>
+ request(`/v1/channels/${id}/secret`, { method: "POST", body }),
+ test: (id: number, body: ChannelTestBody = {}) =>
+ request(`/v1/channels/${id}/test`, { method: "POST", body }),
+};
+
+export const notify = {
+ broadcast: (body: NotifyBroadcastBody) =>
+ request("/v1/notify", { method: "POST", body }),
+};
+
+export const openhands = {
+ profiles: {
+ list: async () => {
+ // Sidecar returns `{profiles: [...]}`. Tolerate plain-array fallback too.
+ const raw = await request<{ profiles?: OpenHandsProfile[] } | OpenHandsProfile[]>(
+ "/v1/openhands/profiles",
+ );
+ if (Array.isArray(raw)) return raw;
+ return ensureArray(raw?.profiles);
+ },
+ get: (id: number) => request(`/v1/openhands/profiles/${id}`),
+ create: (body: CreateOpenHandsProfileBody) =>
+ request("/v1/openhands/profiles", { method: "POST", body }),
+ update: (id: number, body: UpdateOpenHandsProfileBody) =>
+ request(`/v1/openhands/profiles/${id}`, { method: "PATCH", body }),
+ delete: (id: number) =>
+ request<{ ok: boolean }>(`/v1/openhands/profiles/${id}`, { method: "DELETE" }),
+ setSecret: (id: number, body: SetSecretBody) =>
+ request(`/v1/openhands/profiles/${id}/secret`, { method: "POST", body }),
+ activate: (id: number) =>
+ request(`/v1/openhands/profiles/${id}/activate`, { method: "POST" }),
+ test: (id: number, body: OpenHandsTestBody = {}) =>
+ request(`/v1/openhands/profiles/${id}/test`, { method: "POST", body }),
+ },
+};
+
+export const registryApiClient = {
+ pairing,
+ channels,
+ notify,
+ openhands,
+ resolveConfig: resolveRegistryApiConfig,
+};
+
+export default registryApiClient;
diff --git a/src/store/console-stores/agents-store.ts b/src/store/console-stores/agents-store.ts
index bdf13179..3c094484 100644
--- a/src/store/console-stores/agents-store.ts
+++ b/src/store/console-stores/agents-store.ts
@@ -23,7 +23,7 @@ import {
} from "@/lib/config-patch-helpers";
import { clearAgentChannelSessions } from "./agent-session-cleanup";
-export type AgentTab = "overview" | "files" | "tools" | "skills" | "channels" | "cronJobs";
+export type AgentTab = "overview" | "files" | "tools" | "skills" | "local_skills" | "channels" | "cronJobs";
export interface SystemModelOption {
id: string;
diff --git a/src/store/console-stores/setupgcp-store.ts b/src/store/console-stores/setupgcp-store.ts
new file mode 100644
index 00000000..fa4ca0d2
--- /dev/null
+++ b/src/store/console-stores/setupgcp-store.ts
@@ -0,0 +1,412 @@
+import { create } from "zustand";
+import {
+ channels as channelsApi,
+ openhands as openhandsApi,
+ pairing as pairingApi,
+ resolveRegistryApiConfig,
+ RegistryApiNotConfiguredError,
+ type ChannelTestResult,
+ type CreateChannelBody,
+ type CreateOpenHandsProfileBody,
+ type NotificationChannel,
+ type OpenHandsProfile,
+ type OpenHandsTestResult,
+ type PairingRequest,
+ type PairingStatus,
+ type UpdateChannelBody,
+ type UpdateOpenHandsProfileBody,
+} from "@/lib/registry-api-client";
+
+function toMessage(err: unknown): string {
+ if (err instanceof RegistryApiNotConfiguredError) return err.message;
+ if (err instanceof Error) return err.message;
+ try {
+ return String(err);
+ } catch {
+ return "Unknown error";
+ }
+}
+
+function isLikelyNetworkError(err: unknown): boolean {
+ // Browsers throw a TypeError ("Failed to fetch", "NetworkError when…", etc.)
+ // when DNS fails, the host is unreachable, or mixed content is blocked.
+ if (err instanceof TypeError) return true;
+ if (err instanceof Error && /network|fetch|failed to fetch/i.test(err.message)) return true;
+ return false;
+}
+
+function describeError(err: unknown, baseUrl: string): string {
+ const base = toMessage(err);
+ if (isLikelyNetworkError(err)) {
+ return `${base} — cannot reach sidecar at ${baseUrl}. If you are not on the Tailscale tailnet, this page will be unavailable.`;
+ }
+ return base;
+}
+
+interface SetupGcpState {
+ // Pairing
+ pairingItems: PairingRequest[];
+ pairingFilter: PairingStatus | "all";
+ pairingLoading: boolean;
+ pairingError: string | null;
+ pairingActionInFlight: Record;
+
+ // Channels
+ channelItems: NotificationChannel[];
+ channelsLoading: boolean;
+ channelsError: string | null;
+ channelActionInFlight: Record;
+ lastChannelTest: Record;
+
+ // OpenHands
+ openhandsProfiles: OpenHandsProfile[];
+ openhandsLoading: boolean;
+ openhandsError: string | null;
+ openhandsActionInFlight: Record;
+ lastOpenHandsTest: Record;
+
+ // Config readiness
+ configured: boolean;
+ baseUrl: string;
+
+ refreshConfig: () => void;
+
+ fetchPairing: (filter?: PairingStatus | "all") => Promise;
+ setPairingFilter: (filter: PairingStatus | "all") => void;
+ approvePairing: (id: number, decidedBy: string) => Promise;
+ rejectPairing: (id: number, decidedBy: string, reason: string) => Promise;
+
+ fetchChannels: () => Promise;
+ createChannel: (body: CreateChannelBody) => Promise;
+ updateChannel: (id: number, body: UpdateChannelBody) => Promise;
+ deleteChannel: (id: number) => Promise;
+ setChannelSecret: (id: number, secret: string) => Promise;
+ testChannel: (id: number, message?: string) => Promise;
+
+ fetchOpenHandsProfiles: () => Promise;
+ createProfile: (body: CreateOpenHandsProfileBody) => Promise;
+ updateProfile: (id: number, body: UpdateOpenHandsProfileBody) => Promise;
+ setProfileSecret: (id: number, secret: string) => Promise;
+ activateProfile: (id: number) => Promise;
+ testProfile: (id: number, prompt?: string) => Promise;
+ deleteProfile: (id: number) => Promise;
+}
+
+// Resolve config defensively at module load: any throw here would otherwise
+// kill the whole bundle the moment the page is imported.
+function safeInitialConfig(): { configured: boolean; baseUrl: string } {
+ try {
+ return resolveRegistryApiConfig();
+ } catch {
+ return { configured: false, baseUrl: "" };
+ }
+}
+
+const initialConfig = safeInitialConfig();
+
+export const useSetupGcpStore = create((set, get) => ({
+ pairingItems: [],
+ pairingFilter: "pending",
+ pairingLoading: false,
+ pairingError: null,
+ pairingActionInFlight: {},
+
+ channelItems: [],
+ channelsLoading: false,
+ channelsError: null,
+ channelActionInFlight: {},
+ lastChannelTest: {},
+
+ openhandsProfiles: [],
+ openhandsLoading: false,
+ openhandsError: null,
+ openhandsActionInFlight: {},
+ lastOpenHandsTest: {},
+
+ configured: initialConfig.configured,
+ baseUrl: initialConfig.baseUrl,
+
+ refreshConfig: () => {
+ try {
+ const cfg = resolveRegistryApiConfig();
+ set({ configured: cfg.configured, baseUrl: cfg.baseUrl });
+ } catch {
+ set({ configured: false, baseUrl: "" });
+ }
+ },
+
+ fetchPairing: async (filter) => {
+ const useFilter = filter ?? get().pairingFilter;
+ set({ pairingLoading: true, pairingError: null, pairingFilter: useFilter });
+ try {
+ const items = await pairingApi.list(useFilter);
+ set({
+ pairingItems: Array.isArray(items) ? items : [],
+ pairingLoading: false,
+ });
+ } catch (err) {
+ // Never let this throw out — surface as error state so the page can
+ // render a friendly panel instead of unmounting the React tree.
+ set({
+ pairingError: describeError(err, get().baseUrl),
+ pairingLoading: false,
+ pairingItems: [],
+ });
+ }
+ },
+
+ setPairingFilter: (filter) => {
+ set({ pairingFilter: filter });
+ void get().fetchPairing(filter);
+ },
+
+ approvePairing: async (id, decidedBy) => {
+ set((s) => ({ pairingActionInFlight: { ...s.pairingActionInFlight, [id]: true } }));
+ try {
+ await pairingApi.approve(id, { decided_by: decidedBy });
+ await get().fetchPairing();
+ } catch (err) {
+ set({ pairingError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.pairingActionInFlight };
+ delete next[id];
+ return { pairingActionInFlight: next };
+ });
+ }
+ },
+
+ rejectPairing: async (id, decidedBy, reason) => {
+ set((s) => ({ pairingActionInFlight: { ...s.pairingActionInFlight, [id]: true } }));
+ try {
+ await pairingApi.reject(id, { decided_by: decidedBy, reason });
+ await get().fetchPairing();
+ } catch (err) {
+ set({ pairingError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.pairingActionInFlight };
+ delete next[id];
+ return { pairingActionInFlight: next };
+ });
+ }
+ },
+
+ fetchChannels: async () => {
+ set({ channelsLoading: true, channelsError: null });
+ try {
+ const items = await channelsApi.list();
+ set({
+ channelItems: Array.isArray(items) ? items : [],
+ channelsLoading: false,
+ });
+ } catch (err) {
+ set({
+ channelsError: describeError(err, get().baseUrl),
+ channelsLoading: false,
+ channelItems: [],
+ });
+ }
+ },
+
+ createChannel: async (body) => {
+ try {
+ const created = await channelsApi.create(body);
+ await get().fetchChannels();
+ return created;
+ } catch (err) {
+ set({ channelsError: toMessage(err) });
+ return null;
+ }
+ },
+
+ updateChannel: async (id, body) => {
+ set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } }));
+ try {
+ await channelsApi.update(id, body);
+ await get().fetchChannels();
+ } catch (err) {
+ set({ channelsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.channelActionInFlight };
+ delete next[id];
+ return { channelActionInFlight: next };
+ });
+ }
+ },
+
+ deleteChannel: async (id) => {
+ set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } }));
+ try {
+ await channelsApi.delete(id);
+ await get().fetchChannels();
+ } catch (err) {
+ set({ channelsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.channelActionInFlight };
+ delete next[id];
+ return { channelActionInFlight: next };
+ });
+ }
+ },
+
+ setChannelSecret: async (id, secret) => {
+ set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } }));
+ try {
+ await channelsApi.setSecret(id, { secret_value: secret });
+ await get().fetchChannels();
+ } catch (err) {
+ set({ channelsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.channelActionInFlight };
+ delete next[id];
+ return { channelActionInFlight: next };
+ });
+ }
+ },
+
+ testChannel: async (id, message) => {
+ set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } }));
+ try {
+ const result = await channelsApi.test(id, message ? { message } : {});
+ set((s) => ({ lastChannelTest: { ...s.lastChannelTest, [id]: result } }));
+ // refresh so last_test_at / last_test_result reflect server state
+ await get().fetchChannels();
+ return result;
+ } catch (err) {
+ const msg = toMessage(err);
+ const failure: ChannelTestResult = { ok: false, detail: msg };
+ set((s) => ({
+ lastChannelTest: { ...s.lastChannelTest, [id]: failure },
+ channelsError: msg,
+ }));
+ return failure;
+ } finally {
+ set((s) => {
+ const next = { ...s.channelActionInFlight };
+ delete next[id];
+ return { channelActionInFlight: next };
+ });
+ }
+ },
+
+ fetchOpenHandsProfiles: async () => {
+ set({ openhandsLoading: true, openhandsError: null });
+ try {
+ const items = await openhandsApi.profiles.list();
+ set({
+ openhandsProfiles: Array.isArray(items) ? items : [],
+ openhandsLoading: false,
+ });
+ } catch (err) {
+ set({
+ openhandsError: describeError(err, get().baseUrl),
+ openhandsLoading: false,
+ openhandsProfiles: [],
+ });
+ }
+ },
+
+ createProfile: async (body) => {
+ try {
+ const created = await openhandsApi.profiles.create(body);
+ await get().fetchOpenHandsProfiles();
+ return created;
+ } catch (err) {
+ set({ openhandsError: toMessage(err) });
+ return null;
+ }
+ },
+
+ updateProfile: async (id, body) => {
+ set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } }));
+ try {
+ await openhandsApi.profiles.update(id, body);
+ await get().fetchOpenHandsProfiles();
+ } catch (err) {
+ set({ openhandsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.openhandsActionInFlight };
+ delete next[id];
+ return { openhandsActionInFlight: next };
+ });
+ }
+ },
+
+ setProfileSecret: async (id, secret) => {
+ set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } }));
+ try {
+ await openhandsApi.profiles.setSecret(id, { secret_value: secret });
+ await get().fetchOpenHandsProfiles();
+ } catch (err) {
+ set({ openhandsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.openhandsActionInFlight };
+ delete next[id];
+ return { openhandsActionInFlight: next };
+ });
+ }
+ },
+
+ activateProfile: async (id) => {
+ set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } }));
+ try {
+ await openhandsApi.profiles.activate(id);
+ await get().fetchOpenHandsProfiles();
+ } catch (err) {
+ set({ openhandsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.openhandsActionInFlight };
+ delete next[id];
+ return { openhandsActionInFlight: next };
+ });
+ }
+ },
+
+ testProfile: async (id, prompt) => {
+ set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } }));
+ try {
+ const result = await openhandsApi.profiles.test(id, prompt ? { prompt } : {});
+ set((s) => ({ lastOpenHandsTest: { ...s.lastOpenHandsTest, [id]: result } }));
+ // refresh so last_test_at / last_test_result reflect server state
+ await get().fetchOpenHandsProfiles();
+ return result;
+ } catch (err) {
+ const msg = toMessage(err);
+ const failure: OpenHandsTestResult = { ok: false, detail: msg };
+ set((s) => ({
+ lastOpenHandsTest: { ...s.lastOpenHandsTest, [id]: failure },
+ openhandsError: msg,
+ }));
+ return failure;
+ } finally {
+ set((s) => {
+ const next = { ...s.openhandsActionInFlight };
+ delete next[id];
+ return { openhandsActionInFlight: next };
+ });
+ }
+ },
+
+ deleteProfile: async (id) => {
+ set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } }));
+ try {
+ await openhandsApi.profiles.delete(id);
+ await get().fetchOpenHandsProfiles();
+ } catch (err) {
+ set({ openhandsError: toMessage(err) });
+ } finally {
+ set((s) => {
+ const next = { ...s.openhandsActionInFlight };
+ delete next[id];
+ return { openhandsActionInFlight: next };
+ });
+ }
+ },
+}));
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 3c6d6950..acf71b85 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -5,6 +5,14 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_GATEWAY_URL: string;
readonly VITE_GATEWAY_TOKEN: string;
+ /**
+ * Base URL of the local HQ sidecar that exposes the registry-API surface
+ * (pairing requests + notification channels + notify broadcast).
+ * Defaults to `http://openclaw-hq:8781` on the Tailscale tailnet — the
+ * sidecar uses `tailscale whois` for source identity, so no bearer token
+ * is required (and none should ever be shipped in the browser bundle).
+ */
+ readonly VITE_REGISTRY_API_URL?: string;
}
interface ImportMeta {