From cc002f4fd1bd93308506246041a648844e5a3be0 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Wed, 11 Feb 2026 20:40:57 +0000
Subject: [PATCH 001/120] docs(tasks): clarify timestamp parsing and dependency
reconciliation
---
backend/app/api/tasks.py | 43 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py
index f43522b7..957f7a27 100644
--- a/backend/app/api/tasks.py
+++ b/backend/app/api/tasks.py
@@ -144,22 +144,41 @@ async def has_valid_recent_comment(
def _parse_since(value: str | None) -> datetime | None:
+ """Parse an optional ISO-8601 timestamp into a naive UTC `datetime`.
+
+ The API accepts either naive timestamps (treated as UTC) or timezone-aware values.
+ Returning naive UTC simplifies SQLModel comparisons against stored naive UTC values.
+ """
+
if not value:
return None
+
normalized = value.strip()
if not normalized:
return None
+
+ # Allow common ISO-8601 `Z` suffix (UTC) even though `datetime.fromisoformat` expects `+00:00`.
normalized = normalized.replace("Z", "+00:00")
+
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
+
if parsed.tzinfo is not None:
return parsed.astimezone(UTC).replace(tzinfo=None)
+
+ # No tzinfo: interpret as UTC for consistency with other API timestamps.
return parsed
def _coerce_task_items(items: Sequence[object]) -> list[Task]:
+ """Validate/convert paginated query results to a concrete `list[Task]`.
+
+ SQLModel pagination helpers return `Sequence[object]`; we validate types early so the
+ rest of the route logic can assume real `Task` instances.
+ """
+
tasks: list[Task] = []
for item in items:
if not isinstance(item, Task):
@@ -172,6 +191,15 @@ def _coerce_task_items(items: Sequence[object]) -> list[Task]:
def _coerce_task_event_rows(
items: Sequence[object],
) -> list[tuple[ActivityEvent, Task | None]]:
+ """Normalize DB rows into `(ActivityEvent, Task | None)` tuples.
+
+ Depending on the SQLAlchemy/SQLModel execution path, result rows may arrive as:
+ - real Python tuples, or
+ - row-like objects supporting `__len__` and `__getitem__`.
+
+ This helper centralizes validation so SSE/event-stream logic can assume a stable shape.
+ """
+
rows: list[tuple[ActivityEvent, Task | None]] = []
for item in items:
first: object
@@ -208,6 +236,12 @@ async def _lead_was_mentioned(
task: Task,
lead: Agent,
) -> bool:
+ """Return `True` if the lead agent is mentioned in any comment on the task.
+
+ This is used to avoid redundant lead pings (especially in auto-created tasks) while still
+ ensuring escalation happens when explicitly requested.
+ """
+
statement = (
select(ActivityEvent.message)
.where(col(ActivityEvent.task_id) == task.id)
@@ -224,6 +258,8 @@ async def _lead_was_mentioned(
def _lead_created_task(task: Task, lead: Agent) -> bool:
+ """Return `True` if `task` was auto-created by the lead agent."""
+
if not task.auto_created or not task.auto_reason:
return False
return task.auto_reason == f"lead_agent:{lead.id}"
@@ -237,6 +273,13 @@ async def _reconcile_dependents_for_dependency_toggle(
previous_status: str,
actor_agent_id: UUID | None,
) -> None:
+ """Apply dependency side-effects when a dependency task toggles done/undone.
+
+ The UI models dependencies as a DAG: when a dependency is reopened, dependents that were
+ previously marked done may need to be reopened or flagged. This helper keeps dependent state
+ consistent with the dependency graph without duplicating logic across endpoints.
+ """
+
done_toggled = (previous_status == "done") != (dependency_task.status == "done")
if not done_toggled:
return
From b7ad378655cdd2c38071134fd72f60ddeb36c4bb Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Wed, 11 Feb 2026 20:43:32 +0000
Subject: [PATCH 002/120] docs(provisioning): document workspace paths and
config patching
---
backend/app/services/openclaw/provisioning.py | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py
index 96becad5..16b134d8 100644
--- a/backend/app/services/openclaw/provisioning.py
+++ b/backend/app/services/openclaw/provisioning.py
@@ -89,27 +89,47 @@ def _heartbeat_config(agent: Agent) -> dict[str, Any]:
def _channel_heartbeat_visibility_patch(config_data: dict[str, Any]) -> dict[str, Any] | None:
+ """Build a minimal patch ensuring channel default heartbeat visibility is configured.
+
+ Gateways may have existing channel config; we only want to fill missing keys rather than
+ overwrite operator intent.
+
+ Returns:
+ - `None` if no change is needed
+ - a shallow patch dict suitable for a config merge otherwise
+ """
+
channels = config_data.get("channels")
if not isinstance(channels, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
+
defaults = channels.get("defaults")
if not isinstance(defaults, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
+
heartbeat = defaults.get("heartbeat")
if not isinstance(heartbeat, dict):
return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}}
+
merged = dict(heartbeat)
changed = False
for key, value in DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.items():
if key not in merged:
merged[key] = value
changed = True
+
if not changed:
return None
+
return {"defaults": {"heartbeat": merged}}
def _template_env() -> Environment:
+ """Create the Jinja environment used for gateway template rendering.
+
+ Note: we intentionally disable auto-escaping so markdown/plaintext templates render verbatim.
+ """
+
return Environment(
loader=FileSystemLoader(_templates_root()),
# Render markdown verbatim (HTML escaping makes it harder for agents to read).
@@ -124,19 +144,34 @@ def _heartbeat_template_name(agent: Agent) -> str:
def _workspace_path(agent: Agent, workspace_root: str) -> str:
+ """Return the absolute on-disk workspace directory for an agent.
+
+ Why this exists:
+ - We derive the folder name from a stable *agent key* (ultimately rooted in ids/session keys)
+ rather than display names to avoid collisions.
+ - We preserve a historical gateway-main naming quirk to avoid moving existing directories.
+
+ This path is later interpolated into template files (TOOLS.md, etc.) that agents treat as the
+ source of truth for where to read/write.
+ """
+
if not workspace_root:
msg = "gateway_workspace_root is required"
raise ValueError(msg)
+
root = workspace_root.rstrip("/")
+
# Use agent key derived from session key when possible. This prevents collisions for
# lead agents (session key includes board id) even if multiple boards share the same
# display name (e.g. "Lead Agent").
key = _agent_key(agent)
+
# Backwards-compat: gateway-main agents historically used session keys that encoded
# "gateway-" while the gateway agent id is "mc-gateway-".
# Keep the on-disk workspace path stable so existing provisioned files aren't moved.
if key.startswith("mc-gateway-"):
key = key.removeprefix("mc-")
+
return f"{root}/workspace-{slugify(key)}"
From bf7b992cca9f3239586439094eb1e2eea295a7f5 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Wed, 11 Feb 2026 21:45:53 +0000
Subject: [PATCH 003/120] docs(auth): clarify bearer parsing and Clerk claim
extraction
---
backend/app/core/auth.py | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index 256e2bd8..7007b876 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -66,6 +66,13 @@ class AuthContext:
def _extract_bearer_token(authorization: str | None) -> str | None:
+ """Extract the bearer token from an `Authorization` header.
+
+ Returns `None` for missing/empty headers or non-bearer schemes.
+
+ Note: we do *not* validate the token here; this helper is only responsible for parsing.
+ """
+
if not authorization:
return None
value = authorization.strip()
@@ -92,6 +99,14 @@ def _normalize_email(value: object) -> str | None:
def _extract_claim_email(claims: dict[str, object]) -> str | None:
+ """Best-effort extraction of an email address from Clerk/JWT-like claims.
+
+ Clerk payloads vary depending on token type and SDK version. We try common flat keys first,
+ then fall back to an `email_addresses` list (either strings or dict-like entries).
+
+ Returns a normalized lowercase email or `None`.
+ """
+
for key in ("email", "email_address", "primary_email_address"):
email = _normalize_email(claims.get(key))
if email:
@@ -119,10 +134,13 @@ def _extract_claim_email(claims: dict[str, object]) -> str | None:
return candidate
if fallback_email is None:
fallback_email = candidate
+
return fallback_email
def _extract_claim_name(claims: dict[str, object]) -> str | None:
+ """Best-effort extraction of a display name from Clerk/JWT-like claims."""
+
for key in ("name", "full_name"):
text = _non_empty_str(claims.get(key))
if text:
@@ -137,6 +155,17 @@ def _extract_claim_name(claims: dict[str, object]) -> str | None:
def _extract_clerk_profile(profile: ClerkUser | None) -> tuple[str | None, str | None]:
+ """Extract `(email, name)` from a Clerk user profile.
+
+ The Clerk SDK surface is not perfectly consistent across environments:
+ - some fields may be absent,
+ - email addresses may be represented as strings or objects,
+ - the "primary" email may be identified by id.
+
+ This helper implements a defensive, best-effort extraction strategy and returns `(None, None)`
+ when the profile is unavailable.
+ """
+
if profile is None:
return None, None
From ea7a5df1ae93b625118aa65944a989f1e2ff8e94 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Wed, 11 Feb 2026 20:47:06 +0000
Subject: [PATCH 004/120] test(e2e): add boards list + board task flows
(stubbed)
---
frontend/cypress/e2e/board_tasks.cy.ts | 140 +++++++++++++++++++++++++
frontend/cypress/e2e/boards_list.cy.ts | 68 ++++++++++++
2 files changed, 208 insertions(+)
create mode 100644 frontend/cypress/e2e/board_tasks.cy.ts
create mode 100644 frontend/cypress/e2e/boards_list.cy.ts
diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts
new file mode 100644
index 00000000..937682f8
--- /dev/null
+++ b/frontend/cypress/e2e/board_tasks.cy.ts
@@ -0,0 +1,140 @@
+///
+
+describe("/boards/:id task board", () => {
+ const apiBase = "**/api/v1";
+ const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
+
+ const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout");
+
+ beforeEach(() => {
+ Cypress.config("defaultCommandTimeout", 20_000);
+ });
+
+ afterEach(() => {
+ Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout);
+ });
+
+ function stubEmptySse() {
+ // Any SSE endpoint should not hang the UI in tests.
+ cy.intercept("GET", `${apiBase}/**/stream*`, {
+ statusCode: 200,
+ headers: { "content-type": "text/event-stream" },
+ body: "",
+ });
+ }
+
+ it("auth negative: signed-out user is redirected to sign-in", () => {
+ cy.visit("/boards/b1");
+ cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/);
+ });
+
+ it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => {
+ stubEmptySse();
+
+ cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, {
+ statusCode: 200,
+ body: {
+ board: {
+ id: "b1",
+ name: "Demo Board",
+ slug: "demo-board",
+ description: "Demo",
+ gateway_id: "g1",
+ board_group_id: null,
+ board_type: "general",
+ objective: null,
+ success_metrics: null,
+ target_date: null,
+ goal_confirmed: true,
+ goal_source: "test",
+ organization_id: "o1",
+ created_at: "2026-02-11T00:00:00Z",
+ updated_at: "2026-02-11T00:00:00Z",
+ },
+ tasks: [
+ {
+ id: "t1",
+ board_id: "b1",
+ title: "Inbox task",
+ description: "",
+ status: "inbox",
+ priority: "medium",
+ due_at: null,
+ assigned_agent_id: null,
+ depends_on_task_ids: [],
+ created_by_user_id: null,
+ in_progress_at: null,
+ created_at: "2026-02-11T00:00:00Z",
+ updated_at: "2026-02-11T00:00:00Z",
+ blocked_by_task_ids: [],
+ is_blocked: false,
+ assignee: null,
+ approvals_count: 0,
+ approvals_pending_count: 0,
+ },
+ ],
+ agents: [],
+ approvals: [],
+ chat_messages: [],
+ pending_approvals_count: 0,
+ },
+ }).as("snapshot");
+
+ cy.intercept("GET", `${apiBase}/boards/b1/group-snapshot*`, {
+ statusCode: 200,
+ body: { group: null, boards: [] },
+ });
+
+ cy.intercept("POST", `${apiBase}/boards/b1/tasks`, (req) => {
+ // Minimal assertion the UI sends expected fields.
+ expect(req.body).to.have.property("title");
+ req.reply({
+ statusCode: 200,
+ body: {
+ id: "t2",
+ board_id: "b1",
+ title: req.body.title,
+ description: req.body.description ?? "",
+ status: "inbox",
+ priority: req.body.priority ?? "medium",
+ due_at: null,
+ assigned_agent_id: null,
+ depends_on_task_ids: [],
+ created_by_user_id: null,
+ in_progress_at: null,
+ created_at: "2026-02-11T00:00:00Z",
+ updated_at: "2026-02-11T00:00:00Z",
+ blocked_by_task_ids: [],
+ is_blocked: false,
+ assignee: null,
+ approvals_count: 0,
+ approvals_pending_count: 0,
+ },
+ });
+ }).as("createTask");
+
+ cy.visit("/sign-in");
+ cy.clerkLoaded();
+ cy.clerkSignIn({ strategy: "email_code", identifier: email });
+
+ cy.visit("/boards/b1");
+ cy.waitForAppLoaded();
+
+ cy.wait(["@snapshot"]);
+
+ // Existing task visible.
+ cy.contains("Inbox task").should("be.visible");
+
+ // Open create task flow.
+ cy.contains("button", /create task|new task|add task|\+/i)
+ .first()
+ .click({ force: true });
+
+ cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task");
+
+ cy.contains("button", /create|save|add/i).click({ force: true });
+ cy.wait(["@createTask"]);
+
+ cy.contains("New task").should("be.visible");
+ });
+});
diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts
new file mode 100644
index 00000000..e8a01387
--- /dev/null
+++ b/frontend/cypress/e2e/boards_list.cy.ts
@@ -0,0 +1,68 @@
+///
+
+describe("/boards", () => {
+ const apiBase = "**/api/v1";
+ const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
+
+ const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout");
+
+ beforeEach(() => {
+ Cypress.config("defaultCommandTimeout", 20_000);
+ });
+
+ afterEach(() => {
+ Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout);
+ });
+
+ it("auth negative: signed-out user is redirected to sign-in", () => {
+ cy.visit("/boards");
+ cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/);
+ });
+
+ it("happy path: signed-in user sees boards list", () => {
+ cy.intercept("GET", `${apiBase}/boards*`, {
+ statusCode: 200,
+ body: {
+ items: [
+ {
+ id: "b1",
+ name: "Demo Board",
+ slug: "demo-board",
+ description: "Demo",
+ gateway_id: "g1",
+ board_group_id: null,
+ board_type: "general",
+ objective: null,
+ success_metrics: null,
+ target_date: null,
+ goal_confirmed: true,
+ goal_source: "test",
+ organization_id: "o1",
+ created_at: "2026-02-11T00:00:00Z",
+ updated_at: "2026-02-11T00:00:00Z",
+ },
+ ],
+ total: 1,
+ limit: 200,
+ offset: 0,
+ },
+ }).as("boards");
+
+ cy.intercept("GET", `${apiBase}/board-groups*`, {
+ statusCode: 200,
+ body: { items: [], total: 0, limit: 200, offset: 0 },
+ }).as("boardGroups");
+
+ cy.visit("/sign-in");
+ cy.clerkLoaded();
+ cy.clerkSignIn({ strategy: "email_code", identifier: email });
+
+ cy.visit("/boards");
+ cy.waitForAppLoaded();
+
+ cy.wait(["@boards", "@boardGroups"]);
+
+ cy.contains(/boards/i).should("be.visible");
+ cy.contains("Demo Board").should("be.visible");
+ });
+});
From 584dac18552cedef1d9d50b5f5348c24387bb498 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Wed, 11 Feb 2026 23:42:26 +0000
Subject: [PATCH 005/120] test(e2e): cover task status update + delete
(stubbed)
---
frontend/cypress/e2e/board_tasks.cy.ts | 70 ++++++++++++++++++++++++--
1 file changed, 67 insertions(+), 3 deletions(-)
diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts
index 937682f8..aedc6f10 100644
--- a/frontend/cypress/e2e/board_tasks.cy.ts
+++ b/frontend/cypress/e2e/board_tasks.cy.ts
@@ -2,7 +2,8 @@
describe("/boards/:id task board", () => {
const apiBase = "**/api/v1";
- const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
+ const email =
+ Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout");
@@ -28,7 +29,7 @@ describe("/boards/:id task board", () => {
cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/);
});
- it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => {
+ it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => {
stubEmptySse();
cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, {
@@ -113,6 +114,38 @@ describe("/boards/:id task board", () => {
});
}).as("createTask");
+ cy.intercept("PATCH", `${apiBase}/boards/b1/tasks/t1`, (req) => {
+ expect(req.body).to.have.property("status");
+ req.reply({
+ statusCode: 200,
+ body: {
+ id: "t1",
+ board_id: "b1",
+ title: "Inbox task",
+ description: "",
+ status: req.body.status,
+ priority: "medium",
+ due_at: null,
+ assigned_agent_id: null,
+ depends_on_task_ids: [],
+ created_by_user_id: null,
+ in_progress_at: null,
+ created_at: "2026-02-11T00:00:00Z",
+ updated_at: "2026-02-11T00:00:01Z",
+ blocked_by_task_ids: [],
+ is_blocked: false,
+ assignee: null,
+ approvals_count: 0,
+ approvals_pending_count: 0,
+ },
+ });
+ }).as("updateTask");
+
+ cy.intercept("DELETE", `${apiBase}/boards/b1/tasks/t1`, {
+ statusCode: 200,
+ body: { ok: true },
+ }).as("deleteTask");
+
cy.visit("/sign-in");
cy.clerkLoaded();
cy.clerkSignIn({ strategy: "email_code", identifier: email });
@@ -130,11 +163,42 @@ describe("/boards/:id task board", () => {
.first()
.click({ force: true });
- cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task");
+ cy.get("input")
+ .filter(
+ '[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]',
+ )
+ .first()
+ .type("New task");
cy.contains("button", /create|save|add/i).click({ force: true });
cy.wait(["@createTask"]);
cy.contains("New task").should("be.visible");
+
+ // Open edit task dialog.
+ cy.contains("Inbox task").click({ force: true });
+ cy.contains("Edit task").should("be.visible");
+
+ // Change status via Status select.
+ cy.contains("label", "Status")
+ .parent()
+ .within(() => {
+ cy.get("button").first().click({ force: true });
+ });
+
+ cy.contains("In progress").click({ force: true });
+
+ cy.contains("button", /save changes/i).click({ force: true });
+ cy.wait(["@updateTask"]);
+
+ // Delete task via delete dialog.
+ cy.contains("button", /^Delete task$/).click({ force: true });
+ cy.get('[aria-label="Delete task"]').should("be.visible");
+ cy.get('[aria-label="Delete task"]').within(() => {
+ cy.contains("button", /^Delete task$/).click({ force: true });
+ });
+ cy.wait(["@deleteTask"]);
+
+ cy.contains("Inbox task").should("not.exist");
});
});
From 4a6f77eed36ed81459fc0efda99a9adf3f0e2aeb Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Thu, 12 Feb 2026 09:10:42 +0000
Subject: [PATCH 006/120] test(e2e): select New task button by aria-label
---
frontend/cypress/e2e/board_tasks.cy.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts
index aedc6f10..464dce21 100644
--- a/frontend/cypress/e2e/board_tasks.cy.ts
+++ b/frontend/cypress/e2e/board_tasks.cy.ts
@@ -159,9 +159,8 @@ describe("/boards/:id task board", () => {
cy.contains("Inbox task").should("be.visible");
// Open create task flow.
- cy.contains("button", /create task|new task|add task|\+/i)
- .first()
- .click({ force: true });
+ // Board page uses an icon-only button with aria-label="New task".
+ cy.get('button[aria-label="New task"]').click({ force: true });
cy.get("input")
.filter(
From 9a4dabb9e9a5f655736b5169ffaf1ab7b674a82d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Feb 2026 02:50:48 +0000
Subject: [PATCH 007/120] Initial plan
From 07190dca04d0b4e24b936386ee621b5e9c52bbcd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Feb 2026 02:54:17 +0000
Subject: [PATCH 008/120] fix: add operator.read scope to gateway connection
scopes
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
backend/app/services/openclaw/gateway_rpc.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py
index 4807282f..a4fdefa1 100644
--- a/backend/app/services/openclaw/gateway_rpc.py
+++ b/backend/app/services/openclaw/gateway_rpc.py
@@ -23,6 +23,7 @@ from app.core.logging import TRACE_LEVEL, get_logger
PROTOCOL_VERSION = 3
logger = get_logger(__name__)
GATEWAY_OPERATOR_SCOPES = (
+ "operator.read",
"operator.admin",
"operator.approvals",
"operator.pairing",
From 6455a271765643fe5619b36712546d3e39f910d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 05:22:11 +0000
Subject: [PATCH 009/120] Initial plan
From 520e128777ba7bfd5f9e34c47a87761de70f8078 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 05:28:37 +0000
Subject: [PATCH 010/120] feat: Add allow_insecure_tls field to gateway model
and UI
- Added allow_insecure_tls boolean field to Gateway model and schemas
- Created database migration for the new field
- Updated GatewayConfig to include allow_insecure_tls parameter
- Modified openclaw_call to create SSL context that disables verification when allow_insecure_tls is true
- Updated all GatewayConfig instantiations throughout the backend
- Added checkbox to frontend gateway form (create and edit pages)
- Updated API endpoints to handle the new field
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
backend/app/api/gateways.py | 11 ++++--
backend/app/models/gateways.py | 1 +
backend/app/schemas/gateways.py | 2 +
.../app/services/openclaw/admin_service.py | 10 +++--
.../app/services/openclaw/gateway_resolver.py | 8 +++-
backend/app/services/openclaw/gateway_rpc.py | 31 +++++++++++++++-
backend/app/services/openclaw/provisioning.py | 8 +++-
.../app/services/openclaw/provisioning_db.py | 6 ++-
...4a5b6c7d_add_gateway_allow_insecure_tls.py | 37 +++++++++++++++++++
.../app/gateways/[gatewayId]/edit/page.tsx | 8 ++++
frontend/src/app/gateways/new/page.tsx | 4 ++
.../src/components/gateways/GatewayForm.tsx | 22 +++++++++++
12 files changed, 135 insertions(+), 13 deletions(-)
create mode 100644 backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py
diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py
index 3f756ce7..1ba59e44 100644
--- a/backend/app/api/gateways.py
+++ b/backend/app/api/gateways.py
@@ -94,7 +94,9 @@ async def create_gateway(
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session)
- await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token)
+ await service.assert_gateway_runtime_compatible(
+ url=payload.url, token=payload.token, allow_insecure_tls=payload.allow_insecure_tls
+ )
data = payload.model_dump()
gateway_id = uuid4()
data["id"] = gateway_id
@@ -134,12 +136,15 @@ async def update_gateway(
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
- if "url" in updates or "token" in updates:
+ if "url" in updates or "token" in updates or "allow_insecure_tls" in updates:
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
+ next_allow_insecure_tls = updates.get("allow_insecure_tls", gateway.allow_insecure_tls)
if next_url:
- await service.assert_gateway_runtime_compatible(url=next_url, token=next_token)
+ await service.assert_gateway_runtime_compatible(
+ url=next_url, token=next_token, allow_insecure_tls=next_allow_insecure_tls
+ )
await crud.patch(session, gateway, updates)
await service.ensure_main_agent(gateway, auth, action="update")
return gateway
diff --git a/backend/app/models/gateways.py b/backend/app/models/gateways.py
index 954f144f..19240567 100644
--- a/backend/app/models/gateways.py
+++ b/backend/app/models/gateways.py
@@ -24,5 +24,6 @@ class Gateway(QueryModel, table=True):
url: str
token: str | None = Field(default=None)
workspace_root: str
+ allow_insecure_tls: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
diff --git a/backend/app/schemas/gateways.py b/backend/app/schemas/gateways.py
index 233a44d5..241d370b 100644
--- a/backend/app/schemas/gateways.py
+++ b/backend/app/schemas/gateways.py
@@ -17,6 +17,7 @@ class GatewayBase(SQLModel):
name: str
url: str
workspace_root: str
+ allow_insecure_tls: bool = False
class GatewayCreate(GatewayBase):
@@ -43,6 +44,7 @@ class GatewayUpdate(SQLModel):
url: str | None = None
token: str | None = None
workspace_root: str | None = None
+ allow_insecure_tls: bool | None = None
@field_validator("token", mode="before")
@classmethod
diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py
index dfc0c2b6..e248f888 100644
--- a/backend/app/services/openclaw/admin_service.py
+++ b/backend/app/services/openclaw/admin_service.py
@@ -167,7 +167,9 @@ class GatewayAdminLifecycleService(OpenClawDBService):
async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool:
if not gateway.url:
return False
- config = GatewayClientConfig(url=gateway.url, token=gateway.token)
+ config = GatewayClientConfig(
+ url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
+ )
target_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
try:
await openclaw_call("agents.files.list", {"agentId": target_id}, config=config)
@@ -178,9 +180,11 @@ class GatewayAdminLifecycleService(OpenClawDBService):
return True
return True
- async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None:
+ async def assert_gateway_runtime_compatible(
+ self, *, url: str, token: str | None, allow_insecure_tls: bool = False
+ ) -> None:
"""Validate that a gateway runtime meets minimum supported version."""
- config = GatewayClientConfig(url=url, token=token)
+ config = GatewayClientConfig(url=url, token=token, allow_insecure_tls=allow_insecure_tls)
try:
result = await check_gateway_runtime_compatibility(config)
except OpenClawGatewayError as exc:
diff --git a/backend/app/services/openclaw/gateway_resolver.py b/backend/app/services/openclaw/gateway_resolver.py
index 7e31814f..6a91b63d 100644
--- a/backend/app/services/openclaw/gateway_resolver.py
+++ b/backend/app/services/openclaw/gateway_resolver.py
@@ -32,7 +32,9 @@ def gateway_client_config(gateway: Gateway) -> GatewayClientConfig:
detail="Gateway url is required",
)
token = (gateway.token or "").strip() or None
- return GatewayClientConfig(url=url, token=token)
+ return GatewayClientConfig(
+ url=url, token=token, allow_insecure_tls=gateway.allow_insecure_tls
+ )
def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConfig | None:
@@ -43,7 +45,9 @@ def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConf
if not url:
return None
token = (gateway.token or "").strip() or None
- return GatewayClientConfig(url=url, token=token)
+ return GatewayClientConfig(
+ url=url, token=token, allow_insecure_tls=gateway.allow_insecure_tls
+ )
def require_gateway_workspace_root(gateway: Gateway) -> str:
diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py
index a4fdefa1..62a2844d 100644
--- a/backend/app/services/openclaw/gateway_rpc.py
+++ b/backend/app/services/openclaw/gateway_rpc.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import json
+import ssl
from dataclasses import dataclass
from time import perf_counter
from typing import Any
@@ -160,6 +161,7 @@ class GatewayConfig:
url: str
token: str | None = None
+ allow_insecure_tls: bool = False
def _build_gateway_url(config: GatewayConfig) -> str:
@@ -180,6 +182,27 @@ def _redacted_url_for_log(raw_url: str) -> str:
return str(urlunparse(parsed._replace(query="", fragment="")))
+def _create_ssl_context(config: GatewayConfig) -> ssl.SSLContext | None:
+ """Create SSL context for websocket connection.
+
+ Returns None for non-SSL connections (ws://) or an SSL context for wss://.
+ If allow_insecure_tls is True, the context will not verify certificates.
+ """
+ parsed = urlparse(config.url)
+ if parsed.scheme != "wss":
+ return None
+
+ if config.allow_insecure_tls:
+ # Create SSL context that doesn't verify certificates
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ return ssl_context
+
+ # Use default SSL context with certificate verification
+ return None
+
+
async def _await_response(
ws: websockets.ClientConnection,
request_id: str,
@@ -283,14 +306,18 @@ async def openclaw_call(
) -> object:
"""Call a gateway RPC method and return the result payload."""
gateway_url = _build_gateway_url(config)
+ ssl_context = _create_ssl_context(config)
started_at = perf_counter()
logger.debug(
- "gateway.rpc.call.start method=%s gateway_url=%s",
+ "gateway.rpc.call.start method=%s gateway_url=%s allow_insecure_tls=%s",
method,
_redacted_url_for_log(gateway_url),
+ config.allow_insecure_tls,
)
try:
- async with websockets.connect(gateway_url, ping_interval=None) as ws:
+ async with websockets.connect(
+ gateway_url, ping_interval=None, ssl=ssl_context
+ ) as ws:
first_message = None
try:
first_message = await asyncio.wait_for(ws.recv(), timeout=2)
diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py
index 0d2534a3..6544214f 100644
--- a/backend/app/services/openclaw/provisioning.py
+++ b/backend/app/services/openclaw/provisioning.py
@@ -970,7 +970,9 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
return OpenClawGatewayControlPlane(
- GatewayClientConfig(url=gateway.url, token=gateway.token),
+ GatewayClientConfig(
+ url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
+ ),
)
@@ -1099,7 +1101,9 @@ class OpenClawGatewayProvisioner:
if not wake:
return
- client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
+ client_config = GatewayClientConfig(
+ url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
+ )
await ensure_session(session_key, config=client_config, label=agent.name)
verb = wakeup_verb or ("provisioned" if action == "provision" else "updated")
await send_message(
diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py
index 97fcb8f6..139fd8c0 100644
--- a/backend/app/services/openclaw/provisioning_db.py
+++ b/backend/app/services/openclaw/provisioning_db.py
@@ -285,7 +285,11 @@ class OpenClawProvisioningService(OpenClawDBService):
return result
control_plane = OpenClawGatewayControlPlane(
- GatewayClientConfig(url=gateway.url, token=gateway.token),
+ GatewayClientConfig(
+ url=gateway.url,
+ token=gateway.token,
+ allow_insecure_tls=gateway.allow_insecure_tls,
+ ),
)
ctx = _SyncContext(
session=self.session,
diff --git a/backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py b/backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py
new file mode 100644
index 00000000..48174e8c
--- /dev/null
+++ b/backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py
@@ -0,0 +1,37 @@
+"""Add allow_insecure_tls field to gateways.
+
+Revision ID: 2f3e4a5b6c7d
+Revises: 1a7b2c3d4e5f
+Create Date: 2026-02-22 05:30:00.000000
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "2f3e4a5b6c7d"
+down_revision = "1a7b2c3d4e5f"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Add gateways.allow_insecure_tls column with default False."""
+ op.add_column(
+ "gateways",
+ sa.Column(
+ "allow_insecure_tls",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.alter_column("gateways", "allow_insecure_tls", server_default=None)
+
+
+def downgrade() -> None:
+ """Remove gateways.allow_insecure_tls column."""
+ op.drop_column("gateways", "allow_insecure_tls")
diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
index 6b4e2120..c699c675 100644
--- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
@@ -43,6 +43,9 @@ export default function EditGatewayPage() {
const [workspaceRoot, setWorkspaceRoot] = useState(
undefined,
);
+ const [allowInsecureTls, setAllowInsecureTls] = useState(
+ undefined,
+ );
const [gatewayUrlError, setGatewayUrlError] = useState(null);
const [gatewayCheckStatus, setGatewayCheckStatus] =
@@ -84,6 +87,8 @@ export default function EditGatewayPage() {
const resolvedGatewayToken = gatewayToken ?? loadedGateway?.token ?? "";
const resolvedWorkspaceRoot =
workspaceRoot ?? loadedGateway?.workspace_root ?? DEFAULT_WORKSPACE_ROOT;
+ const resolvedAllowInsecureTls =
+ allowInsecureTls ?? loadedGateway?.allow_insecure_tls ?? false;
const isLoading = gatewayQuery.isLoading || updateMutation.isPending;
const errorMessage = error ?? gatewayQuery.error?.message ?? null;
@@ -140,6 +145,7 @@ export default function EditGatewayPage() {
url: resolvedGatewayUrl.trim(),
token: resolvedGatewayToken.trim() || null,
workspace_root: resolvedWorkspaceRoot.trim(),
+ allow_insecure_tls: resolvedAllowInsecureTls,
};
updateMutation.mutate({ gatewayId, data: payload });
@@ -165,6 +171,7 @@ export default function EditGatewayPage() {
gatewayUrl={resolvedGatewayUrl}
gatewayToken={resolvedGatewayToken}
workspaceRoot={resolvedWorkspaceRoot}
+ allowInsecureTls={resolvedAllowInsecureTls}
gatewayUrlError={gatewayUrlError}
gatewayCheckStatus={gatewayCheckStatus}
gatewayCheckMessage={gatewayCheckMessage}
@@ -191,6 +198,7 @@ export default function EditGatewayPage() {
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
+ onAllowInsecureTlsChange={setAllowInsecureTls}
/>
);
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx
index f72db43e..3d6bc1e3 100644
--- a/frontend/src/app/gateways/new/page.tsx
+++ b/frontend/src/app/gateways/new/page.tsx
@@ -29,6 +29,7 @@ export default function NewGatewayPage() {
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
+ const [allowInsecureTls, setAllowInsecureTls] = useState(false);
const [gatewayUrlError, setGatewayUrlError] = useState(null);
const [gatewayCheckStatus, setGatewayCheckStatus] =
@@ -106,6 +107,7 @@ export default function NewGatewayPage() {
url: gatewayUrl.trim(),
token: gatewayToken.trim() || null,
workspace_root: workspaceRoot.trim(),
+ allow_insecure_tls: allowInsecureTls,
},
});
};
@@ -126,6 +128,7 @@ export default function NewGatewayPage() {
gatewayUrl={gatewayUrl}
gatewayToken={gatewayToken}
workspaceRoot={workspaceRoot}
+ allowInsecureTls={allowInsecureTls}
gatewayUrlError={gatewayUrlError}
gatewayCheckStatus={gatewayCheckStatus}
gatewayCheckMessage={gatewayCheckMessage}
@@ -152,6 +155,7 @@ export default function NewGatewayPage() {
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
+ onAllowInsecureTlsChange={setAllowInsecureTls}
/>
);
diff --git a/frontend/src/components/gateways/GatewayForm.tsx b/frontend/src/components/gateways/GatewayForm.tsx
index f5068faf..81854918 100644
--- a/frontend/src/components/gateways/GatewayForm.tsx
+++ b/frontend/src/components/gateways/GatewayForm.tsx
@@ -10,6 +10,7 @@ type GatewayFormProps = {
gatewayUrl: string;
gatewayToken: string;
workspaceRoot: string;
+ allowInsecureTls: boolean;
gatewayUrlError: string | null;
gatewayCheckStatus: GatewayCheckStatus;
gatewayCheckMessage: string | null;
@@ -27,6 +28,7 @@ type GatewayFormProps = {
onGatewayUrlChange: (next: string) => void;
onGatewayTokenChange: (next: string) => void;
onWorkspaceRootChange: (next: string) => void;
+ onAllowInsecureTlsChange: (next: boolean) => void;
};
export function GatewayForm({
@@ -34,6 +36,7 @@ export function GatewayForm({
gatewayUrl,
gatewayToken,
workspaceRoot,
+ allowInsecureTls,
gatewayUrlError,
gatewayCheckStatus,
gatewayCheckMessage,
@@ -51,6 +54,7 @@ export function GatewayForm({
onGatewayUrlChange,
onGatewayTokenChange,
onWorkspaceRootChange,
+ onAllowInsecureTlsChange,
}: GatewayFormProps) {
return (
+
+ onAllowInsecureTlsChange(event.target.checked)}
+ disabled={isLoading}
+ />
+
+
+
{errorMessage ? (
{errorMessage}
) : null}
From 42a6a42902ffdc34385fdcfb004f66ffbfca7e9f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 05:29:32 +0000
Subject: [PATCH 011/120] test: Add tests for SSL context configuration
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
backend/tests/test_gateway_ssl_context.py | 54 +++++++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 backend/tests/test_gateway_ssl_context.py
diff --git a/backend/tests/test_gateway_ssl_context.py b/backend/tests/test_gateway_ssl_context.py
new file mode 100644
index 00000000..b5c4b667
--- /dev/null
+++ b/backend/tests/test_gateway_ssl_context.py
@@ -0,0 +1,54 @@
+"""Tests for SSL/TLS configuration in gateway RPC connections."""
+
+from __future__ import annotations
+
+import ssl
+
+from app.services.openclaw.gateway_rpc import GatewayConfig, _create_ssl_context
+
+
+def test_create_ssl_context_returns_none_for_ws_protocol() -> None:
+ """SSL context should be None for non-secure websocket connections."""
+ config = GatewayConfig(url="ws://gateway.example:18789/ws")
+ ssl_context = _create_ssl_context(config)
+
+ assert ssl_context is None
+
+
+def test_create_ssl_context_returns_none_for_wss_with_secure_mode() -> None:
+ """SSL context should be None for wss:// with default verification (secure mode)."""
+ config = GatewayConfig(url="wss://gateway.example:18789/ws", allow_insecure_tls=False)
+ ssl_context = _create_ssl_context(config)
+
+ assert ssl_context is None
+
+
+def test_create_ssl_context_disables_verification_when_allow_insecure_tls_true() -> None:
+ """SSL context should disable certificate verification when allow_insecure_tls is True."""
+ config = GatewayConfig(url="wss://gateway.example:18789/ws", allow_insecure_tls=True)
+ ssl_context = _create_ssl_context(config)
+
+ assert ssl_context is not None
+ assert isinstance(ssl_context, ssl.SSLContext)
+ assert ssl_context.check_hostname is False
+ assert ssl_context.verify_mode == ssl.CERT_NONE
+
+
+def test_create_ssl_context_respects_localhost_with_insecure_flag() -> None:
+ """SSL context for localhost should respect allow_insecure_tls flag."""
+ config = GatewayConfig(url="wss://localhost:18789/ws", allow_insecure_tls=True)
+ ssl_context = _create_ssl_context(config)
+
+ assert ssl_context is not None
+ assert ssl_context.check_hostname is False
+ assert ssl_context.verify_mode == ssl.CERT_NONE
+
+
+def test_create_ssl_context_respects_ip_address_with_insecure_flag() -> None:
+ """SSL context for IP addresses should respect allow_insecure_tls flag."""
+ config = GatewayConfig(url="wss://192.168.1.100:18789/ws", allow_insecure_tls=True)
+ ssl_context = _create_ssl_context(config)
+
+ assert ssl_context is not None
+ assert ssl_context.check_hostname is False
+ assert ssl_context.verify_mode == ssl.CERT_NONE
From 217f98afcc27ee97d7cf571ac300ad9cdab2ccb5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 05:30:29 +0000
Subject: [PATCH 012/120] fix: Address code review feedback
- Fix docstring formatting in _create_ssl_context
- Break long line in provisioning.py for better readability
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
backend/app/services/openclaw/gateway_rpc.py | 2 +-
backend/app/services/openclaw/provisioning.py | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py
index 62a2844d..bafb0ab2 100644
--- a/backend/app/services/openclaw/gateway_rpc.py
+++ b/backend/app/services/openclaw/gateway_rpc.py
@@ -184,7 +184,7 @@ def _redacted_url_for_log(raw_url: str) -> str:
def _create_ssl_context(config: GatewayConfig) -> ssl.SSLContext | None:
"""Create SSL context for websocket connection.
-
+
Returns None for non-SSL connections (ws://) or an SSL context for wss://.
If allow_insecure_tls is True, the context will not verify certificates.
"""
diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py
index 6544214f..a797edd5 100644
--- a/backend/app/services/openclaw/provisioning.py
+++ b/backend/app/services/openclaw/provisioning.py
@@ -971,7 +971,9 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
raise OpenClawGatewayError(msg)
return OpenClawGatewayControlPlane(
GatewayClientConfig(
- url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls
+ url=gateway.url,
+ token=gateway.token,
+ allow_insecure_tls=gateway.allow_insecure_tls,
),
)
From 87b210b34dfa708e49d176af6720a0a3ef62011d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 05:32:04 +0000
Subject: [PATCH 013/120] docs: Add documentation for self-signed TLS
certificate support
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
docs/openclaw_gateway_ws.md | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/docs/openclaw_gateway_ws.md b/docs/openclaw_gateway_ws.md
index 5fc7b84e..96ecf979 100644
--- a/docs/openclaw_gateway_ws.md
+++ b/docs/openclaw_gateway_ws.md
@@ -1,3 +1,31 @@
# Gateway WebSocket protocol
-Placeholder.
+## Connection Types
+
+OpenClaw Mission Control supports both secure (`wss://`) and non-secure (`ws://`) WebSocket connections to gateways.
+
+### Secure Connections (wss://)
+
+For production environments, always use `wss://` (WebSocket Secure) connections with valid TLS certificates.
+
+### Self-Signed Certificates
+
+For local development or trusted local networks, you can enable support for self-signed TLS certificates:
+
+1. Navigate to the gateway configuration page (Settings → Gateways)
+2. When creating or editing a gateway, check the box: **"Allow self-signed TLS certificates"**
+3. This option is useful for:
+ - Local development: `wss://localhost:18789`
+ - Trusted local networks: `wss://192.168.1.100:18789`
+
+**Security Warning**: Only enable this option for localhost or gateways on trusted local networks. Do not use self-signed certificates for production gateways accessible over the internet.
+
+## Configuration Options
+
+When configuring a gateway, you can specify:
+
+- **Gateway URL**: The WebSocket endpoint (e.g., `wss://localhost:18789` or `ws://gateway:18789`)
+- **Gateway Token**: Optional authentication token
+- **Workspace Root**: The root directory for gateway files (e.g., `~/.openclaw`)
+- **Allow self-signed TLS certificates**: Enable/disable self-signed certificate support (default: disabled)
+
From c072749b35270fce13640e0bb9aa70817233ddcd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 07:37:25 +0000
Subject: [PATCH 014/120] fix: Update generated TypeScript API models for
allow_insecure_tls field
Co-authored-by: abhi1693 <5083532+abhi1693@users.noreply.github.com>
---
frontend/src/api/generated/model/gatewayCreate.ts | 1 +
frontend/src/api/generated/model/gatewayRead.ts | 1 +
frontend/src/api/generated/model/gatewayUpdate.ts | 1 +
3 files changed, 3 insertions(+)
diff --git a/frontend/src/api/generated/model/gatewayCreate.ts b/frontend/src/api/generated/model/gatewayCreate.ts
index 0fff7e4a..b12104e3 100644
--- a/frontend/src/api/generated/model/gatewayCreate.ts
+++ b/frontend/src/api/generated/model/gatewayCreate.ts
@@ -12,5 +12,6 @@ export interface GatewayCreate {
name: string;
url: string;
workspace_root: string;
+ allow_insecure_tls?: boolean;
token?: string | null;
}
diff --git a/frontend/src/api/generated/model/gatewayRead.ts b/frontend/src/api/generated/model/gatewayRead.ts
index 03dcc40c..3399fb51 100644
--- a/frontend/src/api/generated/model/gatewayRead.ts
+++ b/frontend/src/api/generated/model/gatewayRead.ts
@@ -12,6 +12,7 @@ export interface GatewayRead {
name: string;
url: string;
workspace_root: string;
+ allow_insecure_tls: boolean;
id: string;
organization_id: string;
token?: string | null;
diff --git a/frontend/src/api/generated/model/gatewayUpdate.ts b/frontend/src/api/generated/model/gatewayUpdate.ts
index e5f237ef..86241e3f 100644
--- a/frontend/src/api/generated/model/gatewayUpdate.ts
+++ b/frontend/src/api/generated/model/gatewayUpdate.ts
@@ -13,4 +13,5 @@ export interface GatewayUpdate {
url?: string | null;
token?: string | null;
workspace_root?: string | null;
+ allow_insecure_tls?: boolean | null;
}
From e39b2069fba7297f825359f914cfae3d7b1a3282 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 13:37:01 +0530
Subject: [PATCH 015/120] feat: add openclaw_connect_metadata function and
update compatibility check logic, fixes #156
---
.../app/services/openclaw/gateway_compat.py | 22 ++++++++-
backend/app/services/openclaw/gateway_rpc.py | 46 ++++++++++++++++++-
backend/tests/test_gateway_version_compat.py | 43 +++++++++++++++++
3 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/backend/app/services/openclaw/gateway_compat.py b/backend/app/services/openclaw/gateway_compat.py
index ace829c4..2ef7ff8d 100644
--- a/backend/app/services/openclaw/gateway_compat.py
+++ b/backend/app/services/openclaw/gateway_compat.py
@@ -7,7 +7,12 @@ from dataclasses import dataclass
from typing import Any
from app.core.config import settings
-from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError, openclaw_call
+from app.services.openclaw.gateway_rpc import (
+ GatewayConfig,
+ OpenClawGatewayError,
+ openclaw_call,
+ openclaw_connect_metadata,
+)
_VERSION_PATTERN = re.compile(r"(?i)v?(?P\d+(?:\.\d+)+)")
_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = (
@@ -192,12 +197,27 @@ async def _fetch_schema_metadata(config: GatewayConfig) -> object | None:
return None
+async def _fetch_connect_metadata(config: GatewayConfig) -> object | None:
+ try:
+ return await openclaw_connect_metadata(config=config)
+ except OpenClawGatewayError:
+ return None
+
+
async def check_gateway_runtime_compatibility(
config: GatewayConfig,
*,
minimum_version: str | None = None,
) -> GatewayVersionCheckResult:
"""Fetch runtime metadata and evaluate gateway version compatibility."""
+ connect_payload = await _fetch_connect_metadata(config)
+ current_version = extract_gateway_version(connect_payload)
+ if current_version is not None:
+ return evaluate_gateway_version(
+ current_version=current_version,
+ minimum_version=minimum_version,
+ )
+
schema_payload = await _fetch_schema_metadata(config)
current_version = extract_gateway_version(schema_payload)
if current_version is not None:
diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py
index a4fdefa1..0765d54d 100644
--- a/backend/app/services/openclaw/gateway_rpc.py
+++ b/backend/app/services/openclaw/gateway_rpc.py
@@ -253,7 +253,7 @@ async def _ensure_connected(
ws: websockets.ClientConnection,
first_message: str | bytes | None,
config: GatewayConfig,
-) -> None:
+) -> object:
if first_message:
if isinstance(first_message, bytes):
first_message = first_message.decode("utf-8")
@@ -272,7 +272,7 @@ async def _ensure_connected(
"params": _build_connect_params(config),
}
await ws.send(json.dumps(response))
- await _await_response(ws, connect_id)
+ return await _await_response(ws, connect_id)
async def openclaw_call(
@@ -327,6 +327,48 @@ async def openclaw_call(
raise OpenClawGatewayError(str(exc)) from exc
+async def openclaw_connect_metadata(*, config: GatewayConfig) -> object:
+ """Open a gateway connection and return the connect/hello payload."""
+ gateway_url = _build_gateway_url(config)
+ started_at = perf_counter()
+ logger.debug(
+ "gateway.rpc.connect_metadata.start gateway_url=%s",
+ _redacted_url_for_log(gateway_url),
+ )
+ try:
+ async with websockets.connect(gateway_url, ping_interval=None) as ws:
+ first_message = None
+ try:
+ first_message = await asyncio.wait_for(ws.recv(), timeout=2)
+ except TimeoutError:
+ first_message = None
+ metadata = await _ensure_connected(ws, first_message, config)
+ logger.debug(
+ "gateway.rpc.connect_metadata.success duration_ms=%s",
+ int((perf_counter() - started_at) * 1000),
+ )
+ return metadata
+ except OpenClawGatewayError:
+ logger.warning(
+ "gateway.rpc.connect_metadata.gateway_error duration_ms=%s",
+ int((perf_counter() - started_at) * 1000),
+ )
+ raise
+ except (
+ TimeoutError,
+ ConnectionError,
+ OSError,
+ ValueError,
+ WebSocketException,
+ ) as exc: # pragma: no cover - network/protocol errors
+ logger.error(
+ "gateway.rpc.connect_metadata.transport_error duration_ms=%s error_type=%s",
+ int((perf_counter() - started_at) * 1000),
+ exc.__class__.__name__,
+ )
+ raise OpenClawGatewayError(str(exc)) from exc
+
+
async def send_message(
message: str,
*,
diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py
index 37f177cb..c6e6b4d3 100644
--- a/backend/tests/test_gateway_version_compat.py
+++ b/backend/tests/test_gateway_version_compat.py
@@ -50,6 +50,11 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version(
return {"version": "2026.2.13"}
raise AssertionError(f"unexpected method: {method}")
+ async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
+ _ = config
+ return None
+
+ monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
@@ -62,6 +67,34 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version(
assert result.current_version == "2026.2.13"
+@pytest.mark.asyncio
+async def test_check_gateway_runtime_compatibility_prefers_connect_metadata(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[str] = []
+
+ async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
+ _ = config
+ return {"server": {"version": "2026.2.21-2"}}
+
+ async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
+ _ = (params, config)
+ calls.append(method)
+ raise AssertionError(f"unexpected method: {method}")
+
+ monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
+ monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
+
+ result = await gateway_compat.check_gateway_runtime_compatibility(
+ GatewayConfig(url="ws://gateway.example/ws"),
+ minimum_version="2026.1.30",
+ )
+
+ assert calls == []
+ assert result.compatible is True
+ assert result.current_version == "2026.2.21-2"
+
+
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_falls_back_to_health(
monkeypatch: pytest.MonkeyPatch,
@@ -77,6 +110,11 @@ async def test_check_gateway_runtime_compatibility_falls_back_to_health(
raise OpenClawGatewayError("unknown method")
return {"version": "2026.2.0"}
+ async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
+ _ = config
+ return None
+
+ monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
@@ -104,6 +142,11 @@ async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_n
return {"uptime": 1234}
return {"version": "2026.2.0"}
+ async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
+ _ = config
+ return None
+
+ monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
From 3dfb70cd907be6aa22277925fbc7658b78349175 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 19:19:26 +0530
Subject: [PATCH 016/120] feat: add disable_device_pairing option to gateway
configuration
---
backend/app/api/gateway.py | 2 +
backend/app/api/gateways.py | 21 +-
backend/app/models/gateways.py | 1 +
backend/app/schemas/gateway_api.py | 1 +
backend/app/schemas/gateways.py | 2 +
.../app/services/openclaw/admin_service.py | 24 +-
.../app/services/openclaw/device_identity.py | 163 +++++++++++++
.../app/services/openclaw/error_messages.py | 31 +++
.../app/services/openclaw/gateway_resolver.py | 12 +-
backend/app/services/openclaw/gateway_rpc.py | 197 +++++++++++++---
backend/app/services/openclaw/provisioning.py | 12 +-
.../app/services/openclaw/provisioning_db.py | 6 +-
.../app/services/openclaw/session_service.py | 8 +-
..._add_disable_device_pairing_to_gateways.py | 37 +++
backend/pyproject.toml | 1 +
backend/tests/test_gateway_device_identity.py | 67 ++++++
backend/tests/test_gateway_resolver.py | 64 ++++++
.../tests/test_gateway_rpc_connect_scopes.py | 176 +++++++++++++-
backend/tests/test_gateway_version_compat.py | 40 ++++
backend/uv.lock | 20 +-
docs/openclaw_baseline_config.md | 3 +
frontend/src/api/generated/agent/agent.ts | 214 ++++++++++++++++--
.../model/agentHealthStatusResponse.ts | 24 ++
.../src/api/generated/model/gatewayCreate.ts | 1 +
.../src/api/generated/model/gatewayRead.ts | 1 +
.../src/api/generated/model/gatewayUpdate.ts | 1 +
...ewaysStatusApiV1GatewaysStatusGetParams.ts | 1 +
frontend/src/api/generated/model/index.ts | 1 +
.../app/gateways/[gatewayId]/edit/page.tsx | 55 +++--
.../src/app/gateways/[gatewayId]/page.tsx | 9 +
frontend/src/app/gateways/new/page.tsx | 49 ++--
.../src/components/gateways/GatewayForm.tsx | 86 +++----
frontend/src/lib/gateway-form.test.ts | 69 ++++++
frontend/src/lib/gateway-form.ts | 8 +-
34 files changed, 1229 insertions(+), 178 deletions(-)
create mode 100644 backend/app/services/openclaw/device_identity.py
create mode 100644 backend/app/services/openclaw/error_messages.py
create mode 100644 backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py
create mode 100644 backend/tests/test_gateway_device_identity.py
create mode 100644 backend/tests/test_gateway_resolver.py
create mode 100644 frontend/src/api/generated/model/agentHealthStatusResponse.ts
create mode 100644 frontend/src/lib/gateway-form.test.ts
diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py
index 12b24117..5efc8552 100644
--- a/backend/app/api/gateway.py
+++ b/backend/app/api/gateway.py
@@ -37,11 +37,13 @@ def _query_to_resolve_input(
board_id: str | None = Query(default=None),
gateway_url: str | None = Query(default=None),
gateway_token: str | None = Query(default=None),
+ gateway_disable_device_pairing: bool = Query(default=False),
) -> GatewayResolveQuery:
return GatewaySessionService.to_resolve_query(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
+ gateway_disable_device_pairing=gateway_disable_device_pairing,
)
diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py
index 3f756ce7..1e567484 100644
--- a/backend/app/api/gateways.py
+++ b/backend/app/api/gateways.py
@@ -94,7 +94,11 @@ async def create_gateway(
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session)
- await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token)
+ await service.assert_gateway_runtime_compatible(
+ url=payload.url,
+ token=payload.token,
+ disable_device_pairing=payload.disable_device_pairing,
+ )
data = payload.model_dump()
gateway_id = uuid4()
data["id"] = gateway_id
@@ -134,12 +138,23 @@ async def update_gateway(
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
- if "url" in updates or "token" in updates:
+ if (
+ "url" in updates
+ or "token" in updates
+ or "disable_device_pairing" in updates
+ ):
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
+ next_disable_device_pairing = bool(
+ updates.get("disable_device_pairing", gateway.disable_device_pairing),
+ )
if next_url:
- await service.assert_gateway_runtime_compatible(url=next_url, token=next_token)
+ await service.assert_gateway_runtime_compatible(
+ url=next_url,
+ token=next_token,
+ disable_device_pairing=next_disable_device_pairing,
+ )
await crud.patch(session, gateway, updates)
await service.ensure_main_agent(gateway, auth, action="update")
return gateway
diff --git a/backend/app/models/gateways.py b/backend/app/models/gateways.py
index 954f144f..3e125195 100644
--- a/backend/app/models/gateways.py
+++ b/backend/app/models/gateways.py
@@ -23,6 +23,7 @@ class Gateway(QueryModel, table=True):
name: str
url: str
token: str | None = Field(default=None)
+ disable_device_pairing: bool = Field(default=False)
workspace_root: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
diff --git a/backend/app/schemas/gateway_api.py b/backend/app/schemas/gateway_api.py
index 2ae97692..13cc2094 100644
--- a/backend/app/schemas/gateway_api.py
+++ b/backend/app/schemas/gateway_api.py
@@ -21,6 +21,7 @@ class GatewayResolveQuery(SQLModel):
board_id: str | None = None
gateway_url: str | None = None
gateway_token: str | None = None
+ gateway_disable_device_pairing: bool = False
class GatewaysStatusResponse(SQLModel):
diff --git a/backend/app/schemas/gateways.py b/backend/app/schemas/gateways.py
index 233a44d5..eb8d2299 100644
--- a/backend/app/schemas/gateways.py
+++ b/backend/app/schemas/gateways.py
@@ -17,6 +17,7 @@ class GatewayBase(SQLModel):
name: str
url: str
workspace_root: str
+ disable_device_pairing: bool = False
class GatewayCreate(GatewayBase):
@@ -43,6 +44,7 @@ class GatewayUpdate(SQLModel):
url: str | None = None
token: str | None = None
workspace_root: str | None = None
+ disable_device_pairing: bool | None = None
@field_validator("token", mode="before")
@classmethod
diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py
index dfc0c2b6..28197be0 100644
--- a/backend/app/services/openclaw/admin_service.py
+++ b/backend/app/services/openclaw/admin_service.py
@@ -27,6 +27,7 @@ from app.services.openclaw.db_agent_state import (
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
+from app.services.openclaw.error_messages import normalize_gateway_error_message
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
@@ -167,7 +168,11 @@ class GatewayAdminLifecycleService(OpenClawDBService):
async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool:
if not gateway.url:
return False
- config = GatewayClientConfig(url=gateway.url, token=gateway.token)
+ config = GatewayClientConfig(
+ url=gateway.url,
+ token=gateway.token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ )
target_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
try:
await openclaw_call("agents.files.list", {"agentId": target_id}, config=config)
@@ -178,15 +183,26 @@ class GatewayAdminLifecycleService(OpenClawDBService):
return True
return True
- async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None:
+ async def assert_gateway_runtime_compatible(
+ self,
+ *,
+ url: str,
+ token: str | None,
+ disable_device_pairing: bool = False,
+ ) -> None:
"""Validate that a gateway runtime meets minimum supported version."""
- config = GatewayClientConfig(url=url, token=token)
+ config = GatewayClientConfig(
+ url=url,
+ token=token,
+ disable_device_pairing=disable_device_pairing,
+ )
try:
result = await check_gateway_runtime_compatibility(config)
except OpenClawGatewayError as exc:
+ detail = normalize_gateway_error_message(str(exc))
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
- detail=f"Gateway compatibility check failed: {exc}",
+ detail=f"Gateway compatibility check failed: {detail}",
) from exc
if not result.compatible:
raise HTTPException(
diff --git a/backend/app/services/openclaw/device_identity.py b/backend/app/services/openclaw/device_identity.py
new file mode 100644
index 00000000..8a94b87c
--- /dev/null
+++ b/backend/app/services/openclaw/device_identity.py
@@ -0,0 +1,163 @@
+"""OpenClaw-compatible device identity and connect-signature helpers."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from time import time
+from typing import Any, cast
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import (
+ Ed25519PrivateKey,
+ Ed25519PublicKey,
+)
+
+DEFAULT_DEVICE_IDENTITY_PATH = Path.home() / ".openclaw" / "identity" / "device.json"
+
+
+@dataclass(frozen=True)
+class DeviceIdentity:
+ """Persisted gateway device identity used for connect signatures."""
+
+ device_id: str
+ public_key_pem: str
+ private_key_pem: str
+
+
+def _identity_path() -> Path:
+ raw = os.getenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", "").strip()
+ if raw:
+ return Path(raw).expanduser().resolve()
+ return DEFAULT_DEVICE_IDENTITY_PATH
+
+
+def _base64url_encode(raw: bytes) -> str:
+ import base64
+
+ return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
+
+
+def _derive_public_key_raw(public_key_pem: str) -> bytes:
+ loaded = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
+ if not isinstance(loaded, Ed25519PublicKey):
+ msg = "device identity public key is not Ed25519"
+ raise ValueError(msg)
+ return loaded.public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+
+
+def _derive_device_id(public_key_pem: str) -> str:
+ return hashlib.sha256(_derive_public_key_raw(public_key_pem)).hexdigest()
+
+
+def _write_identity(path: Path, identity: DeviceIdentity) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "version": 1,
+ "deviceId": identity.device_id,
+ "publicKeyPem": identity.public_key_pem,
+ "privateKeyPem": identity.private_key_pem,
+ "createdAtMs": int(time() * 1000),
+ }
+ path.write_text(f"{json.dumps(payload, indent=2)}\n", encoding="utf-8")
+ try:
+ path.chmod(0o600)
+ except OSError:
+ # Best effort on platforms/filesystems that ignore chmod.
+ pass
+
+
+def _generate_identity() -> DeviceIdentity:
+ private_key = Ed25519PrivateKey.generate()
+ private_key_pem = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("utf-8")
+ public_key_pem = private_key.public_key().public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ ).decode("utf-8")
+ device_id = _derive_device_id(public_key_pem)
+ return DeviceIdentity(
+ device_id=device_id,
+ public_key_pem=public_key_pem,
+ private_key_pem=private_key_pem,
+ )
+
+
+def load_or_create_device_identity() -> DeviceIdentity:
+ """Load persisted device identity or create a new one when missing/invalid."""
+ path = _identity_path()
+ try:
+ if path.exists():
+ payload = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
+ device_id = str(payload.get("deviceId") or "").strip()
+ public_key_pem = str(payload.get("publicKeyPem") or "").strip()
+ private_key_pem = str(payload.get("privateKeyPem") or "").strip()
+ if device_id and public_key_pem and private_key_pem:
+ derived_id = _derive_device_id(public_key_pem)
+ identity = DeviceIdentity(
+ device_id=derived_id,
+ public_key_pem=public_key_pem,
+ private_key_pem=private_key_pem,
+ )
+ if derived_id != device_id:
+ _write_identity(path, identity)
+ return identity
+ except (OSError, ValueError, json.JSONDecodeError):
+ # Fall through to regenerate.
+ pass
+
+ identity = _generate_identity()
+ _write_identity(path, identity)
+ return identity
+
+
+def public_key_raw_base64url_from_pem(public_key_pem: str) -> str:
+ """Return raw Ed25519 public key in base64url form expected by OpenClaw."""
+ return _base64url_encode(_derive_public_key_raw(public_key_pem))
+
+
+def sign_device_payload(private_key_pem: str, payload: str) -> str:
+ """Sign a device payload with Ed25519 and return base64url signature."""
+ loaded = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
+ if not isinstance(loaded, Ed25519PrivateKey):
+ msg = "device identity private key is not Ed25519"
+ raise ValueError(msg)
+ signature = loaded.sign(payload.encode("utf-8"))
+ return _base64url_encode(signature)
+
+
+def build_device_auth_payload(
+ *,
+ device_id: str,
+ client_id: str,
+ client_mode: str,
+ role: str,
+ scopes: list[str],
+ signed_at_ms: int,
+ token: str | None,
+ nonce: str | None,
+) -> str:
+ """Build the OpenClaw canonical payload string for device signatures."""
+ version = "v2" if nonce else "v1"
+ parts = [
+ version,
+ device_id,
+ client_id,
+ client_mode,
+ role,
+ ",".join(scopes),
+ str(signed_at_ms),
+ token or "",
+ ]
+ if version == "v2":
+ parts.append(nonce or "")
+ return "|".join(parts)
diff --git a/backend/app/services/openclaw/error_messages.py b/backend/app/services/openclaw/error_messages.py
new file mode 100644
index 00000000..c604eedd
--- /dev/null
+++ b/backend/app/services/openclaw/error_messages.py
@@ -0,0 +1,31 @@
+"""Normalization helpers for user-facing OpenClaw gateway errors."""
+
+from __future__ import annotations
+
+import re
+
+_MISSING_SCOPE_PATTERN = re.compile(
+ r"missing\s+scope\s*:\s*(?P[A-Za-z0-9._:-]+)",
+ re.IGNORECASE,
+)
+
+
+def normalize_gateway_error_message(message: str) -> str:
+ """Return a user-friendly message for common gateway auth failures."""
+ raw_message = message.strip()
+ if not raw_message:
+ return "Gateway authentication failed. Verify gateway token and operator scopes."
+
+ missing_scope = _MISSING_SCOPE_PATTERN.search(raw_message)
+ if missing_scope is not None:
+ scope = missing_scope.group("scope")
+ return (
+ f"Gateway token is missing required scope `{scope}`. "
+ "Update the gateway token scopes and retry."
+ )
+
+ lowered = raw_message.lower()
+ if "unauthorized" in lowered or "forbidden" in lowered:
+ return "Gateway authentication failed. Verify gateway token and operator scopes."
+
+ return raw_message
diff --git a/backend/app/services/openclaw/gateway_resolver.py b/backend/app/services/openclaw/gateway_resolver.py
index 7e31814f..58710116 100644
--- a/backend/app/services/openclaw/gateway_resolver.py
+++ b/backend/app/services/openclaw/gateway_resolver.py
@@ -32,7 +32,11 @@ def gateway_client_config(gateway: Gateway) -> GatewayClientConfig:
detail="Gateway url is required",
)
token = (gateway.token or "").strip() or None
- return GatewayClientConfig(url=url, token=token)
+ return GatewayClientConfig(
+ url=url,
+ token=token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ )
def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConfig | None:
@@ -43,7 +47,11 @@ def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConf
if not url:
return None
token = (gateway.token or "").strip() or None
- return GatewayClientConfig(url=url, token=token)
+ return GatewayClientConfig(
+ url=url,
+ token=token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ )
def require_gateway_workspace_root(gateway: Gateway) -> str:
diff --git a/backend/app/services/openclaw/gateway_rpc.py b/backend/app/services/openclaw/gateway_rpc.py
index 0765d54d..02928f5d 100644
--- a/backend/app/services/openclaw/gateway_rpc.py
+++ b/backend/app/services/openclaw/gateway_rpc.py
@@ -10,8 +10,8 @@ from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
-from time import perf_counter
-from typing import Any
+from time import perf_counter, time
+from typing import Any, Literal
from urllib.parse import urlencode, urlparse, urlunparse
from uuid import uuid4
@@ -19,6 +19,12 @@ import websockets
from websockets.exceptions import WebSocketException
from app.core.logging import TRACE_LEVEL, get_logger
+from app.services.openclaw.device_identity import (
+ build_device_auth_payload,
+ load_or_create_device_identity,
+ public_key_raw_base64url_from_pem,
+ sign_device_payload,
+)
PROTOCOL_VERSION = 3
logger = get_logger(__name__)
@@ -28,6 +34,11 @@ GATEWAY_OPERATOR_SCOPES = (
"operator.approvals",
"operator.pairing",
)
+DEFAULT_GATEWAY_CLIENT_ID = "gateway-client"
+DEFAULT_GATEWAY_CLIENT_MODE = "backend"
+CONTROL_UI_CLIENT_ID = "openclaw-control-ui"
+CONTROL_UI_CLIENT_MODE = "ui"
+GatewayConnectMode = Literal["device", "control_ui"]
# NOTE: These are the base gateway methods from the OpenClaw gateway repo.
# The gateway can expose additional methods at runtime via channel plugins.
@@ -160,6 +171,7 @@ class GatewayConfig:
url: str
token: str | None = None
+ disable_device_pairing: bool = False
def _build_gateway_url(config: GatewayConfig) -> str:
@@ -180,6 +192,60 @@ def _redacted_url_for_log(raw_url: str) -> str:
return str(urlunparse(parsed._replace(query="", fragment="")))
+def _build_control_ui_origin(gateway_url: str) -> str | None:
+ parsed = urlparse(gateway_url)
+ if not parsed.hostname:
+ return None
+ if parsed.scheme in {"ws", "http"}:
+ origin_scheme = "http"
+ elif parsed.scheme in {"wss", "https"}:
+ origin_scheme = "https"
+ else:
+ return None
+ host = parsed.hostname
+ if ":" in host and not host.startswith("["):
+ host = f"[{host}]"
+ if parsed.port is not None:
+ host = f"{host}:{parsed.port}"
+ return f"{origin_scheme}://{host}"
+
+
+def _resolve_connect_mode(config: GatewayConfig) -> GatewayConnectMode:
+ return "control_ui" if config.disable_device_pairing else "device"
+
+
+def _build_device_connect_payload(
+ *,
+ client_id: str,
+ client_mode: str,
+ role: str,
+ scopes: list[str],
+ auth_token: str | None,
+ connect_nonce: str | None,
+) -> dict[str, Any]:
+ identity = load_or_create_device_identity()
+ signed_at_ms = int(time() * 1000)
+ payload = build_device_auth_payload(
+ device_id=identity.device_id,
+ client_id=client_id,
+ client_mode=client_mode,
+ role=role,
+ scopes=scopes,
+ signed_at_ms=signed_at_ms,
+ token=auth_token,
+ nonce=connect_nonce,
+ )
+ device_payload: dict[str, Any] = {
+ "id": identity.device_id,
+ "publicKey": public_key_raw_base64url_from_pem(identity.public_key_pem),
+ "signature": sign_device_payload(identity.private_key_pem, payload),
+ "signedAt": signed_at_ms,
+ }
+ if connect_nonce:
+ device_payload["nonce"] = connect_nonce
+ return device_payload
+
+
async def _await_response(
ws: websockets.ClientConnection,
request_id: str,
@@ -231,19 +297,36 @@ async def _send_request(
return await _await_response(ws, request_id)
-def _build_connect_params(config: GatewayConfig) -> dict[str, Any]:
+def _build_connect_params(
+ config: GatewayConfig,
+ *,
+ connect_nonce: str | None = None,
+) -> dict[str, Any]:
+ role = "operator"
+ scopes = list(GATEWAY_OPERATOR_SCOPES)
+ connect_mode = _resolve_connect_mode(config)
+ use_control_ui = connect_mode == "control_ui"
params: dict[str, Any] = {
"minProtocol": PROTOCOL_VERSION,
"maxProtocol": PROTOCOL_VERSION,
- "role": "operator",
- "scopes": list(GATEWAY_OPERATOR_SCOPES),
+ "role": role,
+ "scopes": scopes,
"client": {
- "id": "gateway-client",
+ "id": CONTROL_UI_CLIENT_ID if use_control_ui else DEFAULT_GATEWAY_CLIENT_ID,
"version": "1.0.0",
- "platform": "web",
- "mode": "ui",
+ "platform": "python",
+ "mode": CONTROL_UI_CLIENT_MODE if use_control_ui else DEFAULT_GATEWAY_CLIENT_MODE,
},
}
+ if not use_control_ui:
+ params["device"] = _build_device_connect_payload(
+ client_id=DEFAULT_GATEWAY_CLIENT_ID,
+ client_mode=DEFAULT_GATEWAY_CLIENT_MODE,
+ role=role,
+ scopes=scopes,
+ auth_token=config.token,
+ connect_nonce=connect_nonce,
+ )
if config.token:
params["auth"] = {"token": config.token}
return params
@@ -254,11 +337,18 @@ async def _ensure_connected(
first_message: str | bytes | None,
config: GatewayConfig,
) -> object:
+ connect_nonce: str | None = None
if first_message:
if isinstance(first_message, bytes):
first_message = first_message.decode("utf-8")
data = json.loads(first_message)
- if data.get("type") != "event" or data.get("event") != "connect.challenge":
+ if data.get("type") == "event" and data.get("event") == "connect.challenge":
+ payload = data.get("payload")
+ if isinstance(payload, dict):
+ nonce = payload.get("nonce")
+ if isinstance(nonce, str) and nonce.strip():
+ connect_nonce = nonce.strip()
+ else:
logger.warning(
"gateway.rpc.connect.unexpected_first_message type=%s event=%s",
data.get("type"),
@@ -269,12 +359,52 @@ async def _ensure_connected(
"type": "req",
"id": connect_id,
"method": "connect",
- "params": _build_connect_params(config),
+ "params": _build_connect_params(config, connect_nonce=connect_nonce),
}
await ws.send(json.dumps(response))
return await _await_response(ws, connect_id)
+async def _recv_first_message_or_none(
+ ws: websockets.ClientConnection,
+) -> str | bytes | None:
+ try:
+ return await asyncio.wait_for(ws.recv(), timeout=2)
+ except TimeoutError:
+ return None
+
+
+async def _openclaw_call_once(
+ method: str,
+ params: dict[str, Any] | None,
+ *,
+ config: GatewayConfig,
+ gateway_url: str,
+) -> object:
+ origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None
+ connect_kwargs: dict[str, Any] = {"ping_interval": None}
+ if origin is not None:
+ connect_kwargs["origin"] = origin
+ async with websockets.connect(gateway_url, **connect_kwargs) as ws:
+ first_message = await _recv_first_message_or_none(ws)
+ await _ensure_connected(ws, first_message, config)
+ return await _send_request(ws, method, params)
+
+
+async def _openclaw_connect_metadata_once(
+ *,
+ config: GatewayConfig,
+ gateway_url: str,
+) -> object:
+ origin = _build_control_ui_origin(gateway_url) if config.disable_device_pairing else None
+ connect_kwargs: dict[str, Any] = {"ping_interval": None}
+ if origin is not None:
+ connect_kwargs["origin"] = origin
+ async with websockets.connect(gateway_url, **connect_kwargs) as ws:
+ first_message = await _recv_first_message_or_none(ws)
+ return await _ensure_connected(ws, first_message, config)
+
+
async def openclaw_call(
method: str,
params: dict[str, Any] | None = None,
@@ -290,20 +420,18 @@ async def openclaw_call(
_redacted_url_for_log(gateway_url),
)
try:
- async with websockets.connect(gateway_url, ping_interval=None) as ws:
- first_message = None
- try:
- first_message = await asyncio.wait_for(ws.recv(), timeout=2)
- except TimeoutError:
- first_message = None
- await _ensure_connected(ws, first_message, config)
- payload = await _send_request(ws, method, params)
- logger.debug(
- "gateway.rpc.call.success method=%s duration_ms=%s",
- method,
- int((perf_counter() - started_at) * 1000),
- )
- return payload
+ payload = await _openclaw_call_once(
+ method,
+ params,
+ config=config,
+ gateway_url=gateway_url,
+ )
+ logger.debug(
+ "gateway.rpc.call.success method=%s duration_ms=%s",
+ method,
+ int((perf_counter() - started_at) * 1000),
+ )
+ return payload
except OpenClawGatewayError:
logger.warning(
"gateway.rpc.call.gateway_error method=%s duration_ms=%s",
@@ -336,18 +464,15 @@ async def openclaw_connect_metadata(*, config: GatewayConfig) -> object:
_redacted_url_for_log(gateway_url),
)
try:
- async with websockets.connect(gateway_url, ping_interval=None) as ws:
- first_message = None
- try:
- first_message = await asyncio.wait_for(ws.recv(), timeout=2)
- except TimeoutError:
- first_message = None
- metadata = await _ensure_connected(ws, first_message, config)
- logger.debug(
- "gateway.rpc.connect_metadata.success duration_ms=%s",
- int((perf_counter() - started_at) * 1000),
- )
- return metadata
+ metadata = await _openclaw_connect_metadata_once(
+ config=config,
+ gateway_url=gateway_url,
+ )
+ logger.debug(
+ "gateway.rpc.connect_metadata.success duration_ms=%s",
+ int((perf_counter() - started_at) * 1000),
+ )
+ return metadata
except OpenClawGatewayError:
logger.warning(
"gateway.rpc.connect_metadata.gateway_error duration_ms=%s",
diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py
index 0d2534a3..2eaba0e6 100644
--- a/backend/app/services/openclaw/provisioning.py
+++ b/backend/app/services/openclaw/provisioning.py
@@ -970,7 +970,11 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
return OpenClawGatewayControlPlane(
- GatewayClientConfig(url=gateway.url, token=gateway.token),
+ GatewayClientConfig(
+ url=gateway.url,
+ token=gateway.token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ ),
)
@@ -1099,7 +1103,11 @@ class OpenClawGatewayProvisioner:
if not wake:
return
- client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
+ client_config = GatewayClientConfig(
+ url=gateway.url,
+ token=gateway.token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ )
await ensure_session(session_key, config=client_config, label=agent.name)
verb = wakeup_verb or ("provisioned" if action == "provision" else "updated")
await send_message(
diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py
index 97fcb8f6..2fbc2ef3 100644
--- a/backend/app/services/openclaw/provisioning_db.py
+++ b/backend/app/services/openclaw/provisioning_db.py
@@ -285,7 +285,11 @@ class OpenClawProvisioningService(OpenClawDBService):
return result
control_plane = OpenClawGatewayControlPlane(
- GatewayClientConfig(url=gateway.url, token=gateway.token),
+ GatewayClientConfig(
+ url=gateway.url,
+ token=gateway.token,
+ disable_device_pairing=gateway.disable_device_pairing,
+ ),
)
ctx = _SyncContext(
session=self.session,
diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py
index c42d707a..377856f8 100644
--- a/backend/app/services/openclaw/session_service.py
+++ b/backend/app/services/openclaw/session_service.py
@@ -20,6 +20,7 @@ from app.schemas.gateway_api import (
GatewaysStatusResponse,
)
from app.services.openclaw.db_service import OpenClawDBService
+from app.services.openclaw.error_messages import normalize_gateway_error_message
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
@@ -64,11 +65,13 @@ class GatewaySessionService(OpenClawDBService):
board_id: str | None,
gateway_url: str | None,
gateway_token: str | None,
+ gateway_disable_device_pairing: bool = False,
) -> GatewayResolveQuery:
return GatewayResolveQuery(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
+ gateway_disable_device_pairing=gateway_disable_device_pairing,
)
@staticmethod
@@ -109,6 +112,7 @@ class GatewaySessionService(OpenClawDBService):
GatewayClientConfig(
url=raw_url,
token=(params.gateway_token or "").strip() or None,
+ disable_device_pairing=params.gateway_disable_device_pairing,
),
None,
)
@@ -195,7 +199,7 @@ class GatewaySessionService(OpenClawDBService):
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
- error=str(exc),
+ error=normalize_gateway_error_message(str(exc)),
)
if not compatibility.compatible:
return GatewaysStatusResponse(
@@ -234,7 +238,7 @@ class GatewaySessionService(OpenClawDBService):
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
- error=str(exc),
+ error=normalize_gateway_error_message(str(exc)),
)
async def get_sessions(
diff --git a/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py b/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py
new file mode 100644
index 00000000..b0ce0978
--- /dev/null
+++ b/backend/migrations/versions/c5d1a2b3e4f6_add_disable_device_pairing_to_gateways.py
@@ -0,0 +1,37 @@
+"""Add disable_device_pairing setting to gateways.
+
+Revision ID: c5d1a2b3e4f6
+Revises: b7a1d9c3e4f5
+Create Date: 2026-02-22 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision = "c5d1a2b3e4f6"
+down_revision = "b7a1d9c3e4f5"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Add gateway toggle to bypass device pairing handshake."""
+ op.add_column(
+ "gateways",
+ sa.Column(
+ "disable_device_pairing",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ )
+ op.alter_column("gateways", "disable_device_pairing", server_default=None)
+
+
+def downgrade() -> None:
+ """Remove gateway toggle to bypass device pairing handshake."""
+ op.drop_column("gateways", "disable_device_pairing")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index c5ce49e5..fcd9af8a 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -27,6 +27,7 @@ dependencies = [
"websockets==16.0",
"redis==6.3.0",
"rq==2.6.0",
+ "cryptography==45.0.7",
]
[project.optional-dependencies]
diff --git a/backend/tests/test_gateway_device_identity.py b/backend/tests/test_gateway_device_identity.py
new file mode 100644
index 00000000..39db8cf8
--- /dev/null
+++ b/backend/tests/test_gateway_device_identity.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import base64
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+
+from app.services.openclaw.device_identity import (
+ build_device_auth_payload,
+ load_or_create_device_identity,
+ sign_device_payload,
+)
+
+
+def _base64url_decode(value: str) -> bytes:
+ padding = "=" * ((4 - len(value) % 4) % 4)
+ return base64.urlsafe_b64decode(f"{value}{padding}")
+
+
+def test_load_or_create_device_identity_persists_same_identity(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+) -> None:
+ identity_path = tmp_path / "identity" / "device.json"
+ monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
+
+ first = load_or_create_device_identity()
+ second = load_or_create_device_identity()
+
+ assert identity_path.exists()
+ assert first.device_id == second.device_id
+ assert first.public_key_pem.strip() == second.public_key_pem.strip()
+ assert first.private_key_pem.strip() == second.private_key_pem.strip()
+
+
+def test_build_device_auth_payload_uses_nonce_for_v2() -> None:
+ payload = build_device_auth_payload(
+ device_id="dev",
+ client_id="gateway-client",
+ client_mode="backend",
+ role="operator",
+ scopes=["operator.read", "operator.admin"],
+ signed_at_ms=123,
+ token="token",
+ nonce="nonce-xyz",
+ )
+
+ assert payload == (
+ "v2|dev|gateway-client|backend|operator|operator.read,operator.admin|123|token|nonce-xyz"
+ )
+
+
+def test_sign_device_payload_produces_valid_ed25519_signature(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+) -> None:
+ identity_path = tmp_path / "identity" / "device.json"
+ monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
+ identity = load_or_create_device_identity()
+
+ payload = "v1|device|client|backend|operator|operator.read|1|token"
+ signature = sign_device_payload(identity.private_key_pem, payload)
+
+ loaded = serialization.load_pem_public_key(identity.public_key_pem.encode("utf-8"))
+ assert isinstance(loaded, Ed25519PublicKey)
+ loaded.verify(_base64url_decode(signature), payload.encode("utf-8"))
diff --git a/backend/tests/test_gateway_resolver.py b/backend/tests/test_gateway_resolver.py
new file mode 100644
index 00000000..ccbf673d
--- /dev/null
+++ b/backend/tests/test_gateway_resolver.py
@@ -0,0 +1,64 @@
+# ruff: noqa: S101
+from __future__ import annotations
+
+from uuid import uuid4
+
+from app.models.gateways import Gateway
+from app.services.openclaw.gateway_resolver import (
+ gateway_client_config,
+ optional_gateway_client_config,
+)
+from app.services.openclaw.session_service import GatewaySessionService
+
+
+def _gateway(
+ *,
+ disable_device_pairing: bool,
+ url: str = "ws://gateway.example:18789/ws",
+ token: str | None = " secret-token ",
+) -> Gateway:
+ return Gateway(
+ id=uuid4(),
+ organization_id=uuid4(),
+ name="Primary gateway",
+ url=url,
+ token=token,
+ workspace_root="~/.openclaw",
+ disable_device_pairing=disable_device_pairing,
+ )
+
+
+def test_gateway_client_config_maps_disable_device_pairing() -> None:
+ config = gateway_client_config(_gateway(disable_device_pairing=True))
+
+ assert config.url == "ws://gateway.example:18789/ws"
+ assert config.token == "secret-token"
+ assert config.disable_device_pairing is True
+
+
+def test_optional_gateway_client_config_maps_disable_device_pairing() -> None:
+ config = optional_gateway_client_config(_gateway(disable_device_pairing=False))
+
+ assert config is not None
+ assert config.disable_device_pairing is False
+
+
+def test_optional_gateway_client_config_returns_none_for_missing_or_blank_url() -> None:
+ assert optional_gateway_client_config(None) is None
+ assert (
+ optional_gateway_client_config(
+ _gateway(disable_device_pairing=False, url=" "),
+ )
+ is None
+ )
+
+
+def test_to_resolve_query_keeps_gateway_disable_device_pairing_value() -> None:
+ resolved = GatewaySessionService.to_resolve_query(
+ board_id=None,
+ gateway_url="ws://gateway.example:18789/ws",
+ gateway_token="secret-token",
+ gateway_disable_device_pairing=True,
+ )
+
+ assert resolved.gateway_disable_device_pairing is True
diff --git a/backend/tests/test_gateway_rpc_connect_scopes.py b/backend/tests/test_gateway_rpc_connect_scopes.py
index 962ee90d..0a2b2dad 100644
--- a/backend/tests/test_gateway_rpc_connect_scopes.py
+++ b/backend/tests/test_gateway_rpc_connect_scopes.py
@@ -1,24 +1,194 @@
from __future__ import annotations
+import pytest
+
+import app.services.openclaw.gateway_rpc as gateway_rpc
from app.services.openclaw.gateway_rpc import (
+ CONTROL_UI_CLIENT_ID,
+ CONTROL_UI_CLIENT_MODE,
+ DEFAULT_GATEWAY_CLIENT_ID,
+ DEFAULT_GATEWAY_CLIENT_MODE,
GATEWAY_OPERATOR_SCOPES,
GatewayConfig,
+ OpenClawGatewayError,
_build_connect_params,
+ _build_control_ui_origin,
+ openclaw_call,
)
-def test_build_connect_params_sets_explicit_operator_role_and_scopes() -> None:
+def test_build_connect_params_defaults_to_device_pairing(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, object] = {}
+ expected_device_payload = {
+ "id": "device-id",
+ "publicKey": "public-key",
+ "signature": "signature",
+ "signedAt": 1,
+ }
+
+ def _fake_build_device_connect_payload(
+ *,
+ client_id: str,
+ client_mode: str,
+ role: str,
+ scopes: list[str],
+ auth_token: str | None,
+ connect_nonce: str | None,
+ ) -> dict[str, object]:
+ captured["client_id"] = client_id
+ captured["client_mode"] = client_mode
+ captured["role"] = role
+ captured["scopes"] = scopes
+ captured["auth_token"] = auth_token
+ captured["connect_nonce"] = connect_nonce
+ return expected_device_payload
+
+ monkeypatch.setattr(
+ gateway_rpc,
+ "_build_device_connect_payload",
+ _fake_build_device_connect_payload,
+ )
+
params = _build_connect_params(GatewayConfig(url="ws://gateway.example/ws"))
assert params["role"] == "operator"
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
+ assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
+ assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
+ assert params["device"] == expected_device_payload
assert "auth" not in params
+ assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
+ assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
+ assert captured["role"] == "operator"
+ assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
+ assert captured["auth_token"] is None
+ assert captured["connect_nonce"] is None
-def test_build_connect_params_includes_auth_token_when_provided() -> None:
+def test_build_connect_params_uses_control_ui_when_pairing_disabled() -> None:
params = _build_connect_params(
- GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
+ GatewayConfig(
+ url="ws://gateway.example/ws",
+ token="secret-token",
+ disable_device_pairing=True,
+ ),
)
assert params["auth"] == {"token": "secret-token"}
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
+ assert params["client"]["id"] == CONTROL_UI_CLIENT_ID
+ assert params["client"]["mode"] == CONTROL_UI_CLIENT_MODE
+ assert "device" not in params
+
+
+def test_build_connect_params_passes_nonce_to_device_payload(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, object] = {}
+
+ def _fake_build_device_connect_payload(
+ *,
+ client_id: str,
+ client_mode: str,
+ role: str,
+ scopes: list[str],
+ auth_token: str | None,
+ connect_nonce: str | None,
+ ) -> dict[str, object]:
+ captured["client_id"] = client_id
+ captured["client_mode"] = client_mode
+ captured["role"] = role
+ captured["scopes"] = scopes
+ captured["auth_token"] = auth_token
+ captured["connect_nonce"] = connect_nonce
+ return {"id": "device-id", "nonce": connect_nonce}
+
+ monkeypatch.setattr(
+ gateway_rpc,
+ "_build_device_connect_payload",
+ _fake_build_device_connect_payload,
+ )
+
+ params = _build_connect_params(
+ GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
+ connect_nonce="nonce-xyz",
+ )
+
+ assert params["auth"] == {"token": "secret-token"}
+ assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
+ assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
+ assert params["device"] == {"id": "device-id", "nonce": "nonce-xyz"}
+ assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
+ assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
+ assert captured["role"] == "operator"
+ assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
+ assert captured["auth_token"] == "secret-token"
+ assert captured["connect_nonce"] == "nonce-xyz"
+
+
+@pytest.mark.parametrize(
+ ("gateway_url", "expected_origin"),
+ [
+ ("ws://gateway.example/ws", "http://gateway.example"),
+ ("wss://gateway.example/ws", "https://gateway.example"),
+ ("ws://gateway.example:8080/ws", "http://gateway.example:8080"),
+ ("wss://gateway.example:8443/ws", "https://gateway.example:8443"),
+ ("ws://[::1]:8000/ws", "http://[::1]:8000"),
+ ],
+)
+def test_build_control_ui_origin(gateway_url: str, expected_origin: str) -> None:
+ assert _build_control_ui_origin(gateway_url) == expected_origin
+
+
+@pytest.mark.asyncio
+async def test_openclaw_call_uses_single_connect_attempt(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ call_count = 0
+
+ async def _fake_call_once(
+ method: str,
+ params: dict[str, object] | None,
+ *,
+ config: GatewayConfig,
+ gateway_url: str,
+ ) -> object:
+ nonlocal call_count
+ del method, params, config, gateway_url
+ call_count += 1
+ return {"ok": True}
+
+ monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
+
+ payload = await openclaw_call(
+ "status",
+ config=GatewayConfig(url="ws://gateway.example/ws"),
+ )
+
+ assert payload == {"ok": True}
+ assert call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_openclaw_call_surfaces_scope_error_without_device_fallback(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_call_once(
+ method: str,
+ params: dict[str, object] | None,
+ *,
+ config: GatewayConfig,
+ gateway_url: str,
+ ) -> object:
+ del method, params, config, gateway_url
+ raise OpenClawGatewayError("missing scope: operator.read")
+
+ monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
+
+ with pytest.raises(OpenClawGatewayError, match="missing scope: operator.read"):
+ await openclaw_call(
+ "status",
+ config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
+ )
diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py
index c6e6b4d3..1de0da6d 100644
--- a/backend/tests/test_gateway_version_compat.py
+++ b/backend/tests/test_gateway_version_compat.py
@@ -200,6 +200,24 @@ async def test_admin_service_maps_gateway_transport_errors(
assert "compatibility check failed" in str(exc_info.value.detail).lower()
+@pytest.mark.asyncio
+async def test_admin_service_maps_gateway_scope_errors_with_guidance(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
+ _ = (config, minimum_version)
+ raise OpenClawGatewayError("missing scope: operator.read")
+
+ monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
+
+ service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
+ with pytest.raises(HTTPException) as exc_info:
+ await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
+
+ assert exc_info.value.status_code == 502
+ assert "missing required scope `operator.read`" in str(exc_info.value.detail)
+
+
@pytest.mark.asyncio
async def test_gateway_status_reports_incompatible_version(
monkeypatch: pytest.MonkeyPatch,
@@ -226,6 +244,28 @@ async def test_gateway_status_reports_incompatible_version(
assert response.error == "Gateway version 2026.1.0 is not supported."
+@pytest.mark.asyncio
+async def test_gateway_status_surfaces_scope_error_guidance(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
+ _ = (config, minimum_version)
+ raise OpenClawGatewayError("missing scope: operator.read")
+
+ monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
+
+ service = GatewaySessionService(session=object()) # type: ignore[arg-type]
+ response = await service.get_status(
+ params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
+ organization_id=uuid4(),
+ user=None,
+ )
+
+ assert response.connected is False
+ assert response.error is not None
+ assert "missing required scope `operator.read`" in response.error
+
+
@pytest.mark.asyncio
async def test_gateway_status_returns_sessions_when_version_compatible(
monkeypatch: pytest.MonkeyPatch,
diff --git a/backend/uv.lock b/backend/uv.lock
index c01cab38..8b82574f 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -292,12 +292,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
]
-[[package]]
-name = "crontab"
-version = "1.0.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/36/a255b6f5a2e22df03fd2b2f3088974b44b8c9e9407e26b44742cb7cfbf5b/crontab-1.0.5.tar.gz", hash = "sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5", size = 21963, upload-time = "2025-07-09T17:09:38.264Z" }
-
[[package]]
name = "cryptography"
version = "45.0.7"
@@ -377,18 +371,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
]
-[[package]]
-name = "freezegun"
-version = "1.5.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "python-dateutil" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
-]
-
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -722,6 +704,7 @@ source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "clerk-backend-api" },
+ { name = "cryptography" },
{ name = "fastapi" },
{ name = "fastapi-pagination" },
{ name = "jinja2" },
@@ -759,6 +742,7 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = "==26.1.0" },
{ name = "clerk-backend-api", specifier = "==4.2.0" },
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.13.4" },
+ { name = "cryptography", specifier = "==45.0.7" },
{ name = "fastapi", specifier = "==0.128.6" },
{ name = "fastapi-pagination", specifier = "==0.15.10" },
{ name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" },
diff --git a/docs/openclaw_baseline_config.md b/docs/openclaw_baseline_config.md
index daa0c746..4c28a3d1 100644
--- a/docs/openclaw_baseline_config.md
+++ b/docs/openclaw_baseline_config.md
@@ -479,6 +479,9 @@ When adding a gateway in Mission Control:
- URL: `ws://127.0.0.1:18789` (or your host/IP with explicit port)
- Token: provide only if your gateway requires token auth
+- Device pairing: enabled by default and recommended
+ - Keep pairing enabled for normal operation.
+ - Optional bypass: enable `Disable device pairing` per gateway only when the gateway is explicitly configured for control UI auth bypass (for example `gateway.controlUi.dangerouslyDisableDeviceAuth: true` plus appropriate `gateway.controlUi.allowedOrigins`).
- Workspace root (in Mission Control gateway config): align with `agents.defaults.workspace` when possible
## Security Notes
diff --git a/frontend/src/api/generated/agent/agent.ts b/frontend/src/api/generated/agent/agent.ts
index dbd5bfba..66627b96 100644
--- a/frontend/src/api/generated/agent/agent.ts
+++ b/frontend/src/api/generated/agent/agent.ts
@@ -22,7 +22,7 @@ import type {
import type {
AgentCreate,
- AgentHeartbeatCreate,
+ AgentHealthStatusResponse,
AgentNudge,
AgentRead,
ApprovalCreate,
@@ -67,6 +67,192 @@ import { customFetch } from "../../mutator";
type SecondParameter unknown> = Parameters[1];
+/**
+ * Token-authenticated liveness probe for agent API clients.
+
+Use this endpoint when the caller needs to verify both service availability and agent-token validity in one request.
+ * @summary Agent Auth Health Check
+ */
+export type agentHealthzApiV1AgentHealthzGetResponse200 = {
+ data: AgentHealthStatusResponse;
+ status: 200;
+};
+
+export type agentHealthzApiV1AgentHealthzGetResponse422 = {
+ data: HTTPValidationError;
+ status: 422;
+};
+
+export type agentHealthzApiV1AgentHealthzGetResponseSuccess =
+ agentHealthzApiV1AgentHealthzGetResponse200 & {
+ headers: Headers;
+ };
+export type agentHealthzApiV1AgentHealthzGetResponseError =
+ agentHealthzApiV1AgentHealthzGetResponse422 & {
+ headers: Headers;
+ };
+
+export type agentHealthzApiV1AgentHealthzGetResponse =
+ | agentHealthzApiV1AgentHealthzGetResponseSuccess
+ | agentHealthzApiV1AgentHealthzGetResponseError;
+
+export const getAgentHealthzApiV1AgentHealthzGetUrl = () => {
+ return `/api/v1/agent/healthz`;
+};
+
+export const agentHealthzApiV1AgentHealthzGet = async (
+ options?: RequestInit,
+): Promise => {
+ return customFetch(
+ getAgentHealthzApiV1AgentHealthzGetUrl(),
+ {
+ ...options,
+ method: "GET",
+ },
+ );
+};
+
+export const getAgentHealthzApiV1AgentHealthzGetQueryKey = () => {
+ return [`/api/v1/agent/healthz`] as const;
+};
+
+export const getAgentHealthzApiV1AgentHealthzGetQueryOptions = <
+ TData = Awaited>,
+ TError = HTTPValidationError,
+>(options?: {
+ query?: Partial<
+ UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ >
+ >;
+ request?: SecondParameter;
+}) => {
+ const { query: queryOptions, request: requestOptions } = options ?? {};
+
+ const queryKey =
+ queryOptions?.queryKey ?? getAgentHealthzApiV1AgentHealthzGetQueryKey();
+
+ const queryFn: QueryFunction<
+ Awaited>
+ > = ({ signal }) =>
+ agentHealthzApiV1AgentHealthzGet({ signal, ...requestOptions });
+
+ return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ > & { queryKey: DataTag };
+};
+
+export type AgentHealthzApiV1AgentHealthzGetQueryResult = NonNullable<
+ Awaited>
+>;
+export type AgentHealthzApiV1AgentHealthzGetQueryError = HTTPValidationError;
+
+export function useAgentHealthzApiV1AgentHealthzGet<
+ TData = Awaited>,
+ TError = HTTPValidationError,
+>(
+ options: {
+ query: Partial<
+ UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ >
+ > &
+ Pick<
+ DefinedInitialDataOptions<
+ Awaited>,
+ TError,
+ Awaited>
+ >,
+ "initialData"
+ >;
+ request?: SecondParameter;
+ },
+ queryClient?: QueryClient,
+): DefinedUseQueryResult & {
+ queryKey: DataTag;
+};
+export function useAgentHealthzApiV1AgentHealthzGet<
+ TData = Awaited>,
+ TError = HTTPValidationError,
+>(
+ options?: {
+ query?: Partial<
+ UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ >
+ > &
+ Pick<
+ UndefinedInitialDataOptions<
+ Awaited>,
+ TError,
+ Awaited>
+ >,
+ "initialData"
+ >;
+ request?: SecondParameter;
+ },
+ queryClient?: QueryClient,
+): UseQueryResult & {
+ queryKey: DataTag;
+};
+export function useAgentHealthzApiV1AgentHealthzGet<
+ TData = Awaited>,
+ TError = HTTPValidationError,
+>(
+ options?: {
+ query?: Partial<
+ UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ >
+ >;
+ request?: SecondParameter;
+ },
+ queryClient?: QueryClient,
+): UseQueryResult & {
+ queryKey: DataTag;
+};
+/**
+ * @summary Agent Auth Health Check
+ */
+
+export function useAgentHealthzApiV1AgentHealthzGet<
+ TData = Awaited>,
+ TError = HTTPValidationError,
+>(
+ options?: {
+ query?: Partial<
+ UseQueryOptions<
+ Awaited>,
+ TError,
+ TData
+ >
+ >;
+ request?: SecondParameter;
+ },
+ queryClient?: QueryClient,
+): UseQueryResult & {
+ queryKey: DataTag;
+} {
+ const queryOptions = getAgentHealthzApiV1AgentHealthzGetQueryOptions(options);
+
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<
+ TData,
+ TError
+ > & { queryKey: DataTag };
+
+ return { ...query, queryKey: queryOptions.queryKey };
+}
+
/**
* Return boards the authenticated agent can access.
@@ -3326,9 +3512,9 @@ export const useAgentLeadNudgeAgent = <
);
};
/**
- * Record liveness for the authenticated agent's current status.
+ * Record liveness for the authenticated agent.
-Use this when the agent heartbeat loop reports status changes.
+Use this when the agent heartbeat loop checks in.
* @summary Upsert agent heartbeat
*/
export type agentHeartbeatApiV1AgentHeartbeatPostResponse200 = {
@@ -3359,7 +3545,6 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostUrl = () => {
};
export const agentHeartbeatApiV1AgentHeartbeatPost = async (
- agentHeartbeatCreate: AgentHeartbeatCreate,
options?: RequestInit,
): Promise => {
return customFetch(
@@ -3367,8 +3552,6 @@ export const agentHeartbeatApiV1AgentHeartbeatPost = async (
{
...options,
method: "POST",
- headers: { "Content-Type": "application/json", ...options?.headers },
- body: JSON.stringify(agentHeartbeatCreate),
},
);
};
@@ -3380,14 +3563,14 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { data: AgentHeartbeatCreate },
+ void,
TContext
>;
request?: SecondParameter;
}): UseMutationOptions<
Awaited>,
TError,
- { data: AgentHeartbeatCreate },
+ void,
TContext
> => {
const mutationKey = ["agentHeartbeatApiV1AgentHeartbeatPost"];
@@ -3401,11 +3584,9 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { data: AgentHeartbeatCreate }
- > = (props) => {
- const { data } = props ?? {};
-
- return agentHeartbeatApiV1AgentHeartbeatPost(data, requestOptions);
+ void
+ > = () => {
+ return agentHeartbeatApiV1AgentHeartbeatPost(requestOptions);
};
return { mutationFn, ...mutationOptions };
@@ -3414,8 +3595,7 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationResult = NonNullable<
Awaited>
>;
-export type AgentHeartbeatApiV1AgentHeartbeatPostMutationBody =
- AgentHeartbeatCreate;
+
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationError =
HTTPValidationError;
@@ -3430,7 +3610,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { data: AgentHeartbeatCreate },
+ void,
TContext
>;
request?: SecondParameter;
@@ -3439,7 +3619,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
): UseMutationResult<
Awaited>,
TError,
- { data: AgentHeartbeatCreate },
+ void,
TContext
> => {
return useMutation(
diff --git a/frontend/src/api/generated/model/agentHealthStatusResponse.ts b/frontend/src/api/generated/model/agentHealthStatusResponse.ts
new file mode 100644
index 00000000..067846b2
--- /dev/null
+++ b/frontend/src/api/generated/model/agentHealthStatusResponse.ts
@@ -0,0 +1,24 @@
+/**
+ * Generated by orval v8.3.0 🍺
+ * Do not edit manually.
+ * Mission Control API
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Agent-authenticated liveness payload for agent route probes.
+ */
+export interface AgentHealthStatusResponse {
+ /** Indicates whether the probe check succeeded. */
+ ok: boolean;
+ /** Authenticated agent id derived from `X-Agent-Token`. */
+ agent_id: string;
+ /** Board scope for the authenticated agent, when applicable. */
+ board_id?: string | null;
+ /** Gateway owning the authenticated agent. */
+ gateway_id: string;
+ /** Current persisted lifecycle status for the authenticated agent. */
+ status: string;
+ /** Whether the authenticated agent is the board lead. */
+ is_board_lead: boolean;
+}
diff --git a/frontend/src/api/generated/model/gatewayCreate.ts b/frontend/src/api/generated/model/gatewayCreate.ts
index 0fff7e4a..1fa81eb8 100644
--- a/frontend/src/api/generated/model/gatewayCreate.ts
+++ b/frontend/src/api/generated/model/gatewayCreate.ts
@@ -12,5 +12,6 @@ export interface GatewayCreate {
name: string;
url: string;
workspace_root: string;
+ disable_device_pairing?: boolean;
token?: string | null;
}
diff --git a/frontend/src/api/generated/model/gatewayRead.ts b/frontend/src/api/generated/model/gatewayRead.ts
index 03dcc40c..58528e69 100644
--- a/frontend/src/api/generated/model/gatewayRead.ts
+++ b/frontend/src/api/generated/model/gatewayRead.ts
@@ -12,6 +12,7 @@ export interface GatewayRead {
name: string;
url: string;
workspace_root: string;
+ disable_device_pairing?: boolean;
id: string;
organization_id: string;
token?: string | null;
diff --git a/frontend/src/api/generated/model/gatewayUpdate.ts b/frontend/src/api/generated/model/gatewayUpdate.ts
index e5f237ef..75542c7a 100644
--- a/frontend/src/api/generated/model/gatewayUpdate.ts
+++ b/frontend/src/api/generated/model/gatewayUpdate.ts
@@ -13,4 +13,5 @@ export interface GatewayUpdate {
url?: string | null;
token?: string | null;
workspace_root?: string | null;
+ disable_device_pairing?: boolean | null;
}
diff --git a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
index 1c4bc7ce..21de85f1 100644
--- a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
+++ b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
@@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
board_id?: string | null;
gateway_url?: string | null;
gateway_token?: string | null;
+ gateway_disable_device_pairing?: boolean;
};
diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts
index b2357468..a6b8ec2d 100644
--- a/frontend/src/api/generated/model/index.ts
+++ b/frontend/src/api/generated/model/index.ts
@@ -10,6 +10,7 @@ export * from "./activityTaskCommentFeedItemRead";
export * from "./agentCreate";
export * from "./agentCreateHeartbeatConfig";
export * from "./agentCreateIdentityProfile";
+export * from "./agentHealthStatusResponse";
export * from "./agentHeartbeat";
export * from "./agentHeartbeatCreate";
export * from "./agentNudge";
diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
index 6b4e2120..0f461882 100644
--- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
@@ -40,6 +40,9 @@ export default function EditGatewayPage() {
const [gatewayToken, setGatewayToken] = useState(
undefined,
);
+ const [disableDevicePairing, setDisableDevicePairing] = useState<
+ boolean | undefined
+ >(undefined);
const [workspaceRoot, setWorkspaceRoot] = useState(
undefined,
);
@@ -82,38 +85,23 @@ export default function EditGatewayPage() {
const resolvedName = name ?? loadedGateway?.name ?? "";
const resolvedGatewayUrl = gatewayUrl ?? loadedGateway?.url ?? "";
const resolvedGatewayToken = gatewayToken ?? loadedGateway?.token ?? "";
+ const resolvedDisableDevicePairing =
+ disableDevicePairing ?? loadedGateway?.disable_device_pairing ?? false;
const resolvedWorkspaceRoot =
workspaceRoot ?? loadedGateway?.workspace_root ?? DEFAULT_WORKSPACE_ROOT;
- const isLoading = gatewayQuery.isLoading || updateMutation.isPending;
+ const isLoading =
+ gatewayQuery.isLoading ||
+ updateMutation.isPending ||
+ gatewayCheckStatus === "checking";
const errorMessage = error ?? gatewayQuery.error?.message ?? null;
const canSubmit =
Boolean(resolvedName.trim()) &&
Boolean(resolvedGatewayUrl.trim()) &&
- Boolean(resolvedWorkspaceRoot.trim()) &&
- gatewayCheckStatus === "success";
+ Boolean(resolvedWorkspaceRoot.trim());
- const runGatewayCheck = async () => {
- const validationError = validateGatewayUrl(resolvedGatewayUrl);
- setGatewayUrlError(validationError);
- if (validationError) {
- setGatewayCheckStatus("error");
- setGatewayCheckMessage(validationError);
- return;
- }
- if (!isSignedIn) return;
- setGatewayCheckStatus("checking");
- setGatewayCheckMessage(null);
- const { ok, message } = await checkGatewayConnection({
- gatewayUrl: resolvedGatewayUrl,
- gatewayToken: resolvedGatewayToken,
- });
- setGatewayCheckStatus(ok ? "success" : "error");
- setGatewayCheckMessage(message);
- };
-
- const handleSubmit = (event: React.FormEvent) => {
+ const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!isSignedIn || !gatewayId) return;
@@ -133,12 +121,26 @@ export default function EditGatewayPage() {
return;
}
+ setGatewayCheckStatus("checking");
+ setGatewayCheckMessage(null);
+ const { ok, message } = await checkGatewayConnection({
+ gatewayUrl: resolvedGatewayUrl,
+ gatewayToken: resolvedGatewayToken,
+ gatewayDisableDevicePairing: resolvedDisableDevicePairing,
+ });
+ setGatewayCheckStatus(ok ? "success" : "error");
+ setGatewayCheckMessage(message);
+ if (!ok) {
+ return;
+ }
+
setError(null);
const payload: GatewayUpdate = {
name: resolvedName.trim(),
url: resolvedGatewayUrl.trim(),
token: resolvedGatewayToken.trim() || null,
+ disable_device_pairing: resolvedDisableDevicePairing,
workspace_root: resolvedWorkspaceRoot.trim(),
};
@@ -164,6 +166,7 @@ export default function EditGatewayPage() {
name={resolvedName}
gatewayUrl={resolvedGatewayUrl}
gatewayToken={resolvedGatewayToken}
+ disableDevicePairing={resolvedDisableDevicePairing}
workspaceRoot={resolvedWorkspaceRoot}
gatewayUrlError={gatewayUrlError}
gatewayCheckStatus={gatewayCheckStatus}
@@ -177,7 +180,6 @@ export default function EditGatewayPage() {
submitBusyLabel="Saving…"
onSubmit={handleSubmit}
onCancel={() => router.push("/gateways")}
- onRunGatewayCheck={runGatewayCheck}
onNameChange={setName}
onGatewayUrlChange={(next) => {
setGatewayUrl(next);
@@ -190,6 +192,11 @@ export default function EditGatewayPage() {
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
+ onDisableDevicePairingChange={(next) => {
+ setDisableDevicePairing(next);
+ setGatewayCheckStatus("idle");
+ setGatewayCheckMessage(null);
+ }}
onWorkspaceRootChange={setWorkspaceRoot}
/>
diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx
index b3f4a94e..059d3963 100644
--- a/frontend/src/app/gateways/[gatewayId]/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/page.tsx
@@ -115,6 +115,7 @@ export default function GatewayDetailPage() {
? {
gateway_url: gateway.url,
gateway_token: gateway.token ?? undefined,
+ gateway_disable_device_pairing: gateway.disable_device_pairing,
}
: {};
@@ -232,6 +233,14 @@ export default function GatewayDetailPage() {
{maskToken(gateway.token)}
+
+
+ Device pairing
+
+
+ {gateway.disable_device_pairing ? "Disabled" : "Required"}
+
+
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx
index f72db43e..e3463777 100644
--- a/frontend/src/app/gateways/new/page.tsx
+++ b/frontend/src/app/gateways/new/page.tsx
@@ -28,6 +28,7 @@ export default function NewGatewayPage() {
const [name, setName] = useState("");
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
+ const [disableDevicePairing, setDisableDevicePairing] = useState(false);
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
const [gatewayUrlError, setGatewayUrlError] = useState(null);
@@ -52,34 +53,15 @@ export default function NewGatewayPage() {
},
});
- const isLoading = createMutation.isPending;
+ const isLoading =
+ createMutation.isPending || gatewayCheckStatus === "checking";
const canSubmit =
Boolean(name.trim()) &&
Boolean(gatewayUrl.trim()) &&
- Boolean(workspaceRoot.trim()) &&
- gatewayCheckStatus === "success";
+ Boolean(workspaceRoot.trim());
- const runGatewayCheck = async () => {
- const validationError = validateGatewayUrl(gatewayUrl);
- setGatewayUrlError(validationError);
- if (validationError) {
- setGatewayCheckStatus("error");
- setGatewayCheckMessage(validationError);
- return;
- }
- if (!isSignedIn) return;
- setGatewayCheckStatus("checking");
- setGatewayCheckMessage(null);
- const { ok, message } = await checkGatewayConnection({
- gatewayUrl,
- gatewayToken,
- });
- setGatewayCheckStatus(ok ? "success" : "error");
- setGatewayCheckMessage(message);
- };
-
- const handleSubmit = (event: React.FormEvent) => {
+ const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!isSignedIn) return;
@@ -99,12 +81,26 @@ export default function NewGatewayPage() {
return;
}
+ setGatewayCheckStatus("checking");
+ setGatewayCheckMessage(null);
+ const { ok, message } = await checkGatewayConnection({
+ gatewayUrl,
+ gatewayToken,
+ gatewayDisableDevicePairing: disableDevicePairing,
+ });
+ setGatewayCheckStatus(ok ? "success" : "error");
+ setGatewayCheckMessage(message);
+ if (!ok) {
+ return;
+ }
+
setError(null);
createMutation.mutate({
data: {
name: name.trim(),
url: gatewayUrl.trim(),
token: gatewayToken.trim() || null,
+ disable_device_pairing: disableDevicePairing,
workspace_root: workspaceRoot.trim(),
},
});
@@ -125,6 +121,7 @@ export default function NewGatewayPage() {
name={name}
gatewayUrl={gatewayUrl}
gatewayToken={gatewayToken}
+ disableDevicePairing={disableDevicePairing}
workspaceRoot={workspaceRoot}
gatewayUrlError={gatewayUrlError}
gatewayCheckStatus={gatewayCheckStatus}
@@ -138,7 +135,6 @@ export default function NewGatewayPage() {
submitBusyLabel="Creating…"
onSubmit={handleSubmit}
onCancel={() => router.push("/gateways")}
- onRunGatewayCheck={runGatewayCheck}
onNameChange={setName}
onGatewayUrlChange={(next) => {
setGatewayUrl(next);
@@ -151,6 +147,11 @@ export default function NewGatewayPage() {
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
+ onDisableDevicePairingChange={(next) => {
+ setDisableDevicePairing(next);
+ setGatewayCheckStatus("idle");
+ setGatewayCheckMessage(null);
+ }}
onWorkspaceRootChange={setWorkspaceRoot}
/>
diff --git a/frontend/src/components/gateways/GatewayForm.tsx b/frontend/src/components/gateways/GatewayForm.tsx
index f5068faf..79906817 100644
--- a/frontend/src/components/gateways/GatewayForm.tsx
+++ b/frontend/src/components/gateways/GatewayForm.tsx
@@ -1,5 +1,4 @@
import type { FormEvent } from "react";
-import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
import type { GatewayCheckStatus } from "@/lib/gateway-form";
import { Button } from "@/components/ui/button";
@@ -9,6 +8,7 @@ type GatewayFormProps = {
name: string;
gatewayUrl: string;
gatewayToken: string;
+ disableDevicePairing: boolean;
workspaceRoot: string;
gatewayUrlError: string | null;
gatewayCheckStatus: GatewayCheckStatus;
@@ -22,10 +22,10 @@ type GatewayFormProps = {
submitBusyLabel: string;
onSubmit: (event: FormEvent) => void;
onCancel: () => void;
- onRunGatewayCheck: () => Promise;
onNameChange: (next: string) => void;
onGatewayUrlChange: (next: string) => void;
onGatewayTokenChange: (next: string) => void;
+ onDisableDevicePairingChange: (next: boolean) => void;
onWorkspaceRootChange: (next: string) => void;
};
@@ -33,6 +33,7 @@ export function GatewayForm({
name,
gatewayUrl,
gatewayToken,
+ disableDevicePairing,
workspaceRoot,
gatewayUrlError,
gatewayCheckStatus,
@@ -46,10 +47,10 @@ export function GatewayForm({
submitBusyLabel,
onSubmit,
onCancel,
- onRunGatewayCheck,
onNameChange,
onGatewayUrlChange,
onGatewayTokenChange,
+ onDisableDevicePairingChange,
onWorkspaceRootChange,
}: GatewayFormProps) {
return (
@@ -78,40 +79,15 @@ export function GatewayForm({
onGatewayUrlChange(event.target.value)}
- onBlur={onRunGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
-
{gatewayUrlError ? (
{gatewayUrlError}
- ) : gatewayCheckMessage ? (
-
- {gatewayCheckMessage}
-
+ ) : gatewayCheckStatus === "error" && gatewayCheckMessage ? (
+ {gatewayCheckMessage}
) : null}
@@ -121,23 +97,51 @@ export function GatewayForm({
onGatewayTokenChange(event.target.value)}
- onBlur={onRunGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
-
-
-
onWorkspaceRootChange(event.target.value)}
- placeholder={workspaceRootPlaceholder}
- disabled={isLoading}
- />
+
+
+
+ onWorkspaceRootChange(event.target.value)}
+ placeholder={workspaceRootPlaceholder}
+ disabled={isLoading}
+ />
+
+
+
+
+
+
{errorMessage ? (
diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts
new file mode 100644
index 00000000..059174d9
--- /dev/null
+++ b/frontend/src/lib/gateway-form.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
+
+import { checkGatewayConnection, validateGatewayUrl } from "./gateway-form";
+
+vi.mock("@/api/generated/gateways/gateways", () => ({
+ gatewaysStatusApiV1GatewaysStatusGet: vi.fn(),
+}));
+
+const mockedGatewaysStatusApiV1GatewaysStatusGet = vi.mocked(
+ gatewaysStatusApiV1GatewaysStatusGet,
+);
+
+describe("validateGatewayUrl", () => {
+ it("requires ws/wss with an explicit port", () => {
+ expect(validateGatewayUrl("https://gateway.example")).toBe(
+ "Gateway URL must start with ws:// or wss://.",
+ );
+ expect(validateGatewayUrl("ws://gateway.example")).toBe(
+ "Gateway URL must include an explicit port.",
+ );
+ expect(validateGatewayUrl("ws://gateway.example:18789")).toBeNull();
+ });
+});
+
+describe("checkGatewayConnection", () => {
+ beforeEach(() => {
+ mockedGatewaysStatusApiV1GatewaysStatusGet.mockReset();
+ });
+
+ it("passes pairing toggle to gateway status API", async () => {
+ mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({
+ status: 200,
+ data: { connected: true },
+ } as never);
+
+ const result = await checkGatewayConnection({
+ gatewayUrl: "ws://gateway.example:18789",
+ gatewayToken: "secret-token",
+ gatewayDisableDevicePairing: true,
+ });
+
+ expect(mockedGatewaysStatusApiV1GatewaysStatusGet).toHaveBeenCalledWith({
+ gateway_url: "ws://gateway.example:18789",
+ gateway_token: "secret-token",
+ gateway_disable_device_pairing: true,
+ });
+ expect(result).toEqual({ ok: true, message: "Gateway reachable." });
+ });
+
+ it("returns gateway-provided error message when offline", async () => {
+ mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({
+ status: 200,
+ data: {
+ connected: false,
+ error: "missing required scope",
+ },
+ } as never);
+
+ const result = await checkGatewayConnection({
+ gatewayUrl: "ws://gateway.example:18789",
+ gatewayToken: "",
+ gatewayDisableDevicePairing: false,
+ });
+
+ expect(result).toEqual({ ok: false, message: "missing required scope" });
+ });
+});
diff --git a/frontend/src/lib/gateway-form.ts b/frontend/src/lib/gateway-form.ts
index 5dec8c54..3c9e1171 100644
--- a/frontend/src/lib/gateway-form.ts
+++ b/frontend/src/lib/gateway-form.ts
@@ -24,10 +24,16 @@ export const validateGatewayUrl = (value: string) => {
export async function checkGatewayConnection(params: {
gatewayUrl: string;
gatewayToken: string;
+ gatewayDisableDevicePairing: boolean;
}): Promise<{ ok: boolean; message: string }> {
try {
- const requestParams: Record
= {
+ const requestParams: {
+ gateway_url: string;
+ gateway_token?: string;
+ gateway_disable_device_pairing: boolean;
+ } = {
gateway_url: params.gatewayUrl.trim(),
+ gateway_disable_device_pairing: params.gatewayDisableDevicePairing,
};
if (params.gatewayToken.trim()) {
requestParams.gateway_token = params.gatewayToken.trim();
From ab7a3c66cebf79773478db7c73afc5b721181341 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 19:41:26 +0530
Subject: [PATCH 017/120] feat: add disable_device_pairing parameter to agent
configuration
---
backend/tests/test_agent_delete_lead_agent.py | 1 +
backend/tests/test_agent_delete_main_agent.py | 1 +
backend/tests/test_agent_provisioning_utils.py | 3 +++
3 files changed, 5 insertions(+)
diff --git a/backend/tests/test_agent_delete_lead_agent.py b/backend/tests/test_agent_delete_lead_agent.py
index 0713b8da..c30f78f7 100644
--- a/backend/tests/test_agent_delete_lead_agent.py
+++ b/backend/tests/test_agent_delete_lead_agent.py
@@ -51,6 +51,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
+ disable_device_pairing: bool = False
@pytest.mark.asyncio
diff --git a/backend/tests/test_agent_delete_main_agent.py b/backend/tests/test_agent_delete_main_agent.py
index 3bcaf9c1..284a1e88 100644
--- a/backend/tests/test_agent_delete_main_agent.py
+++ b/backend/tests/test_agent_delete_main_agent.py
@@ -43,6 +43,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
+ disable_device_pairing: bool = False
@pytest.mark.asyncio
diff --git a/backend/tests/test_agent_provisioning_utils.py b/backend/tests/test_agent_provisioning_utils.py
index f5529e3b..78fc135f 100644
--- a/backend/tests/test_agent_provisioning_utils.py
+++ b/backend/tests/test_agent_provisioning_utils.py
@@ -119,6 +119,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
+ disable_device_pairing: bool = False
@pytest.mark.asyncio
@@ -229,6 +230,7 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
url: str
token: str | None
workspace_root: str
+ disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -296,6 +298,7 @@ async def test_set_agent_files_update_preserves_user_md_even_when_size_zero():
url: str
token: str | None
workspace_root: str
+ disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
From cdced8e07c488f5e7b60abd5285506b9a11299f0 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 19:45:18 +0530
Subject: [PATCH 018/120] refactor: improve code formatting and readability in
tests and components
---
backend/app/api/gateways.py | 6 +----
.../app/services/openclaw/device_identity.py | 12 ++++++---
.../components/BoardOnboardingChat.test.tsx | 26 ++++++++++++-------
.../src/components/gateways/GatewayForm.tsx | 4 ++-
4 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py
index 1e567484..4b6e5199 100644
--- a/backend/app/api/gateways.py
+++ b/backend/app/api/gateways.py
@@ -138,11 +138,7 @@ async def update_gateway(
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
- if (
- "url" in updates
- or "token" in updates
- or "disable_device_pairing" in updates
- ):
+ if "url" in updates or "token" in updates or "disable_device_pairing" in updates:
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
diff --git a/backend/app/services/openclaw/device_identity.py b/backend/app/services/openclaw/device_identity.py
index 8a94b87c..e0652a93 100644
--- a/backend/app/services/openclaw/device_identity.py
+++ b/backend/app/services/openclaw/device_identity.py
@@ -80,10 +80,14 @@ def _generate_identity() -> DeviceIdentity:
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
- public_key_pem = private_key.public_key().public_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
- ).decode("utf-8")
+ public_key_pem = (
+ private_key.public_key()
+ .public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ .decode("utf-8")
+ )
device_id = _derive_device_id(public_key_pem)
return DeviceIdentity(
device_id=device_id,
diff --git a/frontend/src/components/BoardOnboardingChat.test.tsx b/frontend/src/components/BoardOnboardingChat.test.tsx
index b32591db..20487678 100644
--- a/frontend/src/components/BoardOnboardingChat.test.tsx
+++ b/frontend/src/components/BoardOnboardingChat.test.tsx
@@ -1,4 +1,10 @@
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -21,9 +27,7 @@ vi.mock("@/components/ui/dialog", () => ({
DialogFooter: ({ children }: { children?: ReactNode }) => (
{children}
),
- DialogTitle: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
+ DialogTitle: ({ children }: { children?: ReactNode }) => {children}
,
}));
vi.mock("@/api/generated/board-onboarding/board-onboarding", () => ({
@@ -31,10 +35,12 @@ vi.mock("@/api/generated/board-onboarding/board-onboarding", () => ({
startOnboardingMock(...args),
getOnboardingApiV1BoardsBoardIdOnboardingGet: (...args: unknown[]) =>
getOnboardingMock(...args),
- answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost: (...args: unknown[]) =>
- answerOnboardingMock(...args),
- confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost: (...args: unknown[]) =>
- confirmOnboardingMock(...args),
+ answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost: (
+ ...args: unknown[]
+ ) => answerOnboardingMock(...args),
+ confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost: (
+ ...args: unknown[]
+ ) => confirmOnboardingMock(...args),
}));
const buildQuestionSession = (question: string): BoardOnboardingRead => ({
@@ -116,7 +122,9 @@ describe("BoardOnboardingChat polling", () => {
});
await waitFor(() => {
- expect(getOnboardingMock.mock.calls.length).toBeGreaterThan(callsBeforePoll);
+ expect(getOnboardingMock.mock.calls.length).toBeGreaterThan(
+ callsBeforePoll,
+ );
});
});
});
diff --git a/frontend/src/components/gateways/GatewayForm.tsx b/frontend/src/components/gateways/GatewayForm.tsx
index 79906817..d46a4cc5 100644
--- a/frontend/src/components/gateways/GatewayForm.tsx
+++ b/frontend/src/components/gateways/GatewayForm.tsx
@@ -126,7 +126,9 @@ export function GatewayForm({
role="switch"
aria-checked={disableDevicePairing}
aria-label="Disable device pairing"
- onClick={() => onDisableDevicePairingChange(!disableDevicePairing)}
+ onClick={() =>
+ onDisableDevicePairingChange(!disableDevicePairing)
+ }
disabled={isLoading}
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
disableDevicePairing
From d37f230eb32af0064c764d8ad7a599aa67e12ca0 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 20:07:30 +0530
Subject: [PATCH 019/120] feat: add allow_insecure_tls column to gateways
---
...97b348ebb4_add_gateway_allow_insecure_tls_flag.py} | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
rename backend/migrations/versions/{2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py => b497b348ebb4_add_gateway_allow_insecure_tls_flag.py} (83%)
diff --git a/backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py b/backend/migrations/versions/b497b348ebb4_add_gateway_allow_insecure_tls_flag.py
similarity index 83%
rename from backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py
rename to backend/migrations/versions/b497b348ebb4_add_gateway_allow_insecure_tls_flag.py
index 48174e8c..1b4f9718 100644
--- a/backend/migrations/versions/2f3e4a5b6c7d_add_gateway_allow_insecure_tls.py
+++ b/backend/migrations/versions/b497b348ebb4_add_gateway_allow_insecure_tls_flag.py
@@ -1,8 +1,8 @@
"""Add allow_insecure_tls field to gateways.
-Revision ID: 2f3e4a5b6c7d
-Revises: 1a7b2c3d4e5f
-Create Date: 2026-02-22 05:30:00.000000
+Revision ID: b497b348ebb4
+Revises: c5d1a2b3e4f6
+Create Date: 2026-02-22 20:06:54.417968
"""
@@ -11,9 +11,10 @@ from __future__ import annotations
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
-revision = "2f3e4a5b6c7d"
-down_revision = "1a7b2c3d4e5f"
+revision = "b497b348ebb4"
+down_revision = "c5d1a2b3e4f6"
branch_labels = None
depends_on = None
From 56f4964332a2f2dae5da33c2efefe39815f2bd73 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Sun, 22 Feb 2026 20:20:19 +0530
Subject: [PATCH 020/120] feat: add support for allowing self-signed TLS
certificates in gateway configuration
---
backend/app/api/gateway.py | 2 +
backend/app/schemas/gateway_api.py | 1 +
.../app/services/openclaw/session_service.py | 3 ++
backend/tests/test_gateway_resolver.py | 38 ++++++++++++++++++
.../src/api/generated/model/gatewayRead.ts | 2 +-
...ewaysStatusApiV1GatewaysStatusGetParams.ts | 1 +
.../app/gateways/[gatewayId]/edit/page.tsx | 7 +++-
frontend/src/app/gateways/new/page.tsx | 7 +++-
.../src/components/gateways/GatewayForm.tsx | 39 ++++++++++++-------
frontend/src/lib/gateway-form.test.ts | 5 ++-
frontend/src/lib/gateway-form.ts | 3 ++
11 files changed, 89 insertions(+), 19 deletions(-)
diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py
index 5efc8552..a3e2d807 100644
--- a/backend/app/api/gateway.py
+++ b/backend/app/api/gateway.py
@@ -38,12 +38,14 @@ def _query_to_resolve_input(
gateway_url: str | None = Query(default=None),
gateway_token: str | None = Query(default=None),
gateway_disable_device_pairing: bool = Query(default=False),
+ gateway_allow_insecure_tls: bool = Query(default=False),
) -> GatewayResolveQuery:
return GatewaySessionService.to_resolve_query(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
gateway_disable_device_pairing=gateway_disable_device_pairing,
+ gateway_allow_insecure_tls=gateway_allow_insecure_tls,
)
diff --git a/backend/app/schemas/gateway_api.py b/backend/app/schemas/gateway_api.py
index 13cc2094..188a25b2 100644
--- a/backend/app/schemas/gateway_api.py
+++ b/backend/app/schemas/gateway_api.py
@@ -22,6 +22,7 @@ class GatewayResolveQuery(SQLModel):
gateway_url: str | None = None
gateway_token: str | None = None
gateway_disable_device_pairing: bool = False
+ gateway_allow_insecure_tls: bool = False
class GatewaysStatusResponse(SQLModel):
diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py
index 377856f8..2f7e3e1b 100644
--- a/backend/app/services/openclaw/session_service.py
+++ b/backend/app/services/openclaw/session_service.py
@@ -66,12 +66,14 @@ class GatewaySessionService(OpenClawDBService):
gateway_url: str | None,
gateway_token: str | None,
gateway_disable_device_pairing: bool = False,
+ gateway_allow_insecure_tls: bool = False,
) -> GatewayResolveQuery:
return GatewayResolveQuery(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
gateway_disable_device_pairing=gateway_disable_device_pairing,
+ gateway_allow_insecure_tls=gateway_allow_insecure_tls,
)
@staticmethod
@@ -112,6 +114,7 @@ class GatewaySessionService(OpenClawDBService):
GatewayClientConfig(
url=raw_url,
token=(params.gateway_token or "").strip() or None,
+ allow_insecure_tls=params.gateway_allow_insecure_tls,
disable_device_pairing=params.gateway_disable_device_pairing,
),
None,
diff --git a/backend/tests/test_gateway_resolver.py b/backend/tests/test_gateway_resolver.py
index ccbf673d..2c5da95a 100644
--- a/backend/tests/test_gateway_resolver.py
+++ b/backend/tests/test_gateway_resolver.py
@@ -3,7 +3,10 @@ from __future__ import annotations
from uuid import uuid4
+import pytest
+
from app.models.gateways import Gateway
+from app.schemas.gateway_api import GatewayResolveQuery
from app.services.openclaw.gateway_resolver import (
gateway_client_config,
optional_gateway_client_config,
@@ -14,6 +17,7 @@ from app.services.openclaw.session_service import GatewaySessionService
def _gateway(
*,
disable_device_pairing: bool,
+ allow_insecure_tls: bool = False,
url: str = "ws://gateway.example:18789/ws",
token: str | None = " secret-token ",
) -> Gateway:
@@ -25,6 +29,7 @@ def _gateway(
token=token,
workspace_root="~/.openclaw",
disable_device_pairing=disable_device_pairing,
+ allow_insecure_tls=allow_insecure_tls,
)
@@ -43,6 +48,14 @@ def test_optional_gateway_client_config_maps_disable_device_pairing() -> None:
assert config.disable_device_pairing is False
+def test_gateway_client_config_maps_allow_insecure_tls() -> None:
+ config = gateway_client_config(
+ _gateway(disable_device_pairing=False, allow_insecure_tls=True),
+ )
+
+ assert config.allow_insecure_tls is True
+
+
def test_optional_gateway_client_config_returns_none_for_missing_or_blank_url() -> None:
assert optional_gateway_client_config(None) is None
assert (
@@ -62,3 +75,28 @@ def test_to_resolve_query_keeps_gateway_disable_device_pairing_value() -> None:
)
assert resolved.gateway_disable_device_pairing is True
+
+
+def test_to_resolve_query_keeps_gateway_allow_insecure_tls_value() -> None:
+ resolved = GatewaySessionService.to_resolve_query(
+ board_id=None,
+ gateway_url="wss://gateway.example:18789/ws",
+ gateway_token="secret-token",
+ gateway_allow_insecure_tls=True,
+ )
+
+ assert resolved.gateway_allow_insecure_tls is True
+
+
+@pytest.mark.asyncio
+async def test_resolve_gateway_keeps_gateway_allow_insecure_tls_for_direct_url() -> None:
+ service = GatewaySessionService(session=object()) # type: ignore[arg-type]
+ _, config, _ = await service.resolve_gateway(
+ GatewayResolveQuery(
+ gateway_url="wss://gateway.example:18789/ws",
+ gateway_allow_insecure_tls=True,
+ ),
+ user=None,
+ )
+
+ assert config.allow_insecure_tls is True
diff --git a/frontend/src/api/generated/model/gatewayRead.ts b/frontend/src/api/generated/model/gatewayRead.ts
index ed048337..e7dde24e 100644
--- a/frontend/src/api/generated/model/gatewayRead.ts
+++ b/frontend/src/api/generated/model/gatewayRead.ts
@@ -12,7 +12,7 @@ export interface GatewayRead {
name: string;
url: string;
workspace_root: string;
- allow_insecure_tls: boolean;
+ allow_insecure_tls?: boolean;
disable_device_pairing?: boolean;
id: string;
organization_id: string;
diff --git a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
index 21de85f1..ae875a56 100644
--- a/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
+++ b/frontend/src/api/generated/model/gatewaysStatusApiV1GatewaysStatusGetParams.ts
@@ -10,4 +10,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
gateway_url?: string | null;
gateway_token?: string | null;
gateway_disable_device_pairing?: boolean;
+ gateway_allow_insecure_tls?: boolean;
};
diff --git a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
index b60b6771..7b4b1246 100644
--- a/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/edit/page.tsx
@@ -132,6 +132,7 @@ export default function EditGatewayPage() {
gatewayUrl: resolvedGatewayUrl,
gatewayToken: resolvedGatewayToken,
gatewayDisableDevicePairing: resolvedDisableDevicePairing,
+ gatewayAllowInsecureTls: resolvedAllowInsecureTls,
});
setGatewayCheckStatus(ok ? "success" : "error");
setGatewayCheckMessage(message);
@@ -205,7 +206,11 @@ export default function EditGatewayPage() {
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
- onAllowInsecureTlsChange={setAllowInsecureTls}
+ onAllowInsecureTlsChange={(next) => {
+ setAllowInsecureTls(next);
+ setGatewayCheckStatus("idle");
+ setGatewayCheckMessage(null);
+ }}
/>
);
diff --git a/frontend/src/app/gateways/new/page.tsx b/frontend/src/app/gateways/new/page.tsx
index 99a9130e..7e752c6d 100644
--- a/frontend/src/app/gateways/new/page.tsx
+++ b/frontend/src/app/gateways/new/page.tsx
@@ -88,6 +88,7 @@ export default function NewGatewayPage() {
gatewayUrl,
gatewayToken,
gatewayDisableDevicePairing: disableDevicePairing,
+ gatewayAllowInsecureTls: allowInsecureTls,
});
setGatewayCheckStatus(ok ? "success" : "error");
setGatewayCheckMessage(message);
@@ -156,7 +157,11 @@ export default function NewGatewayPage() {
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
- onAllowInsecureTlsChange={setAllowInsecureTls}
+ onAllowInsecureTlsChange={(next) => {
+ setAllowInsecureTls(next);
+ setGatewayCheckStatus("idle");
+ setGatewayCheckMessage(null);
+ }}
/>
);
diff --git a/frontend/src/components/gateways/GatewayForm.tsx b/frontend/src/components/gateways/GatewayForm.tsx
index 49b7ff01..5e36599c 100644
--- a/frontend/src/components/gateways/GatewayForm.tsx
+++ b/frontend/src/components/gateways/GatewayForm.tsx
@@ -150,21 +150,30 @@ export function GatewayForm({
-
-
onAllowInsecureTlsChange(event.target.checked)}
- disabled={isLoading}
- />
-
+
+
+
+
+ Require comment for review
+
+
+ Require a task comment when moving status to{" "}
+ review.
+
+
+