diff --git a/extensions/signal/src/setup-managed-validation-integration.test.ts b/extensions/signal/src/setup-managed-validation-integration.test.ts index c4c82ac4b580..899ae4998c8f 100644 --- a/extensions/signal/src/setup-managed-validation-integration.test.ts +++ b/extensions/signal/src/setup-managed-validation-integration.test.ts @@ -145,4 +145,38 @@ describe("Signal managed setup validation wiring", () => { }), ); }); + + it("propagates setup cancellation without entering readiness recovery", async () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + }, + }, + }; + const { queued, credentialValues } = await prepareLocal(cfg); + const abort = new AbortController(); + const reason = new DOMException("setup cancelled", "AbortError"); + mocks.probeManagedSignalSetup.mockImplementationOnce(async (params) => { + expect(params.abortSignal).toBe(abort.signal); + abort.abort(reason); + params.abortSignal?.throwIfAborted(); + return { ok: false }; + }); + + await expect( + runSetupWizardFinalize({ + finalize: signalSetupWizard.finalize, + cfg, + credentialValues, + prompter: queued.prompter, + runtime: createRuntimeEnv({ throwOnExit: false }), + options: { abortSignal: abort.signal }, + }), + ).rejects.toBe(reason); + + expect(queued.select).toHaveBeenCalledTimes(1); + expect(queued.note).not.toHaveBeenCalledWith(expect.any(String), "Signal setup"); + }); }); diff --git a/extensions/signal/src/setup-managed-validation.test.ts b/extensions/signal/src/setup-managed-validation.test.ts index 667b05b4cdd5..43bac94e921a 100644 --- a/extensions/signal/src/setup-managed-validation.test.ts +++ b/extensions/signal/src/setup-managed-validation.test.ts @@ -26,9 +26,14 @@ const mocks = vi.hoisted(() => ({ isExited: () => false, }), ), - waitForReady: vi.fn(async (params: { check: () => Promise }) => { - await params.check(); - }), + waitForReady: vi.fn( + async (params: { + abortSignal?: AbortSignal; + check: () => Promise; + }) => { + await params.check(); + }, + ), })); vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({ @@ -114,6 +119,34 @@ describe("probeManagedSignalSetup", () => { expect(mocks.spawnDaemon).not.toHaveBeenCalled(); }); + it.each([ + { + name: "localhost connection for an IPv4 loopback bind", + candidate: { ...transport, url: "http://localhost:8080" }, + }, + { + name: "IPv4 loopback connection for a localhost bind", + candidate: { ...transport, httpHost: "localhost", url: "http://127.0.0.1:8080" }, + }, + ])("does not re-probe the managed bind through $name", async ({ candidate }) => { + const cfg = { + channels: { signal: { accounts: { work: { account, transport: candidate } } } }, + } as OpenClawConfig; + + await expect( + probeManagedSignalSetup(createParams(cfg, candidate, account)), + ).resolves.toMatchObject({ ok: true }); + + expect(mocks.probeTransport).toHaveBeenCalledOnce(); + expect(mocks.probeTransport).toHaveBeenCalledWith( + expect.objectContaining({ + transport: expect.objectContaining({ + url: `http://${candidate.httpHost}:8080`, + }), + }), + ); + }); + it("uses owner-known proof for the exact daemon registered by the monitor lifecycle", async () => { const cfg = { channels: { signal: { accounts: { work: { account, transport } } } }, @@ -209,6 +242,22 @@ describe("probeManagedSignalSetup", () => { expect(mocks.stop).toHaveBeenCalledOnce(); }); + it("passes setup cancellation to readiness and rethrows its reason after cleanup", async () => { + const abort = new AbortController(); + const reason = new DOMException("setup cancelled", "AbortError"); + mocks.waitForReady.mockImplementationOnce(async ({ abortSignal }) => { + expect(abortSignal).toBe(abort.signal); + abort.abort(reason); + }); + + await expect( + probeManagedSignalSetup({ ...createParams(), abortSignal: abort.signal }), + ).rejects.toBe(reason); + + expect(mocks.spawnDaemon).toHaveBeenCalledOnce(); + expect(mocks.stop).toHaveBeenCalledOnce(); + }); + it("returns the actual final-port bind category without spawning", async () => { mocks.assertBindAvailable.mockRejectedValueOnce( new Error("address unavailable (EADDRNOTAVAIL)"), diff --git a/extensions/signal/src/setup-managed-validation.ts b/extensions/signal/src/setup-managed-validation.ts index bd16b98c3c5a..33e3894e1453 100644 --- a/extensions/signal/src/setup-managed-validation.ts +++ b/extensions/signal/src/setup-managed-validation.ts @@ -12,6 +12,7 @@ import { type SignalManagedNativeTransport, type SignalTransportProbeResult, } from "./setup-transport.js"; +import { isSignalManagedNativeConnectionUrlForBind } from "./transport-policy.js"; import { buildSignalTransportHttpUrl } from "./transport-url.js"; type ResolvedManagedSignalTransport = Extract< @@ -59,8 +60,10 @@ async function probeManagedBind(params: { resolved: ResolvedManagedSignalTransport; account: string; accountBinding: "selected-account" | "owner-known-bound-account"; + abortSignal?: AbortSignal; }): Promise { - return await probeSignalTransport({ + params.abortSignal?.throwIfAborted(); + const result = await probeSignalTransport({ cfg: params.cfg, accountId: params.accountId, transport: { @@ -73,6 +76,8 @@ async function probeManagedBind(params: { nativeAccountBinding: params.accountBinding, timeoutMs: 1_000, }).catch((error: unknown) => ({ ok: false, error: String(error) })); + params.abortSignal?.throwIfAborted(); + return result; } async function probeSeparateConnectionUrl(params: { @@ -81,18 +86,28 @@ async function probeSeparateConnectionUrl(params: { transport: SignalManagedNativeTransport; resolved: ResolvedManagedSignalTransport; account: string; + abortSignal?: AbortSignal; }): Promise { - const bindUrl = buildSignalTransportHttpUrl(params.resolved.httpHost, params.resolved.httpPort); - if (params.resolved.baseUrl === bindUrl) { + params.abortSignal?.throwIfAborted(); + if ( + isSignalManagedNativeConnectionUrlForBind({ + ...params.transport, + url: params.resolved.baseUrl, + httpHost: params.resolved.httpHost, + httpPort: params.resolved.httpPort, + }) + ) { return { ok: true, status: 200, error: null }; } - return await probeSignalTransport({ + const result = await probeSignalTransport({ cfg: params.cfg, accountId: params.accountId, transport: params.transport, account: params.account, timeoutMs: 1_000, }).catch((error: unknown) => ({ ok: false, error: String(error) })); + params.abortSignal?.throwIfAborted(); + return result; } export async function probeManagedSignalSetup(params: { @@ -104,7 +119,9 @@ export async function probeManagedSignalSetup(params: { reusableConfiguredTransport?: string; runtime: RuntimeEnv; prompter: WizardPrompter; + abortSignal?: AbortSignal; }): Promise { + params.abortSignal?.throwIfAborted(); const resolved = resolveSignalTransport(params.transport); if (resolved.kind !== "managed-native") { throw new Error("Signal setup did not resolve a managed signal-cli transport."); @@ -157,6 +174,7 @@ export async function probeManagedSignalSetup(params: { httpHost: resolved.httpHost, httpPort: resolved.httpPort, }); + params.abortSignal?.throwIfAborted(); const spawnedDaemon = spawnSignalDaemon({ cliPath: resolved.cliPath, ...(resolved.configPath ? { configPath: resolved.configPath } : {}), @@ -176,6 +194,7 @@ export async function probeManagedSignalSetup(params: { logAfterMs: 10_000, logIntervalMs: 10_000, pollIntervalMs: 150, + ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), runtime: params.runtime, check: async () => { if (spawnedDaemon.isExited()) { @@ -189,18 +208,26 @@ export async function probeManagedSignalSetup(params: { return result; }, }); + params.abortSignal?.throwIfAborted(); if (result.ok) { result = await probeSeparateConnectionUrl({ ...params, resolved }); } return result; } catch (error) { + params.abortSignal?.throwIfAborted(); result = { ok: false, error: String(error) }; return result; } finally { try { await daemon?.stop(); } finally { - progress.stop(result.ok ? "Signal setup validated." : "Signal setup validation failed."); + progress.stop( + params.abortSignal?.aborted + ? "Signal setup validation cancelled." + : result.ok + ? "Signal setup validated." + : "Signal setup validation failed.", + ); } } } diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index b363c2162134..e9f136bb22fd 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -332,6 +332,7 @@ export const signalSetupWizard: ChannelSetupWizard = { params.credentialValues[signalSetupStateKeys.managedReuseTransport], runtime: params.runtime, prompter: params.prompter, + ...(params.options?.abortSignal ? { abortSignal: params.options.abortSignal } : {}), }); if (probe.ok) { if (params.credentialValues[SIGNAL_LINK_COMPLETED_INPUT_KEY] === "true") {