diff --git a/extensions/signal/src/setup-managed-validation-integration.test.ts b/extensions/signal/src/setup-managed-validation-integration.test.ts index 899ae4998c8f..3f0ddf838c18 100644 --- a/extensions/signal/src/setup-managed-validation-integration.test.ts +++ b/extensions/signal/src/setup-managed-validation-integration.test.ts @@ -110,7 +110,7 @@ describe("Signal managed setup validation wiring", () => { ); }); - it("keeps the original account identity when the generic input changes A to B", async () => { + it("validates an account replacement on a distinct persisted port", async () => { const originalCfg: OpenClawConfig = { channels: { signal: { @@ -129,7 +129,7 @@ describe("Signal managed setup validation wiring", () => { }, }; - await runSetupWizardFinalize({ + const finalized = await runSetupWizardFinalize({ finalize: signalSetupWizard.finalize, cfg: changedCfg, credentialValues, @@ -142,8 +142,12 @@ describe("Signal managed setup validation wiring", () => { account: "+15555550124", reusableConfiguredAccount: "+15555550123", reusableConfiguredTransport: expect.any(String), + transport: expect.objectContaining({ httpPort: 8081 }), }), ); + expect(finalized?.cfg?.channels?.signal?.transport).toEqual( + expect.objectContaining({ httpPort: 8081 }), + ); }); it("propagates setup cancellation without entering readiness recovery", async () => { diff --git a/extensions/signal/src/setup-managed-validation.test.ts b/extensions/signal/src/setup-managed-validation.test.ts index 43bac94e921a..32a8fffef871 100644 --- a/extensions/signal/src/setup-managed-validation.test.ts +++ b/extensions/signal/src/setup-managed-validation.test.ts @@ -208,26 +208,28 @@ describe("probeManagedSignalSetup", () => { expect(mocks.spawnDaemon).not.toHaveBeenCalled(); }); - it("never reuses the old daemon after the selected account changes", async () => { + it("validates a replacement account on its newly allocated port", async () => { const changedAccount = "+15555550124"; + const replacementTransport = { ...transport, httpPort: 8081 }; const cfg = { channels: { signal: { accounts: { work: { account: changedAccount, transport } } }, }, } as OpenClawConfig; - mocks.assertBindAvailable.mockRejectedValueOnce(new Error("address in use (EADDRINUSE)")); await expect( probeManagedSignalSetup({ - ...createParams(cfg, transport, account), + ...createParams(cfg, replacementTransport, account), account: changedAccount, }), - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining("EADDRINUSE"), + ).resolves.toMatchObject({ ok: true }); + expect(mocks.assertBindAvailable).toHaveBeenCalledWith({ + httpHost: "127.0.0.1", + httpPort: 8081, }); - expect(mocks.probeTransport).not.toHaveBeenCalled(); - expect(mocks.spawnDaemon).not.toHaveBeenCalled(); + expect(mocks.spawnDaemon).toHaveBeenCalledWith( + expect.objectContaining({ account: changedAccount, httpPort: 8081 }), + ); }); it("spawns the selected account on the final port and always stops it", async () => { diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index e9f136bb22fd..3d998c091340 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -312,9 +312,17 @@ export const signalSetupWizard: ChannelSetupWizard = { if (!account) { throw new Error("Signal managed setup requires an account number before validation."); } + const reusableConfiguredAccount = normalizeSignalAccountInput( + params.credentialValues[signalSetupStateKeys.managedReuseAccount], + ); const transport = prepareSignalManagedNativeTransport({ cfg: params.cfg, accountId: params.accountId, + // The old account keeps its daemon port until whole-channel reload drains it. Treat an + // identity replacement as a new bind so setup validates the exact transport it persists. + reserveTargetAccountPorts: Boolean( + reusableConfiguredAccount && reusableConfiguredAccount !== account, + ), overrides: typeof params.credentialValues.cliPath === "string" ? { cliPath: params.credentialValues.cliPath } @@ -326,8 +334,7 @@ export const signalSetupWizard: ChannelSetupWizard = { accountId: params.accountId, transport, account, - reusableConfiguredAccount: - params.credentialValues[signalSetupStateKeys.managedReuseAccount], + reusableConfiguredAccount, reusableConfiguredTransport: params.credentialValues[signalSetupStateKeys.managedReuseTransport], runtime: params.runtime, diff --git a/extensions/signal/src/setup-transport.test.ts b/extensions/signal/src/setup-transport.test.ts index ba5e2e249a1d..81d612aee366 100644 --- a/extensions/signal/src/setup-transport.test.ts +++ b/extensions/signal/src/setup-transport.test.ts @@ -267,6 +267,42 @@ describe("prepareSignalManagedNativeTransport", () => { }); }); + it("reserves an inherited target port while replacing its account identity", () => { + const cfg = { + channels: { + signal: { + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpPort: 8080, + }, + }, + personal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8081 }, + }, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "work", + reserveTargetAccountPorts: true, + }), + ).toEqual({ + kind: "managed-native", + url: "http://127.0.0.1:8082", + httpHost: "127.0.0.1", + httpPort: 8082, + }); + }); + it("keeps an aligned managed connection URL on the allocated bind port", () => { const cfg = { channels: { diff --git a/extensions/signal/src/setup-transport.ts b/extensions/signal/src/setup-transport.ts index 63c2735fb1f4..4f361519d9c3 100644 --- a/extensions/signal/src/setup-transport.ts +++ b/extensions/signal/src/setup-transport.ts @@ -156,6 +156,7 @@ export function prepareSignalManagedNativeTransport(params: { cfg: OpenClawConfig; accountId: string; overrides?: Omit; + reserveTargetAccountPorts?: boolean; }): SignalManagedNativeTransport { const existing = resolveConfiguredSignalTransport(params.cfg, params.accountId); const existingManaged = existing?.kind === "managed-native" ? existing : undefined; @@ -221,7 +222,7 @@ export function prepareSignalManagedNativeTransport(params: { const targetAccountId = normalizeAccountId(params.accountId); const reservedPorts = new Set(); for (const [accountId, accountPorts] of portsByAccountId) { - if (accountId === targetAccountId) { + if (accountId === targetAccountId && !params.reserveTargetAccountPorts) { continue; } for (const httpPort of accountPorts) {