fix(gateway): prefer bootstrap auth over tailscale (#59232)

* fix(gateway): prefer bootstrap auth over tailscale

* fix(gateway): prefer bootstrap auth over tailscale (#59232) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman 2026-04-01 23:20:10 +03:00 committed by GitHub
parent 5cf254a5f7
commit 017bc5261c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 48 additions and 13 deletions

View File

@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
- Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman.
- Config/Discord: coerce safe integer numeric Discord IDs to strings during config validation, keep unsafe or precision-losing numeric snowflakes rejected, and align `openclaw doctor` repair guidance with the same fail-closed behavior. (#45125) Thanks @moliendocode.
- Gateway/sessions: scope bare `sessions.create` aliases like `main` to the requested agent while preserving the canonical `global` and `unknown` sentinel keys. (#58207) thanks @jalehman.
- `/context detail` now compares the tracked prompt estimate with cached context usage and surfaces untracked provider/runtime overhead when present. (#28391) thanks @ImLukeF.

View File

@ -169,23 +169,58 @@ describe("resolveConnectAuthDecision", () => {
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns the original decision when device fallback does not apply", async () => {
it("prefers a valid bootstrap token over an already successful shared auth path", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },
authResult: { ok: true, method: "tailscale" },
authOk: true,
authMethod: "tailscale",
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: undefined,
deviceTokenCandidateSource: undefined,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
role: "node",
scopes: [],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyBootstrapToken,
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("token");
expect(decision.authMethod).toBe("bootstrap-token");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("keeps the original successful auth path when bootstrap validation fails", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "tailscale" },
authOk: true,
authMethod: "tailscale",
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: undefined,
deviceTokenCandidateSource: undefined,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "node",
scopes: [],
verifyBootstrapToken,
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("tailscale");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
});

View File

@ -170,13 +170,7 @@ export async function resolveConnectAuthDecision(params: {
let authMethod = params.state.authMethod;
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
if (
params.hasDeviceIdentity &&
params.deviceId &&
params.publicKey &&
!authOk &&
bootstrapTokenCandidate
) {
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
const tokenCheck = await params.verifyBootstrapToken({
deviceId: params.deviceId,
publicKey: params.publicKey,
@ -185,9 +179,14 @@ export async function resolveConnectAuthDecision(params: {
scopes: params.scopes,
});
if (tokenCheck.ok) {
// Prefer an explicit valid bootstrap token even when another auth path
// (for example tailscale serve header auth) already succeeded. QR pairing
// relies on the server classifying the handshake as bootstrap-token so the
// initial node pairing can be silently auto-approved and the bootstrap
// token can be revoked after approval.
authOk = true;
authMethod = "bootstrap-token";
} else {
} else if (!authOk) {
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
}
}