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. + + +