diff --git a/CHANGELOG.md b/CHANGELOG.md index 0162138f63a..5b9a53cecfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 4b575eb87c7..d402de16662 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl Stored under `~/.openclaw/credentials/`: - Pending requests: `-pairing.json` -- Approved allowlist store: `-allowFrom.json` +- Approved allowlist store: + - Default account: `-allowFrom.json` + - Non-default account: `--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). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index a61a81eab1e..32a2a55329d 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//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/-allowFrom.json` (merged with config allowlists). + - When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`-allowFrom.json` for default account, `--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). diff --git a/docs/start/setup.md b/docs/start/setup.md index ee50e02afd4..7eef5bce714 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -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/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//agent/auth-profiles.json` - **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json` More detail: [Security](/gateway/security#credential-storage-map). diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 3d42546f6c1..130a8dc3807 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -35,6 +35,7 @@ async function withTempStateDir(fn: (stateDir: string) => Promise) { } 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([]); }); }); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 0f46d53b479..d6a8b9e6c8e 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -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 { - const { value } = await readJsonFile(filePath, { + return (await readAllowFromStateForPathWithExists(channel, filePath)).entries; +} + +async function readAllowFromStateForPathWithExists( + channel: PairingChannel, + filePath: string, +): Promise<{ entries: string[]; exists: boolean }> { + const { value, exists } = await readJsonFile(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 { + 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) =>