mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
pairing: enforce strict account-scoped state
This commit is contained in:
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
|
||||
- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
|
||||
- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl.
|
||||
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage.
|
||||
- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
|
||||
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
|
||||
- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.
|
||||
|
||||
@@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
|
||||
- Pending requests: `<channel>-pairing.json`
|
||||
- Approved allowlist store: `<channel>-allowFrom.json`
|
||||
- Approved allowlist store:
|
||||
- Default account: `<channel>-allowFrom.json`
|
||||
- Non-default account: `<channel>-<accountId>-allowFrom.json`
|
||||
|
||||
Account scoping behavior:
|
||||
|
||||
- Non-default accounts read/write only their scoped allowlist file.
|
||||
- Default account uses the channel-scoped unscoped allowlist file.
|
||||
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ Use this when auditing access or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
|
||||
@@ -488,7 +490,7 @@ If you run multiple accounts on the same channel, use `per-account-channel-peer`
|
||||
OpenClaw has two separate “who can trigger me?” layers:
|
||||
|
||||
- **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
|
||||
- When `dmPolicy="pairing"`, approvals are written to `~/.openclaw/credentials/<channel>-allowFrom.json` (merged with config allowlists).
|
||||
- When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`<channel>-allowFrom.json` for default account, `<channel>-<accountId>-allowFrom.json` for non-default accounts), merged with config allowlists.
|
||||
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
|
||||
- Common patterns:
|
||||
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
|
||||
|
||||
@@ -130,7 +130,9 @@ Use this when debugging auth or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
More detail: [Security](/gateway/security#credential-storage-map).
|
||||
|
||||
@@ -35,6 +35,7 @@ async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>) {
|
||||
}
|
||||
|
||||
async function writeJsonFixture(filePath: string, value: unknown) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
@@ -42,6 +43,11 @@ function resolvePairingFilePath(stateDir: string, channel: string) {
|
||||
return path.join(resolveOAuthDir(process.env, stateDir), `${channel}-pairing.json`);
|
||||
}
|
||||
|
||||
function resolveAllowFromFilePath(stateDir: string, channel: string, accountId?: string) {
|
||||
const suffix = accountId ? `-${accountId}` : "";
|
||||
return path.join(resolveOAuthDir(process.env, stateDir), `${channel}${suffix}-allowFrom.json`);
|
||||
}
|
||||
|
||||
async function writeAllowFromFixture(params: {
|
||||
stateDir: string;
|
||||
channel: string;
|
||||
@@ -273,8 +279,68 @@ describe("pairing store", () => {
|
||||
|
||||
const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
|
||||
const channelScoped = readChannelAllowFromStoreSync("telegram");
|
||||
expect(scoped).toEqual(["1002", "1001", "1002"]);
|
||||
expect(channelScoped).toEqual(["1001", "1001"]);
|
||||
expect(scoped).toEqual(["1002", "1001"]);
|
||||
expect(channelScoped).toEqual(["1001"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not read legacy channel-scoped allowFrom for non-default account ids", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await writeAllowFromFixture({
|
||||
stateDir,
|
||||
channel: "telegram",
|
||||
allowFrom: ["1001", "*", "1002", "1001"],
|
||||
});
|
||||
await writeAllowFromFixture({
|
||||
stateDir,
|
||||
channel: "telegram",
|
||||
accountId: "yy",
|
||||
allowFrom: ["1003"],
|
||||
});
|
||||
|
||||
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
|
||||
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
|
||||
expect(asyncScoped).toEqual(["1003"]);
|
||||
expect(syncScoped).toEqual(["1003"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to legacy allowFrom when scoped file exists but is empty", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await writeAllowFromFixture({
|
||||
stateDir,
|
||||
channel: "telegram",
|
||||
allowFrom: ["1001"],
|
||||
});
|
||||
await writeAllowFromFixture({
|
||||
stateDir,
|
||||
channel: "telegram",
|
||||
accountId: "yy",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
|
||||
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
|
||||
expect(asyncScoped).toEqual([]);
|
||||
expect(syncScoped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps async and sync reads aligned for malformed scoped allowFrom files", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
await writeAllowFromFixture({
|
||||
stateDir,
|
||||
channel: "telegram",
|
||||
allowFrom: ["1001"],
|
||||
});
|
||||
const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy");
|
||||
await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true });
|
||||
await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8");
|
||||
|
||||
const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy");
|
||||
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy");
|
||||
expect(asyncScoped).toEqual([]);
|
||||
expect(syncScoped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -243,7 +243,9 @@ function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
|
||||
|
||||
function normalizeAllowFromList(channel: PairingChannel, store: AllowFromStore): string[] {
|
||||
const list = Array.isArray(store.allowFrom) ? store.allowFrom : [];
|
||||
return list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean);
|
||||
return dedupePreserveOrder(
|
||||
list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAllowFromInput(channel: PairingChannel, entry: string | number): string {
|
||||
@@ -268,20 +270,46 @@ async function readAllowFromStateForPath(
|
||||
channel: PairingChannel,
|
||||
filePath: string,
|
||||
): Promise<string[]> {
|
||||
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||
return (await readAllowFromStateForPathWithExists(channel, filePath)).entries;
|
||||
}
|
||||
|
||||
async function readAllowFromStateForPathWithExists(
|
||||
channel: PairingChannel,
|
||||
filePath: string,
|
||||
): Promise<{ entries: string[]; exists: boolean }> {
|
||||
const { value, exists } = await readJsonFile<AllowFromStore>(filePath, {
|
||||
version: 1,
|
||||
allowFrom: [],
|
||||
});
|
||||
return normalizeAllowFromList(channel, value);
|
||||
const entries = normalizeAllowFromList(channel, value);
|
||||
return { entries, exists };
|
||||
}
|
||||
|
||||
function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] {
|
||||
return readAllowFromStateForPathSyncWithExists(channel, filePath).entries;
|
||||
}
|
||||
|
||||
function readAllowFromStateForPathSyncWithExists(
|
||||
channel: PairingChannel,
|
||||
filePath: string,
|
||||
): { entries: string[]; exists: boolean } {
|
||||
let raw = "";
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "ENOENT") {
|
||||
return { entries: [], exists: false };
|
||||
}
|
||||
return { entries: [], exists: false };
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as AllowFromStore;
|
||||
return normalizeAllowFromList(channel, parsed);
|
||||
const entries = normalizeAllowFromList(channel, parsed);
|
||||
return { entries, exists: true };
|
||||
} catch {
|
||||
return [];
|
||||
// Keep parity with async reads: malformed JSON still means the file exists.
|
||||
return { entries: [], exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +334,24 @@ async function writeAllowFromState(filePath: string, allowFrom: string[]): Promi
|
||||
} satisfies AllowFromStore);
|
||||
}
|
||||
|
||||
async function readNonDefaultAccountAllowFrom(params: {
|
||||
channel: PairingChannel;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
}): Promise<string[]> {
|
||||
const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId);
|
||||
return await readAllowFromStateForPath(params.channel, scopedPath);
|
||||
}
|
||||
|
||||
function readNonDefaultAccountAllowFromSync(params: {
|
||||
channel: PairingChannel;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
}): string[] {
|
||||
const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId);
|
||||
return readAllowFromStateForPathSync(params.channel, scopedPath);
|
||||
}
|
||||
|
||||
async function updateAllowFromStoreEntry(params: {
|
||||
channel: PairingChannel;
|
||||
entry: string | number;
|
||||
@@ -348,11 +394,15 @@ export async function readChannelAllowFromStore(
|
||||
return await readAllowFromStateForPath(channel, filePath);
|
||||
}
|
||||
|
||||
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
|
||||
return await readNonDefaultAccountAllowFrom({
|
||||
channel,
|
||||
env,
|
||||
accountId: normalizedAccountId,
|
||||
});
|
||||
}
|
||||
const scopedPath = resolveAllowFromPath(channel, env, accountId);
|
||||
const scopedEntries = await readAllowFromStateForPath(channel, scopedPath);
|
||||
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
|
||||
return scopedEntries;
|
||||
}
|
||||
// Backward compatibility: legacy channel-level allowFrom store was unscoped.
|
||||
// Keep honoring it for default account to prevent re-pair prompts after upgrades.
|
||||
const legacyPath = resolveAllowFromPath(channel, env);
|
||||
@@ -371,11 +421,15 @@ export function readChannelAllowFromStoreSync(
|
||||
return readAllowFromStateForPathSync(channel, filePath);
|
||||
}
|
||||
|
||||
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
|
||||
return readNonDefaultAccountAllowFromSync({
|
||||
channel,
|
||||
env,
|
||||
accountId: normalizedAccountId,
|
||||
});
|
||||
}
|
||||
const scopedPath = resolveAllowFromPath(channel, env, accountId);
|
||||
const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath);
|
||||
if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) {
|
||||
return scopedEntries;
|
||||
}
|
||||
const legacyPath = resolveAllowFromPath(channel, env);
|
||||
const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath);
|
||||
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
|
||||
@@ -515,11 +569,12 @@ export async function upsertChannelPairingRequest(params: {
|
||||
nowMs,
|
||||
);
|
||||
reqs = prunedExpired;
|
||||
const normalizedMatchingAccountId = normalizePairingAccountId(normalizedAccountId);
|
||||
const existingIdx = reqs.findIndex((r) => {
|
||||
if (r.id !== id) {
|
||||
return false;
|
||||
}
|
||||
return requestMatchesAccountId(r, normalizePairingAccountId(normalizedAccountId));
|
||||
return requestMatchesAccountId(r, normalizedMatchingAccountId);
|
||||
});
|
||||
const existingCodes = new Set(
|
||||
reqs.map((req) =>
|
||||
|
||||
Reference in New Issue
Block a user