Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,9 +791,15 @@ Pairing management is available only for instances configured with the

- `GET .../channels/:name/pairing-requests`
- `POST .../channels/:name/pairing-requests/approve` with `{ "code": "..." }`

Both pairing routes require a bearer token and use `Cache-Control: no-store`.
Approval is scoped to the selected Channel instance and workspace.
- `GET .../channels/:name/pairing-approvals`
- `DELETE .../channels/:name/pairing-approvals` with
`{ "senderId": "..." }`

All pairing routes require a bearer token and use `Cache-Control: no-store`.
Requests, approvals, and revocations are scoped to the selected Channel
instance and workspace. The approvals snapshot contains sender IDs because the
allowlist does not persist sender display names. Revoking an unknown sender
returns `404 channel_pairing_approval_not_found`.

### Channel delivery and Notify

Expand Down
4 changes: 3 additions & 1 deletion packages/channels/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,16 @@ When `requireMention` is `true` (default), group messages are only processed if
constructor(channelName: string, workspaceCwd?: string)
```

Persists pairing state to `{channelName}-pairing.json` and `{channelName}-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels/<workspace-scope>/` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `<channel>.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. To revoke a sender, remove their entry from the scoped allowlist (and from the legacy global file, while it exists) — deleting the scoped file does not revoke, and recreating the scope directory from scratch re-imports the legacy baseline.
Persists pairing state to `{channelName}-pairing.json` and `{channelName}-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels/<workspace-scope>/` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `<channel>.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. `revoke(senderId)` removes the sender only from this store's allowlist and never mutates the legacy global baseline.

| Method | Description |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `createRequest(senderId, senderName)` | Generate an 8-char pairing code (or return existing). Returns `null` if 3 pending requests already exist. |
| `approve(code)` | Approve a pairing request, adds sender to allowlist. Returns the request or `null`. |
| `isApproved(senderId)` | Check if sender is in the approved allowlist |
| `listPending()` | Get active (non-expired) pending requests |
| `getAllowlist()` | Get approved sender IDs |
| `revoke(senderId)` | Remove an approved sender. Returns whether the sender was present. |

## Envelope

Expand Down
32 changes: 32 additions & 0 deletions packages/channels/base/src/PairingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ describe('PairingStore workspace scoping (#7017)', () => {
expect(storeB.isApproved('sender-1')).toBe(false);
});

it('revokes an approved sender only from the selected workspace', () => {
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);

for (const store of [storeA, storeB]) {
const code = store.createRequest('sender-1', 'Sender One')!;
store.approve(code);
}

expect(storeA.revoke('sender-1')).toBe(true);
expect(storeA.isApproved('sender-1')).toBe(false);
expect(storeB.isApproved('sender-1')).toBe(true);
});

it('keeps path-traversal channel names inside the workspace scope', () => {
// Channel names come from unrestricted config keys. Without encoding,
// `../support` climbs out of the scope directory and both workspaces
Expand Down Expand Up @@ -173,6 +187,24 @@ describe('PairingStore workspace scoping (#7017)', () => {
expect(legacy).toEqual(['legacy-sender']);
});

it('does not mutate the legacy baseline when revoking in one workspace', () => {
seedLegacy();
const storeA = new PairingStore('support-bot', workspaceA);

expect(storeA.revoke('legacy-sender')).toBe(true);
expect(storeA.isApproved('legacy-sender')).toBe(false);

const storeB = new PairingStore('support-bot', workspaceB);
expect(storeB.isApproved('legacy-sender')).toBe(true);
const legacy = JSON.parse(
fs.readFileSync(
path.join(channelsRoot(), 'support-bot-allowlist.json'),
'utf-8',
),
) as string[];
expect(legacy).toEqual(['legacy-sender']);
});

it('does not resurrect senders revoked by deleting the scoped allowlist file', () => {
seedLegacy();
const store = new PairingStore('support-bot', workspaceA);
Expand Down
14 changes: 12 additions & 2 deletions packages/channels/base/src/PairingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ export class PairingStore {
* grandfather the same baseline, and an older qwen version running
* concurrently still reads the global files.
*
* Revocation therefore means REMOVING ENTRIES from the scoped allowlist
* (and from the legacy global file, while it exists) — not deleting files.
* Revocation therefore means removing entries from this store's allowlist,
* not deleting files or mutating the legacy global baseline.
*/
private migrateLegacyState(channelsRoot: string, channelName: string): void {
try {
Expand Down Expand Up @@ -216,6 +216,16 @@ export class PairingStore {
return this.readAllowlist();
}

revoke(senderId: string): boolean {
const list = this.readAllowlist();
const next = list.filter((id) => id !== senderId);
if (next.length === list.length) {
return false;
}
this.writeAllowlist(next);
return true;
}

private ensureDir(): void {
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/serve/channel-management-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,22 @@ describe('createChannelManagementService', () => {
await expect(service.pairingRequests('bot')).rejects.toMatchObject({
code: 'channel_workspace_mismatch',
});
await expect(service.pairingApprovals('bot')).rejects.toMatchObject({
code: 'channel_workspace_mismatch',
});
await expect(
service.revokePairingApproval('bot', 'sender-1'),
).rejects.toMatchObject({
code: 'channel_workspace_mismatch',
});

expect(store.setStartupNames).not.toHaveBeenCalled();
expect(store.remove).not.toHaveBeenCalled();
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
expect(manager.reloadWorkspace).not.toHaveBeenCalled();
});

it('lists and approves pairing requests in the selected workspace scope', async () => {
it('manages pairing requests and approvals in the selected workspace scope', async () => {
const previousQwenHome = process.env['QWEN_HOME'];
const qwenHome = await fs.mkdtemp(
path.join(os.tmpdir(), 'channel-management-pairing-'),
Expand Down Expand Up @@ -344,6 +352,21 @@ describe('createChannelManagementService', () => {
requests: [],
});
expect(pairing.isApproved('sender-1')).toBe(true);
await expect(service.pairingApprovals('bot')).resolves.toEqual({
senderIds: ['sender-1'],
});
await expect(
service.revokePairingApproval('bot', 'sender-1'),
).resolves.toEqual({
revoked: 'sender-1',
senderIds: [],
});
expect(pairing.isApproved('sender-1')).toBe(false);
await expect(
service.revokePairingApproval('bot', 'sender-1'),
).rejects.toMatchObject({
code: 'channel_pairing_approval_not_found',
});
} finally {
if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
else process.env['QWEN_HOME'] = previousQwenHome;
Expand Down Expand Up @@ -820,5 +843,11 @@ describe('createChannelManagementService', () => {
await expect(
service.approvePairing('bot', 'ABCDEFGH'),
).rejects.toMatchObject({ code: 'channel_pairing_not_enabled' });
await expect(service.pairingApprovals('bot')).rejects.toMatchObject({
code: 'channel_pairing_not_enabled',
});
await expect(
service.revokePairingApproval('bot', 'sender-1'),
).rejects.toMatchObject({ code: 'channel_pairing_not_enabled' });
});
});
30 changes: 30 additions & 0 deletions packages/cli/src/serve/channel-management-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export interface ChannelPairingApprovalResult
approved: PairingRequest;
}

export interface ChannelPairingApprovalsSnapshot {
senderIds: string[];
}

export interface ChannelPairingRevocationResult
extends ChannelPairingApprovalsSnapshot {
revoked: string;
}

export interface ChannelManagementService {
list(): Promise<DaemonChannelsSnapshot>;
upsert(
Expand All @@ -100,6 +109,11 @@ export interface ChannelManagementService {
name: string,
code: string,
): Promise<ChannelPairingApprovalResult>;
pairingApprovals(name: string): Promise<ChannelPairingApprovalsSnapshot>;
revokePairingApproval(
name: string,
senderId: string,
): Promise<ChannelPairingRevocationResult>;
}

interface ChannelManagementSettingsStore {
Expand Down Expand Up @@ -540,6 +554,19 @@ export function createChannelManagementService(
}
return { approved, requests: store.listPending() };
},
async pairingApprovals(name) {
return { senderIds: pairingStoreFor(name).getAllowlist() };
},
async revokePairingApproval(name, senderId) {
const store = pairingStoreFor(name);
if (!store.revoke(senderId)) {
throw new ChannelManagementError(
'channel_pairing_approval_not_found',
'Pairing approval was not found.',
);
}
return { revoked: senderId, senderIds: store.getAllowlist() };
},
};
return {
list: () => service.list(),
Expand All @@ -555,5 +582,8 @@ export function createChannelManagementService(
pairingRequests: (name) => service.pairingRequests(name),
approvePairing: (name, code) =>
inMutationLane(() => service.approvePairing(name, code)),
pairingApprovals: (name) => service.pairingApprovals(name),
revokePairingApproval: (name, senderId) =>
inMutationLane(() => service.revokePairingApproval(name, senderId)),
};
}
Loading
Loading