mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user