fix(remote): make permission approval work from Slack - #332
Conversation
Bash tool permission requests were effectively un-approvable from Slack:
- English replies ("allow", "approve", "ok") were parsed as deny — only a
few Chinese tokens and "y"/"yes" were accepted.
- The permission.request event was consumed unconditionally for remote
sessions, so when remote delivery failed the desktop dialog never
appeared and the request silently auto-denied after 60s.
- The session-level 60s auto-deny timer beat the remote flow's 5-minute
window, leaving users almost no time to reply.
- Slack reports threaded channels as "C123:threadTs", so replies posted
in the main channel never matched the prompt posted in the thread.
Changes:
- src/main/remote/interaction-utils.ts (new): parsePermissionReply
accepts y/yes/allow/approve/ok/1 (allow), always (allow+remember);
unrecognized input denies. normalizeChannelId strips Slack thread
suffixes for matching.
- session-manager: permission timers are now deferrable
(deferPermissionTimeout); the remote flow re-arms them to 5 minutes.
- remote-manager: English Slack prompt (Feishu keeps Chinese), expiry
notice on timeout, two-pass channel matching (exact then normalized),
pending permissions settled as deny when a session ends.
- index.ts: falls back to the desktop permission dialog when remote
handling returns null or throws.
Tests: 44 new cases in tests/interaction-utils.test.ts,
tests/remote-manager-permission-request.test.ts, and
tests/session-manager-permission-timeout.test.ts.
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Findings
-
[Major] Normalized channel matching can resolve the wrong pending interaction when multiple permission prompts are pending in different threads of the same Slack channel.
The new pass 2 inhandlePotentialInteractionResponsestrips thread suffixes and matches any pending interaction whose channel type + normalized channel id are equal. A thread reply (C123:999.999) can therefore resolve the first pending interaction for a different thread (C123:111.111) or for the main channel; with multiple pending prompts in the same base channel, the first inserted interaction wins even though the reply was not directed at that prompt. The new testmatches a reply with a different thread ts via normalized channel iddocuments this over-broad behavior. This can lead the owner to unintentionally approve a different tool call than the one they are responding to.
Evidence:src/main/remote/remote-manager.ts:855-864
Suggested fix: only normalize when at least one side is non-threaded (main-channel reply), since exact matching already handles same-thread replies:const isThreaded = (channelId: string) => channelId.includes(':'); // ... if ( channelInfo.channelType === channelType && normalizeChannelId(channelInfo.channelId) === normalizeChannelId(channelId) && (!isThreaded(channelInfo.channelId) || !isThreaded(channelId)) ) { if (consume(interaction, id)) return true; }
-
[Minor] The remote prompt advertises a 5-minute session timeout even when the configured agent executor does not implement
deferPermissionTimeout.
IfagentExecutoris set elsewhere without that method,handlePermissionRequestproceeds with the session-manager default 60s timer while the Slack notice says 5 minutes, so the owner has far less time than advertised and may get a false deny.
Evidence:src/main/remote/remote-manager.ts:736-746
Suggested fix: makedeferPermissionTimeouta required method onAgentExecutor, or fall back to usingPERMISSION_TIMEOUT_MSin the timeout notice when the defer method is unavailable.
Testing
- The added unit tests cover the parsing and interaction-matching branches, but
sendToRendererfallback behavior insrc/main/index.ts(whenhandlePermissionRequestreturnsnullor throws) is not covered by automated tests. A small test would help protect the fallback-to-desktop path. - Not run (automation)
Summary
Review mode: initial
Review policy: advisory — the check reflects automation health/completion only; it does not approve the PR or resolve findings.
Open Cowork Bot
- Cross-thread replies could resolve the wrong pending permission when multiple prompts were pending in different threads of the same channel. Normalized channel matching now only applies when at least one side is non-threaded; exact matching still handles same-thread replies. - The permission prompt advertises a 5-minute reply window, so deferPermissionTimeout is now required on AgentExecutor (the stdio executor gets a no-op stub) and an expiry notice is posted when the session timer has already fired. - Permission routing from index.ts moved to src/main/remote/permission-route.ts so the fallback-to-desktop-dialog paths (null result, thrown error, missing handler) are unit-tested. Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed the review findings in 5f10719: [Major] Cross-thread matching — normalized channel matching now only applies when at least one side is non-threaded ( [Minor] 5-minute notice — Testing — the permission routing previously inlined in index.ts (with its null/throw fallback to the desktop dialog) is extracted into Verification: 54 permission-related unit tests pass; |
There was a problem hiding this comment.
Review mode: follow-up after new commits
Findings
-
[Major] Main-channel replies can still resolve the wrong pending permission when multiple threads of the same channel have outstanding prompts. The new normalized second pass in
handlePotentialInteractionResponse(src/main/remote/remote-manager.ts, around the!isThreadedChannelId(...)condition) accepts a reply posted in the base channel as a match for any threaded prompt in that base channel. If more than one threaded prompt is pending, the first Map entry is consumed, which can approve the wrong tool call. Suggested fix: when a non-threaded reply would match multiple pending threaded interactions for the same base channel, do not consume it; either require the reply to be in the specific thread or ask for clarification.// in the normalized pass if (!isThreadedChannelId(channelId)) { const matches = [...this.pendingInteractions.values()].filter( (i) => i.channelInfo.channelType === channelType && normalizeChannelId(i.channelInfo.channelId) === normalizeChannelId(channelId) ); if (matches.length > 1) continue; // ambiguous; do not auto-consume }
-
[Minor]
clearSessionBufferresolves pendingquestioninteractions with the raw string'{}'instead of the parsed empty answer. Other question paths callparseQuestionResponse(...)before invoking the resolver, so the agent can receive a literal{}string instead of an empty object when a question is cleared. Suggested fix:resolver( interaction.type === 'permission' ? 'no' : this.parseQuestionResponse('{}', interaction.questions || []) );
-
[Minor]
this.agentExecutor?.deferPermissionTimeout(...)remains optional-chained even thoughAgentExecutor.deferPermissionTimeoutis now required. IfagentExecutorisundefined,deferredisundefinedand the remote flow proceeds without deferring the session-level timer; a session timeout can then fire while the remote prompt is still waiting. Consider failing closed:const deferred = this.agentExecutor ? this.agentExecutor.deferPermissionTimeout(toolUseId, PERMISSION_TIMEOUT_MS) : false; if (deferred === false) { /* deny + notify */ }
Questions
- Is
RemoteManagerever instantiated withstdioAgentExecutorfor real permission prompts? If so,deferPermissionTimeout: () => falseinsrc/main/index.tswould cause every remote permission request to be denied immediately because the code treatsfalseas “session timer already fired.”
Summary
- Review mode: follow-up after new commits
- Review policy: advisory — the check reflects automation health/completion only; it does not approve the PR or resolve findings.
- The previously reported issues (normalized thread matching and the optional
deferPermissionTimeoutfallback) are materially addressed in this revision. The remaining concerns above are edge cases introduced or left open by the new matching/teardown logic.
Testing
- Suggested: add a unit test with two pending threaded interactions in the same base channel and a base-channel reply; assert no interaction is consumed (or a clarification is requested).
- Suggested: add a unit test for
clearSessionBufferwith a pendingquestionso the resolved value is asserted to equalparseQuestionResponse('{}', questions), not the raw'{}'string. - Existing coverage for permission parsing and routing looks reasonable.
Open Cowork Bot
Problem
Bash tool permission requests were effectively un-approvable from Slack. Four issues combined to make the flow fail:
允许/y/yes/是(and始终允许/always), so "allow", "approve", and "ok" all denied the request.permission.requestevents for remote sessions were consumed unconditionally; when remote handling returnednull(missing session mapping, gateway down, send failure) the desktop dialog never appeared and the request silently auto-denied after 60s.C123:threadTs, so replies posted in the main channel never matched a prompt posted inside a thread (strict channel-id equality).Changes
src/main/remote/interaction-utils.ts(new, dependency-free):parsePermissionReplyacceptsy/yes/allow/approve/ok/1(allow once),always(allow + remember), with Chinese tokens (是/允许/始终允许) kept for parity; anything unrecognized denies (fail-closed).normalizeChannelIdstrips Slack thread suffixes for matching.session-manager: permission timers are now deferrable — newdeferPermissionTimeoutclears and re-arms the auto-deny timer, andhandlePermissionResponse/requestPermissionsettle through one idempotent path.remote-manager:handlePotentialInteractionResponsematches in two passes: exact channel id first, then normalized (thread-suffix-stripped) comparison; owner check and consume-once semantics preserved.clearSessionBuffersettles any pending permission as deny when a session ends, so its promise doesn't hang until the 5-minute timeout.index.ts: falls back to the desktop permission dialog when remote handling returnsnullor throws.Tests
44 new cases across three files:
tests/interaction-utils.test.ts— reply parsing (all tokens, case/whitespace/punctuation variants, fail-closed) and channel-id normalization.tests/remote-manager-permission-request.test.ts— end-to-end permission flow: allow/always/deny replies, non-owner replies ignored, main-channel replies matching threaded prompts, send-failure cleanup, timer deferral, safe-tool auto-approve, session-end settlement.tests/session-manager-permission-timeout.test.ts— 60s default auto-deny, deferral to 5 minutes, reply-after-defer leaves no timer,deferPermissionTimeoutreturnsfalsefor settled/unknown ids.Verified with
npx vitest runon the three new files (44 passed),npm run typecheck, andnpm run lint(0 errors).🤖 Generated with Claude Code