fix(pairing): scope pending request caps per account (#58239)

* fix(pairing): scope pending pairing caps per account

* fix(pairing): count legacy default-account requests
This commit is contained in:
Vincent Koc
2026-03-31 19:45:45 +09:00
committed by GitHub
parent f45e5a6569
commit 9bc1f896c8
3 changed files with 121 additions and 12 deletions

View File

@@ -147,6 +147,7 @@ Docs: https://docs.openclaw.ai
- Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
- Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
- Pairing: enforce pending request limits per account instead of per shared channel queue, so one account's outstanding pairing challenges no longer block new pairing on other accounts. Thanks @smaeljaish771 and @vincentkoc.
- Exec approvals: unwrap `caffeinate` and `sandbox-exec` before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.
## 2026.3.28

View File

@@ -359,6 +359,51 @@ describe("pairing store", () => {
});
},
},
{
name: "counts legacy default-account pending requests before admitting a new one",
run: async () => {
await withTempStateDir(async (stateDir) => {
const createdAt = new Date().toISOString();
await writeJsonFixture(resolvePairingFilePath(stateDir, "demo-pairing-c"), {
version: 1,
requests: [
{
id: "+15550000001",
code: "AAAAAAAB",
createdAt,
lastSeenAt: createdAt,
},
{
id: "+15550000002",
code: "AAAAAAAC",
createdAt,
lastSeenAt: createdAt,
},
{
id: "+15550000003",
code: "AAAAAAAD",
createdAt,
lastSeenAt: createdAt,
},
],
});
const blocked = await upsertChannelPairingRequest({
channel: "demo-pairing-c",
id: "+15550000004",
accountId: DEFAULT_ACCOUNT_ID,
});
expect(blocked.created).toBe(false);
const list = await listChannelPairingRequests("demo-pairing-c");
expect(list.map((entry) => entry.id)).toEqual([
"+15550000001",
"+15550000002",
"+15550000003",
]);
});
},
},
] as const)("$name", async ({ run }) => {
await expectPairingRequestStateCase({ run });
});
@@ -573,6 +618,38 @@ describe("pairing store", () => {
});
},
},
{
name: "does not block a new account when other accounts already filled their own pending slots",
run: async () => {
await withTempStateDir(async () => {
for (const accountId of ["alpha", "beta", "gamma"]) {
const created = await upsertChannelPairingRequest({
channel: "telegram",
accountId,
id: `pending-${accountId}`,
});
expect(created.created).toBe(true);
}
const delta = await upsertChannelPairingRequest({
channel: "telegram",
accountId: "delta",
id: "pending-delta",
});
expect(delta.created).toBe(true);
const deltaList = await listChannelPairingRequests("telegram", process.env, "delta");
const allPending = await listChannelPairingRequests("telegram");
expect(deltaList.map((entry) => entry.id)).toEqual(["pending-delta"]);
expect(allPending.map((entry) => entry.id)).toEqual([
"pending-alpha",
"pending-beta",
"pending-gamma",
"pending-delta",
]);
});
},
},
] as const)("$name", async ({ run }) => {
await expectPairingRequestStateCase({ run });
});

View File

@@ -193,12 +193,44 @@ function resolveLastSeenAt(entry: PairingRequest): number {
return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0;
}
function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
function resolvePairingRequestAccountId(entry: PairingRequest): string {
return normalizePairingAccountId(String(entry.meta?.accountId ?? "")) || DEFAULT_ACCOUNT_ID;
}
function pruneExcessRequestsByAccount(reqs: PairingRequest[], maxPending: number) {
if (maxPending <= 0 || reqs.length <= maxPending) {
return { requests: reqs, removed: false };
}
const sorted = reqs.slice().toSorted((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
return { requests: sorted.slice(-maxPending), removed: true };
const grouped = new Map<string, number[]>();
for (const [index, entry] of reqs.entries()) {
const accountId = resolvePairingRequestAccountId(entry);
const current = grouped.get(accountId);
if (current) {
current.push(index);
continue;
}
grouped.set(accountId, [index]);
}
const droppedIndexes = new Set<number>();
for (const indexes of grouped.values()) {
if (indexes.length <= maxPending) {
continue;
}
const sortedIndexes = indexes
.slice()
.toSorted((left, right) => resolveLastSeenAt(reqs[left]) - resolveLastSeenAt(reqs[right]));
for (const index of sortedIndexes.slice(0, sortedIndexes.length - maxPending)) {
droppedIndexes.add(index);
}
}
if (droppedIndexes.size === 0) {
return { requests: reqs, removed: false };
}
return {
requests: reqs.filter((_, index) => !droppedIndexes.has(index)),
removed: true,
};
}
function randomCode(): string {
@@ -229,11 +261,7 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str
if (!normalizedAccountId) {
return true;
}
return (
String(entry.meta?.accountId ?? "")
.trim()
.toLowerCase() === normalizedAccountId
);
return resolvePairingRequestAccountId(entry) === normalizedAccountId;
}
function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean {
@@ -666,7 +694,7 @@ export async function listChannelPairingRequests(
async () => {
const { requests: prunedExpired, removed: expiredRemoved } =
await readPrunedPairingRequests(filePath);
const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(
const { requests: pruned, removed: cappedRemoved } = pruneExcessRequestsByAccount(
prunedExpired,
PAIRING_PENDING_MAX,
);
@@ -757,7 +785,7 @@ export async function upsertChannelPairingRequest(params: {
meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
const { requests: capped } = pruneExcessRequestsByAccount(reqs, PAIRING_PENDING_MAX);
await writeJsonFile(filePath, {
version: 1,
requests: capped,
@@ -765,12 +793,15 @@ export async function upsertChannelPairingRequest(params: {
return { code, created: false };
}
const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(
const { requests: capped, removed: cappedRemoved } = pruneExcessRequestsByAccount(
reqs,
PAIRING_PENDING_MAX,
);
reqs = capped;
if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
const accountRequestCount = reqs.filter((r) =>
requestMatchesAccountId(r, normalizedMatchingAccountId),
).length;
if (PAIRING_PENDING_MAX > 0 && accountRequestCount >= PAIRING_PENDING_MAX) {
if (expiredRemoved || cappedRemoved) {
await writeJsonFile(filePath, {
version: 1,