fix(channels): allow request owners to answer one-time tool permissions (#16087)

* fix(channels): allow request owners to answer one-time tool permissions

* docs(channels): record permission-owner verification
This commit is contained in:
BaboBen authored and GitHub committed 2026-11-02 19:41:43 +08:00
1 parent 5df87f7080
commit 84c4c70789
8 files changed
+568 -6

No files matched your search

@@ -0,0 +1,59 @@
# Channel Request Owner Approval
[English](channel-request-owner-approval.md) | [简体中文](channel-request-owner-approval.zh-CN.md)
## Problem
Text permission commands currently require shared-session operator privileges
before locating the request. An admitted user can therefore start a turn and
receive a tool confirmation they cannot answer, including in a direct message
using `sessionScope: "single"`. Group request owners encounter the same failure.
## Decision
Allow an admitted sender to use `/approve` (allow once) and `/deny` for their
own pending request in their current attended turn. This applies to direct and
group conversations, independently of the `operators` list, including an empty
list. Ownership of one request does not grant shared-session management rights.
Retain the existing operator requirements for `/approve-always`, approvals of
another sender's text request, `/cancel`, `/clear`, `/who`, `/status`, `/loop`,
`/btw`, loop tools, and steering. Existing native-card owner checks remain in
place. This change does not broaden native-card delegation or persistent grants.
## Implementation and Boundaries
Keep session routing and the shared-session operator predicate unchanged. Record
the active run ID with a pending permission. For a caller without shared-session
operator rights, filter lookup candidates to requests whose sender, current
attended run, chat, and thread match the caller. A missing, cancelled,
unattended, or superseded run cannot supply owner authority. Apply this filter
before producing ambiguity lists or permission titles, including explicit IDs.
Operators retain the existing lookup rules and normal conversation admission.
Named-task selection, native-card ownership, permission options, and settlement
continue to use the existing paths. Do not infer operators from pairing or
automatically rewrite configuration.
## Validation and Acceptance
- In `single`, `chat_thread`, and group `thread` sessions, the current request
owner can approve once or deny without operator membership.
- A real privately paired sender can answer their own direct-message request.
- Another ordinary member cannot answer or list another sender's requests; the
denied attempt leaves the request available for its owner.
- Cross-chat and cross-thread explicit IDs, requests without an attended owner,
cancelled turns, and requests belonging to an earlier run are rejected.
- A non-operator owner cannot gain persistent permission with `/approve-always`
in a shared session or clear the shared conversation.
- Existing operator delegation and native-card request settlement keep working.
- Run focused and full ChannelBase tests, build, typecheck, and bundle; distinguish
local harness checks from live IM acceptance, which requires a deployed build.
## Risks
This intentionally separates request authority from session management: an empty
`operators` list continues to disable shared-session management and delegation,
but no longer prevents an attended request owner from approving once or denying.
The request must use the active run's sender snapshot, rather than the initial
owner of a `single` session shared across senders.
@@ -0,0 +1,48 @@
# Channel 请求发起者审批
[English](channel-request-owner-approval.md) | [简体中文](channel-request-owner-approval.zh-CN.md)
## 问题
文字权限命令目前在查找请求前就要求共享会话操作员权限。因此,通过准入的用户可以
发起轮次并收到工具确认,却无法回答,使用 `sessionScope: "single"` 的私聊也会
遇到此问题。群聊中的请求发起者同样受影响。
## 决策
允许通过准入的发送者对自己当前交互轮次的待审批请求使用 `/approve`(仅允许
本次)和 `/deny`。该规则覆盖私聊和群聊,不依赖 `operators` 名单,空名单也适用。
拥有某个请求的审批权,不代表获得共享会话管理权。
`/approve-always`、代答其他发送者的文字请求、`/cancel`、`/clear`、`/who`、
`/status`、`/loop`、`/btw`、loop 工具及 steer 保留现有操作员权限要求。
现有原生卡片的发起者校验保持不变,不扩大卡片代审或持久授权权限。
## 实现及边界
保持会话路由和共享会话操作员判定不变。在待审批请求中记录当前 run ID。
对于没有共享会话操作员权限的调用者,查找时仅保留发送者、当前交互轮次、聊天及
线程均匹配的请求。缺失、已取消、无人值守或已被替换的轮次不能提供发起者权限。
在生成歧义列表或显示请求标题之前应用筛选,显式请求 ID 也遵循此规则。
操作员保留现有查找规则,并仍需通过正常会话准入。命名任务选择、原生卡片归属、
权限选项和请求结算复用已有路径。不从 pairing 推导操作员,也不自动改写配置。
## 验证与验收
- 在 `single`、`chat_thread` 和群聊 `thread` 会话中,当前请求的发起者无需
操作员身份即可仅批准本次或拒绝。
- 真实通过私聊配对的发送者能够回答自己在私聊中触发的请求。
- 其他普通成员不能回答或列出别人的请求;拒绝代答后,请求仍可由发起者回答。
- 拒绝跨聊天或线程的显式 ID、没有交互发起者的请求、已取消轮次和旧轮次请求。
- 共享会话中的非操作员发起者不能通过 `/approve-always` 获取持久权限,也不能
清空共享对话。
- 现有操作员代审和原生卡片请求结算继续正常工作。
- 运行定向及完整 ChannelBase 测试、构建、类型检查和打包;区分本地测试与需要
部署新构建后才能进行的真实 IM 验收。
## 风险
本次有意区分请求审批权和会话管理权:空 `operators` 继续禁用共享会话管理和
代审,但不再阻止当前交互请求的发起者仅批准本次或拒绝。请求必须使用当前轮次的
发送者快照,不能使用跨发送者共享的 `single` 会话最初记录的归属。
@@ -0,0 +1,75 @@
# Channel Request Owner Approval Implementation Plan
**Goal:** Allow an admitted request owner to approve once or deny in direct and
group shared sessions while retaining operator-only management and delegation.
**Architecture:** Keep shared-session routing and operator predicates unchanged.
Record each permission's active run and filter non-operator lookup results by
that run's attended owner before exposing any request details.
**Tech stack:** TypeScript, existing ChannelBase relay, Vitest.
**Spec:** [Request owner approval](../design/channel-request-owner-approval.md).
## Constraints and Review Focus
- No new public configuration, dependency, route, or automatic migration.
- Explicit operators, including `[]`, still control shared-session management.
- Cross-sender, cross-chat, cross-thread, unattended and stale-run IDs must not
become owner approvals or leak request titles through ambiguity reporting.
- `/approve-always` does not gain the shared-session owner exception.
- Preserve named selection, operator delegation and native-card settlement.
## Task: Request-Scoped Owner Authority
Files: `packages/channels/base/src/ChannelBase.ts`, its collocated tests,
channel overview and synchronized English/Chinese design documents.
- [x] Add regressions exercising attended owners and denied delegation; run
`cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t 'request owner approval'`
and observe owner approvals fail against the existing operator-only gate.
- [x] Add the private run snapshot and owner filtering; keep all other shared
commands on the existing operator predicate.
- [x] Run the regression subset, then the complete ChannelBase test file.
- [x] Update the channel overview's request-vs-session permission contract.
- [x] Run changed-file formatting and lint checks, package compilation, and
independent compiled-code reproduction verification.
- [x] Complete whole-repository build, typecheck, and bundle after synchronizing
the installed dependencies and sibling build outputs with this checkout.
- [x] Read the full diff in two clean self-audit passes, then obtain a fresh
code review and fix correctness findings before reporting completion.
The user subsequently requested full verification, an issue following the
community template, and a linked PR before finishing the full checks. Track the
reported failure in [#16086](https://github.com/QwenLM/qwen-code/issues/16086).
Submission does not authorize a release, merge, or deployment.
## Verification Evidence
- The first 28 owner regressions failed against the original operator gate,
then passed after the fix. The complete updated file passed 783 tests.
- `npm run build` from `packages/channels/base` passed; changed-file ESLint,
Prettier and whitespace checks passed.
- Independent compiled ChannelBase inbound/event verification passed 33
scenarios, including a real isolated PairingStore. This is not live IM E2E.
- After refreshing pinned dependencies and synchronizing main, root
`npm run build`, `npm run bundle`, and
`NODE_OPTIONS=--max-old-space-size=6144 npm run typecheck` passed. The refreshed
ChannelBase test file passed all 783 tests again.
- Independent concurrent-session verification passed 13 scenarios, including
ordinary-member isolation and the existing operator delegation behavior.
- A fresh bundled CLI, isolated extension transport, real ACP child, local
OpenAI-compatible fixture, and real edit tool passed five end-to-end cases:
direct approval, group approval after another member's rejection, group
denial, persistent/management rejection followed by one-time approval, and
privately paired approval. Approved edits changed files; denial preserved
the file and completed the attended lifecycle without a second request.
- CI static checks, TUI snapshots, and no-AK integration passed on the tested
production head. Full Linux unit and Web Shell smoke jobs were still running
when this evidence was recorded. No live IM or all-platform acceptance is
claimed.
- Independent review found no actionable correctness or security defects. Its
suggested original-owner-versus-current-sender regression was added and
included in the final 783-test run. An active unattended loop/webhook case
remains outside the independent integration coverage; the owner filter and
existing automation authorization were inspected without broadening them.
+4 -2
View File
@@ -123,11 +123,13 @@ New managed channels default to `pairing`. The deprecated `senderPolicy` and `dm
### Shared-Session Operators
In a shared session (`sessionScope: "chat_thread"` or `"single"`, and group chats under `"thread"`), some commands affect everyone in the conversation: `/approve`, `/deny`, `/cancel`, `/clear`, `/who`, `/status`, `/loop`, `/btw`, the loop tool, and steering an in-flight turn. Only the session's operators may use them; other members' messages queue instead of steering. Sessions that are not shared belong to their own sender, who may always use them.
In a shared session (`sessionScope: "chat_thread"` or `"single"`, and group chats under `"thread"`), session management requires operator privileges: `/approve-always`, `/cancel`, `/clear`, `/who`, `/status`, `/loop`, `/btw`, the loop tool, and steering an in-flight turn. Other members' messages queue instead of steering. Sessions that are not shared belong to their own sender, who may always use them.
An admitted sender may use `/approve` (allow once) or `/deny` for their own pending tool request in their current attended turn, in direct messages and groups. This request-specific authority does not depend on `operators`, even when the list is empty. Another ordinary member cannot answer it, and an attempt from another chat or thread is rejected. Operators may still answer another sender's text permission request in the same chat and thread; native interactive cards retain their existing owner checks.
Set `operators` to the user IDs allowed to manage shared sessions. Omitted or empty `operators` grants nobody this role. Private allowlists, user pairing, group pairing, and group member lists never imply operator permissions. Operators must still pass normal conversation admission.
For example, `groups: { "*": { "senders": "open" } }` with `operators: ["alice"]` lets every admitted group member start a turn, but only Alice can manage its shared session.
For example, `groups: { "*": { "senders": "open" } }` with `operators: ["alice"]` lets every admitted group member start a turn and approve once or deny their own current request. Only Alice can manage its shared session or answer another sender's text permission request.
A saved loop is checked against the same rule when it fires, using its creator as the sender, and is disabled if the creator is no longer an operator.
+4 -1
View File
@@ -294,11 +294,14 @@ sender check an adapter makes itself must go through
group's setting is silently ignored on that lane. A group gate never carries
`pairing`: an approval there would also unlock direct messages.
**Operators:** commands that act on a shared session (`/approve`, `/cancel`,
**Operators:** commands that act on a shared session (`/approve-always`, `/cancel`,
`/clear`, `/loop`, the loop tool, steering, ...) check
`isSharedSessionOperator`, not the sender gates. Only explicit `config.operators`
grants shared-session operator permissions; omitted or empty means nobody.
Private policy, user pairing, and group member admission never imply this role.
An admitted sender may still `/approve` once or `/deny` their own pending request
in the current attended run, in the same chat and thread. This does not grant
session-management permissions or permission to answer another member's request.
`resolvePrivatePolicy(config)` resolves the four-value private policy. Explicit
`privatePolicy` takes precedence over deprecated `dmPolicy` and `senderPolicy`;
@@ -3569,6 +3569,345 @@ describe('ChannelBase', () => {
});
});
describe('request owner approval', () => {
const conversations = [
{ sessionScope: 'single', isGroup: false },
{ sessionScope: 'single', isGroup: true },
{ sessionScope: 'chat_thread', isGroup: true },
{ sessionScope: 'thread', isGroup: true },
] as const;
const cases = conversations.flatMap((conversation) =>
[undefined, [], ['operator']].flatMap((operators) =>
(['approve', 'deny'] as const).map((decision) => ({
...conversation,
operators,
decision,
})),
),
);
it.each(cases)(
'lets the current owner $decision with $sessionScope, group=$isGroup, operators=$operators',
async ({ sessionScope, isGroup, operators, decision }) => {
const ch = createChannel({
sessionScope,
operators,
groupPolicy: 'open',
});
const inbound = { senderId: 'alice', isGroup, isMentioned: isGroup };
const active = await startActiveSession(ch, inbound);
try {
emitPermission(active.sessionId, 'req-owner');
await ch.handleInbound(
envelope({ ...inbound, text: `/${decision}` }),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-owner',
{
outcome: {
outcome: 'selected',
optionId: decision === 'approve' ? 'proceed_once' : 'cancel',
},
},
);
} finally {
await active.finish();
}
},
);
it('rejects other members without consuming or listing the owner requests', async () => {
const ch = createChannel({
sessionScope: 'chat_thread',
groupPolicy: 'open',
});
const group = { isGroup: true, isMentioned: true, threadId: 'topic' };
const active = await startActiveSession(ch, {
...group,
senderId: 'alice',
});
try {
emitPermission(active.sessionId, 'req-alice-1');
emitPermission(active.sessionId, 'req-alice-2');
await vi.waitFor(() => expect(ch.sent).toHaveLength(2));
ch.sent.length = 0;
for (const text of [
'/approve',
'/approve req-alice-1',
'/deny req-alice-1',
]) {
await ch.handleInbound(
envelope({ ...group, senderId: 'bob', text }),
);
}
expect(respondToPermissionMock()).not.toHaveBeenCalled();
expect(ch.sent).toHaveLength(3);
for (const message of ch.sent) {
expect(message.text).not.toContain('Run req-alice');
expect(message.text).not.toContain('Multiple permission');
}
await ch.handleInbound(
envelope({
...group,
senderId: 'alice',
text: '/approve req-alice-1',
}),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-alice-1',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
it('rejects the owner in another chat or thread before accepting the correct target', async () => {
const ch = createChannel({
sessionScope: 'single',
groupPolicy: 'open',
});
const owner = {
senderId: 'alice',
isGroup: true,
isMentioned: true,
chatId: 'group1',
threadId: 'topic1',
};
const active = await startActiveSession(ch, owner);
try {
emitPermission(active.sessionId, 'req-target');
for (const target of [{ chatId: 'group2' }, { threadId: 'topic2' }]) {
await ch.handleInbound(
envelope({ ...owner, ...target, text: '/approve req-target' }),
);
}
expect(respondToPermissionMock()).not.toHaveBeenCalled();
await ch.handleInbound(
envelope({ ...owner, text: '/approve req-target' }),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-target',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
it('does not grant shared management or persistent approval to the owner', async () => {
const ch = createChannel({ sessionScope: 'single', operators: [] });
const active = await startActiveSession(ch, { senderId: 'alice' });
try {
emitPermission(active.sessionId, 'req-once-only');
await ch.handleInbound(
envelope({
senderId: 'alice',
text: '/approve-always req-once-only',
}),
);
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/clear confirm' }),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
expect(bridge.discardSession).not.toHaveBeenCalled();
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/approve req-once-only' }),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-once-only',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
it('does not reuse an earlier run owner when the same sender starts another turn', async () => {
const ch = createChannel({ sessionScope: 'single' });
const first = await startActiveSession(ch, { senderId: 'alice' });
emitPermission(first.sessionId, 'req-old-run');
await first.finish();
(bridge.prompt as ReturnType<typeof vi.fn>).mockClear();
const current = await startActiveSession(ch, { senderId: 'alice' });
try {
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/approve req-old-run' }),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
emitPermission(current.sessionId, 'req-current-run');
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/approve req-current-run' }),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-current-run',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await current.finish();
}
});
it('allows a privately paired sender to answer their own single-session request', async () => {
const previousHome = process.env['QWEN_HOME'];
const home = mkdtempSync(join(tmpdir(), 'qwen-owner-pairing-'));
process.env['QWEN_HOME'] = home;
try {
const store = new PairingStore('test-chan', '/tmp');
store.approve(pairingCodeOf(store.createRequest('alice', 'Alice')));
const ch = createChannel({
senderPolicy: 'pairing',
sessionScope: 'single',
});
const active = await startActiveSession(ch, { senderId: 'alice' });
try {
emitPermission(active.sessionId, 'req-paired-owner');
await ch.handleInbound(
envelope({
senderId: 'stranger',
text: '/approve req-paired-owner',
}),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
await ch.handleInbound(
envelope({
senderId: 'alice',
text: '/approve req-paired-owner',
}),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-paired-owner',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
} finally {
if (previousHome === undefined) delete process.env['QWEN_HOME'];
else process.env['QWEN_HOME'] = previousHome;
rmSync(home, { recursive: true, force: true });
}
});
it('retains operator delegation for another sender attended text request', async () => {
const ch = createChannel({
sessionScope: 'chat_thread',
groupPolicy: 'open',
operators: ['admin'],
});
const group = { isGroup: true, isMentioned: true, threadId: 'topic' };
const active = await startActiveSession(ch, {
...group,
senderId: 'alice',
});
try {
emitPermission(active.sessionId, 'req-delegated');
await ch.handleInbound(
envelope({
...group,
senderId: 'admin',
text: '/approve req-delegated',
}),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-delegated',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
it('does not infer request ownership without an attended turn', async () => {
const ch = createChannel({ sessionScope: 'single' });
const sessionId = await startSession(ch, { senderId: 'alice' });
emitPermission(sessionId, 'req-no-active-owner');
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/approve req-no-active-owner' }),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
});
it('uses the current sender rather than the original single-session owner', async () => {
const ch = createChannel({ sessionScope: 'single' });
const sessionId = await startSession(ch, { senderId: 'alice' });
(bridge.prompt as ReturnType<typeof vi.fn>).mockClear();
const active = await startActiveSession(ch, { senderId: 'bob' });
try {
expect(active.sessionId).toBe(sessionId);
emitPermission(active.sessionId, 'req-current-sender');
await ch.handleInbound(
envelope({
senderId: 'alice',
text: '/approve req-current-sender',
}),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
await ch.handleInbound(
envelope({ senderId: 'bob', text: '/approve req-current-sender' }),
);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-current-sender',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
it('does not let the owner approve after an operator cancels the turn', async () => {
const ch = createChannel({
sessionScope: 'single',
operators: ['admin'],
});
ch.enableCancelCommand();
const active = await startActiveSession(ch, { senderId: 'alice' });
try {
emitPermission(active.sessionId, 'req-cancelled-owner');
await ch.handleInbound(
envelope({ senderId: 'admin', text: '/cancel' }),
);
emitPermission(active.sessionId, 'req-late-after-cancel');
await ch.handleInbound(
envelope({
senderId: 'alice',
text: '/approve req-late-after-cancel',
}),
);
expect(respondToPermissionMock()).not.toHaveBeenCalled();
} finally {
await active.finish();
}
});
it('settles an owner text response to a native card exactly once', async () => {
const ch = createChannel({ sessionScope: 'single' });
ch.permissionPresentationResult = { kind: 'presented' };
const active = await startActiveSession(ch, { senderId: 'alice' });
try {
emitPermission(active.sessionId, 'req-owner-card');
await vi.waitFor(() =>
expect(ch.permissionPresentations).toHaveLength(1),
);
const presentation = ch.permissionPresentations[0]!;
await ch.handleInbound(
envelope({ senderId: 'alice', text: '/approve req-owner-card' }),
);
await expect(presentation.respond('deny')).resolves.toBe(false);
expect(respondToPermissionMock()).toHaveBeenCalledExactlyOnceWith(
'req-owner-card',
{ outcome: { outcome: 'selected', optionId: 'proceed_once' } },
);
} finally {
await active.finish();
}
});
});
it('gates shared-session permission responses to authorized senders', async () => {
const ch = createChannel({
operators: ['boss'],
+38 -3
View File
@@ -338,6 +338,7 @@ type CommandHandler = (envelope: Envelope, args: string) => Promise<boolean>;
type PendingPermission = {
requestId: string;
sessionId: string;
runId?: string;
target: SessionTarget;
request: PermissionRequestEvent['request'];
sourceLabel?: string;
@@ -959,6 +960,7 @@ export abstract class ChannelBase {
const pending: PendingPermission = {
requestId: event.requestId,
sessionId: event.sessionId,
runId: this.activePrompts.get(event.sessionId)?.runId,
target,
request: event.request,
...(sourceLabel ? { sourceLabel } : {}),
@@ -3341,6 +3343,7 @@ export abstract class ChannelBase {
private pendingPermissionForEnvelope(
envelope: Envelope,
args: string,
requireOwner: boolean,
selectedSessionId?: string | null,
): PendingPermissionLookup {
const trimmed = args.trim();
@@ -3348,7 +3351,11 @@ export abstract class ChannelBase {
const explicit = this.pendingPermissions.get(trimmed);
if (
explicit &&
this.canEnvelopeAnswerPendingPermission(envelope, explicit)
this.canEnvelopeAnswerPendingPermission(
envelope,
explicit,
requireOwner,
)
) {
return { kind: 'found', pending: explicit };
}
@@ -3367,7 +3374,11 @@ export abstract class ChannelBase {
pending !== undefined &&
(selectedSessionId === undefined ||
pending.sessionId === selectedSessionId) &&
this.canEnvelopeAnswerPendingPermission(envelope, pending),
this.canEnvelopeAnswerPendingPermission(
envelope,
pending,
requireOwner,
),
);
if (matching.length === 0) {
return { kind: 'none', explicit: false };
@@ -3384,7 +3395,21 @@ export abstract class ChannelBase {
private canEnvelopeAnswerPendingPermission(
envelope: Envelope,
pending: PendingPermission,
requireOwner: boolean,
): boolean {
if (requireOwner) {
const active = this.activePrompts.get(pending.sessionId);
if (
!active ||
pending.runId !== active.runId ||
active.owner?.id !== envelope.senderId ||
active.cancelled ||
active.loopPrompt ||
pending.target.senderId !== envelope.senderId
) {
return false;
}
}
return (
pending.target.chatId === envelope.chatId &&
pending.target.threadId === envelope.threadId &&
@@ -3620,7 +3645,8 @@ export abstract class ChannelBase {
args: string,
decision: 'approve' | 'approve-always' | 'deny',
): Promise<boolean> {
if (!this.isAuthorizedForSharedSession(envelope)) {
const requireOwner = !this.isAuthorizedForSharedSession(envelope);
if (requireOwner && decision === 'approve-always') {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
@@ -3644,8 +3670,17 @@ export abstract class ChannelBase {
const lookup = this.pendingPermissionForEnvelope(
envelope,
args,
requireOwner,
bareNamedCommand ? (selectedTask?.sessionId ?? null) : undefined,
);
if (lookup.kind === 'none' && requireOwner) {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
'Only authorized members can answer permission requests in this shared session.',
);
return true;
}
if (lookup.kind === 'ambiguous') {
const requestList = lookup.requestIds
.slice(0, 6)
+1
View File
@@ -88,6 +88,7 @@ export interface ChannelConfig {
/**
* Who may operate a shared session (/approve, /cancel, /clear, /loop, ...).
* Unset or empty grants no shared-session operator permissions.
* An attended request owner may still approve once or deny their own request.
*/
operators?: string[];
groupHistoryLimit?: number;