Merge branch 'master' into copilot/feature-allow-self-signed-tls

# Conflicts:
#	backend/app/api/gateways.py
#	backend/app/schemas/gateways.py
#	backend/app/services/openclaw/admin_service.py
#	backend/app/services/openclaw/gateway_resolver.py
#	backend/app/services/openclaw/gateway_rpc.py
#	backend/app/services/openclaw/provisioning.py
#	backend/app/services/openclaw/provisioning_db.py
#	frontend/src/api/generated/model/gatewayCreate.ts
#	frontend/src/api/generated/model/gatewayRead.ts
#	frontend/src/api/generated/model/gatewayUpdate.ts
This commit is contained in:
Abhimanyu Saharan
2026-02-22 19:51:27 +05:30
39 changed files with 1357 additions and 196 deletions

View File

@@ -22,7 +22,7 @@ import type {
import type {
AgentCreate,
AgentHeartbeatCreate,
AgentHealthStatusResponse,
AgentNudge,
AgentRead,
ApprovalCreate,
@@ -67,6 +67,192 @@ import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[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<agentHealthzApiV1AgentHealthzGetResponse> => {
return customFetch<agentHealthzApiV1AgentHealthzGetResponse>(
getAgentHealthzApiV1AgentHealthzGetUrl(),
{
...options,
method: "GET",
},
);
};
export const getAgentHealthzApiV1AgentHealthzGetQueryKey = () => {
return [`/api/v1/agent/healthz`] as const;
};
export const getAgentHealthzApiV1AgentHealthzGetQueryOptions = <
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError = HTTPValidationError,
>(options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getAgentHealthzApiV1AgentHealthzGetQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
> = ({ signal }) =>
agentHealthzApiV1AgentHealthzGet({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type AgentHealthzApiV1AgentHealthzGetQueryResult = NonNullable<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
>;
export type AgentHealthzApiV1AgentHealthzGetQueryError = HTTPValidationError;
export function useAgentHealthzApiV1AgentHealthzGet<
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError = HTTPValidationError,
>(
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useAgentHealthzApiV1AgentHealthzGet<
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError = HTTPValidationError,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useAgentHealthzApiV1AgentHealthzGet<
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError = HTTPValidationError,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Agent Auth Health Check
*/
export function useAgentHealthzApiV1AgentHealthzGet<
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError = HTTPValidationError,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getAgentHealthzApiV1AgentHealthzGetQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<agentHeartbeatApiV1AgentHeartbeatPostResponse> => {
return customFetch<agentHeartbeatApiV1AgentHeartbeatPostResponse>(
@@ -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<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
TError,
{ data: AgentHeartbeatCreate },
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
TError,
{ data: AgentHeartbeatCreate },
void,
TContext
> => {
const mutationKey = ["agentHeartbeatApiV1AgentHeartbeatPost"];
@@ -3401,11 +3584,9 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
{ 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<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>
>;
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationBody =
AgentHeartbeatCreate;
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationError =
HTTPValidationError;
@@ -3430,7 +3610,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
TError,
{ data: AgentHeartbeatCreate },
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
@@ -3439,7 +3619,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
): UseMutationResult<
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
TError,
{ data: AgentHeartbeatCreate },
void,
TContext
> => {
return useMutation(

View File

@@ -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;
}

View File

@@ -13,5 +13,6 @@ export interface GatewayCreate {
url: string;
workspace_root: string;
allow_insecure_tls?: boolean;
disable_device_pairing?: boolean;
token?: string | null;
}

View File

@@ -13,6 +13,7 @@ export interface GatewayRead {
url: string;
workspace_root: string;
allow_insecure_tls: boolean;
disable_device_pairing?: boolean;
id: string;
organization_id: string;
token?: string | null;

View File

@@ -14,4 +14,5 @@ export interface GatewayUpdate {
token?: string | null;
workspace_root?: string | null;
allow_insecure_tls?: boolean | null;
disable_device_pairing?: boolean | null;
}

View File

@@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
board_id?: string | null;
gateway_url?: string | null;
gateway_token?: string | null;
gateway_disable_device_pairing?: boolean;
};

View File

@@ -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";

View File

@@ -40,6 +40,9 @@ export default function EditGatewayPage() {
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
undefined,
);
const [disableDevicePairing, setDisableDevicePairing] = useState<
boolean | undefined
>(undefined);
const [workspaceRoot, setWorkspaceRoot] = useState<string | undefined>(
undefined,
);
@@ -85,40 +88,25 @@ 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 resolvedAllowInsecureTls =
allowInsecureTls ?? loadedGateway?.allow_insecure_tls ?? false;
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<HTMLFormElement>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !gatewayId) return;
@@ -138,12 +126,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(),
allow_insecure_tls: resolvedAllowInsecureTls,
};
@@ -170,6 +172,7 @@ export default function EditGatewayPage() {
name={resolvedName}
gatewayUrl={resolvedGatewayUrl}
gatewayToken={resolvedGatewayToken}
disableDevicePairing={resolvedDisableDevicePairing}
workspaceRoot={resolvedWorkspaceRoot}
allowInsecureTls={resolvedAllowInsecureTls}
gatewayUrlError={gatewayUrlError}
@@ -184,7 +187,6 @@ export default function EditGatewayPage() {
submitBusyLabel="Saving…"
onSubmit={handleSubmit}
onCancel={() => router.push("/gateways")}
onRunGatewayCheck={runGatewayCheck}
onNameChange={setName}
onGatewayUrlChange={(next) => {
setGatewayUrl(next);
@@ -197,6 +199,11 @@ export default function EditGatewayPage() {
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onDisableDevicePairingChange={(next) => {
setDisableDevicePairing(next);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
onAllowInsecureTlsChange={setAllowInsecureTls}
/>

View File

@@ -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)}
</p>
</div>
<div>
<p className="text-xs uppercase text-slate-400">
Device pairing
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{gateway.disable_device_pairing ? "Disabled" : "Required"}
</p>
</div>
</div>
</div>

View File

@@ -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 [allowInsecureTls, setAllowInsecureTls] = useState(false);
@@ -53,34 +54,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<HTMLFormElement>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
@@ -100,12 +82,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(),
allow_insecure_tls: allowInsecureTls,
},
@@ -127,6 +123,7 @@ export default function NewGatewayPage() {
name={name}
gatewayUrl={gatewayUrl}
gatewayToken={gatewayToken}
disableDevicePairing={disableDevicePairing}
workspaceRoot={workspaceRoot}
allowInsecureTls={allowInsecureTls}
gatewayUrlError={gatewayUrlError}
@@ -141,7 +138,6 @@ export default function NewGatewayPage() {
submitBusyLabel="Creating…"
onSubmit={handleSubmit}
onCancel={() => router.push("/gateways")}
onRunGatewayCheck={runGatewayCheck}
onNameChange={setName}
onGatewayUrlChange={(next) => {
setGatewayUrl(next);
@@ -154,6 +150,11 @@ export default function NewGatewayPage() {
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onDisableDevicePairingChange={(next) => {
setDisableDevicePairing(next);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onWorkspaceRootChange={setWorkspaceRoot}
onAllowInsecureTlsChange={setAllowInsecureTls}
/>

View File

@@ -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 }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children?: ReactNode }) => (
<h2>{children}</h2>
),
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
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,
);
});
});
});

View File

@@ -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;
allowInsecureTls: boolean;
gatewayUrlError: string | null;
@@ -23,10 +23,10 @@ type GatewayFormProps = {
submitBusyLabel: string;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onCancel: () => void;
onRunGatewayCheck: () => Promise<void>;
onNameChange: (next: string) => void;
onGatewayUrlChange: (next: string) => void;
onGatewayTokenChange: (next: string) => void;
onDisableDevicePairingChange: (next: boolean) => void;
onWorkspaceRootChange: (next: string) => void;
onAllowInsecureTlsChange: (next: boolean) => void;
};
@@ -35,6 +35,7 @@ export function GatewayForm({
name,
gatewayUrl,
gatewayToken,
disableDevicePairing,
workspaceRoot,
allowInsecureTls,
gatewayUrlError,
@@ -49,10 +50,10 @@ export function GatewayForm({
submitBusyLabel,
onSubmit,
onCancel,
onRunGatewayCheck,
onNameChange,
onGatewayUrlChange,
onGatewayTokenChange,
onDisableDevicePairingChange,
onWorkspaceRootChange,
onAllowInsecureTlsChange,
}: GatewayFormProps) {
@@ -82,40 +83,15 @@ export function GatewayForm({
<Input
value={gatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
onBlur={onRunGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
<button
type="button"
onClick={() => void onRunGatewayCheck()}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : gatewayCheckStatus === "error" && gatewayCheckMessage ? (
<p className="text-xs text-red-500">{gatewayCheckMessage}</p>
) : null}
</div>
<div className="space-y-2">
@@ -125,23 +101,53 @@ export function GatewayForm({
<Input
value={gatewayToken}
onChange={(event) => onGatewayTokenChange(event.target.value)}
onBlur={onRunGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => onWorkspaceRootChange(event.target.value)}
placeholder={workspaceRootPlaceholder}
disabled={isLoading}
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => onWorkspaceRootChange(event.target.value)}
placeholder={workspaceRootPlaceholder}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Disable device pairing
</label>
<label className="flex h-10 items-center gap-3 px-1 text-sm text-slate-900">
<button
type="button"
role="switch"
aria-checked={disableDevicePairing}
aria-label="Disable device pairing"
onClick={() =>
onDisableDevicePairingChange(!disableDevicePairing)
}
disabled={isLoading}
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
disableDevicePairing
? "border-emerald-600 bg-emerald-600"
: "border-slate-300 bg-slate-200"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
disableDevicePairing ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</label>
</div>
</div>
<div className="flex items-center gap-2">

View File

@@ -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" });
});
});

View File

@@ -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<string, string> = {
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();