fix(signal): validate account replacement on new port

This commit is contained in:
jesse-merhi
2026-08-11 16:38:57 +10:00
parent 534a8713d2
commit 678e2b93e1
5 changed files with 63 additions and 13 deletions
@@ -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 () => {
@@ -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 () => {
+9 -2
View File
@@ -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,
@@ -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: {
+2 -1
View File
@@ -156,6 +156,7 @@ export function prepareSignalManagedNativeTransport(params: {
cfg: OpenClawConfig;
accountId: string;
overrides?: Omit<SignalManagedNativeTransport, "kind">;
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<number>();
for (const [accountId, accountPorts] of portsByAccountId) {
if (accountId === targetAccountId) {
if (accountId === targetAccountId && !params.reserveTargetAccountPorts) {
continue;
}
for (const httpPort of accountPorts) {