diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 893ee4e63c0..19d68385ebb 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -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 diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index f1c02608800..3721c48520e 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -301,7 +301,7 @@ 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//` 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 `.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//` 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 `.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 | | ------------------------------------- | --------------------------------------------------------------------------------------------------------- | @@ -309,6 +309,8 @@ Persists pairing state to `{channelName}-pairing.json` and `{channelName}-allowl | `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 diff --git a/packages/channels/base/src/PairingStore.test.ts b/packages/channels/base/src/PairingStore.test.ts index 8446bbac701..21aa3fe9202 100644 --- a/packages/channels/base/src/PairingStore.test.ts +++ b/packages/channels/base/src/PairingStore.test.ts @@ -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 @@ -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); diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts index e9aac86c076..8c409741692 100644 --- a/packages/channels/base/src/PairingStore.ts +++ b/packages/channels/base/src/PairingStore.ts @@ -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 { @@ -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 }); diff --git a/packages/cli/src/serve/channel-management-service.test.ts b/packages/cli/src/serve/channel-management-service.test.ts index 3feb84eac63..1343f31f3c5 100644 --- a/packages/cli/src/serve/channel-management-service.test.ts +++ b/packages/cli/src/serve/channel-management-service.test.ts @@ -302,6 +302,14 @@ 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(); @@ -309,7 +317,7 @@ describe('createChannelManagementService', () => { 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-'), @@ -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; @@ -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' }); }); }); diff --git a/packages/cli/src/serve/channel-management-service.ts b/packages/cli/src/serve/channel-management-service.ts index f6903dde8f9..002ca22078d 100644 --- a/packages/cli/src/serve/channel-management-service.ts +++ b/packages/cli/src/serve/channel-management-service.ts @@ -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; upsert( @@ -100,6 +109,11 @@ export interface ChannelManagementService { name: string, code: string, ): Promise; + pairingApprovals(name: string): Promise; + revokePairingApproval( + name: string, + senderId: string, + ): Promise; } interface ChannelManagementSettingsStore { @@ -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(), @@ -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)), }; } diff --git a/packages/cli/src/serve/routes/workspace-channel-management.test.ts b/packages/cli/src/serve/routes/workspace-channel-management.test.ts index 04117e889bb..5af1cca8a9a 100644 --- a/packages/cli/src/serve/routes/workspace-channel-management.test.ts +++ b/packages/cli/src/serve/routes/workspace-channel-management.test.ts @@ -60,6 +60,11 @@ function service(): ChannelManagementService { }, requests: [], })), + pairingApprovals: vi.fn(async () => ({ senderIds: ['sender-1'] })), + revokePairingApproval: vi.fn(async (_name, senderId) => ({ + revoked: senderId, + senderIds: [], + })), }; } @@ -220,11 +225,17 @@ describe('workspace Channel management routes', () => { const { app, primaryService, secondaryService } = mount(false); const response = await request(app).get('/workspaces/secondary/channels'); + const approvals = await auth( + request(app).get('/workspaces/secondary/channels/bot/pairing-approvals'), + ); expect(response.status).toBe(403); expect(response.body.code).toBe('untrusted_workspace'); + expect(approvals.status).toBe(403); + expect(approvals.body.code).toBe('untrusted_workspace'); expect(primaryService.list).not.toHaveBeenCalled(); expect(secondaryService.list).not.toHaveBeenCalled(); + expect(secondaryService.pairingApprovals).not.toHaveBeenCalled(); }); it('returns 503 when the channel management service is unavailable', async () => { @@ -246,7 +257,7 @@ describe('workspace Channel management routes', () => { expect(response.body.code).toBe('channel_management_unavailable'); }); - it('lists and approves pairing requests in the selected workspace', async () => { + it('manages pairing requests and approvals in the selected workspace', async () => { const { app, primaryService, secondaryService } = mount(); await request(app) @@ -260,17 +271,42 @@ describe('workspace Channel management routes', () => { .post('/workspaces/secondary/channels/bot/pairing-requests/approve') .send({ code: 'abcdefgh' }), ); + await request(app) + .get('/workspaces/secondary/channels/bot/pairing-approvals') + .expect(401); + await request(app) + .delete('/workspaces/secondary/channels/bot/pairing-approvals') + .send({ senderId: 'sender-1' }) + .expect(401); + const approvals = await auth( + request(app).get('/workspaces/secondary/channels/bot/pairing-approvals'), + ); + const revocation = await auth( + request(app) + .delete('/workspaces/secondary/channels/bot/pairing-approvals') + .send({ senderId: 'sender-1' }), + ); expect(pairingRequests.status).toBe(200); expect(pairingRequests.headers['cache-control']).toBe('no-store'); expect(approval.status).toBe(200); expect(approval.headers['cache-control']).toBe('no-store'); + expect(approvals.status).toBe(200); + expect(approvals.headers['cache-control']).toBe('no-store'); + expect(revocation.status).toBe(200); + expect(revocation.headers['cache-control']).toBe('no-store'); expect(secondaryService.pairingRequests).toHaveBeenCalledWith('bot'); expect(secondaryService.approvePairing).toHaveBeenCalledWith( 'bot', 'ABCDEFGH', ); + expect(secondaryService.pairingApprovals).toHaveBeenCalledWith('bot'); + expect(secondaryService.revokePairingApproval).toHaveBeenCalledWith( + 'bot', + 'sender-1', + ); expect(primaryService.pairingRequests).not.toHaveBeenCalled(); + expect(primaryService.pairingApprovals).not.toHaveBeenCalled(); const primaryPairingRequests = await auth( request(app).get('/workspace/channels/bot/pairing-requests'), @@ -279,6 +315,24 @@ describe('workspace Channel management routes', () => { expect(primaryService.pairingRequests).toHaveBeenCalledWith('bot'); }); + it('returns 404 when a pairing approval no longer exists', async () => { + const { app, primaryService } = mount(); + vi.mocked(primaryService.revokePairingApproval).mockRejectedValueOnce( + Object.assign(new Error('Pairing approval was not found.'), { + code: 'channel_pairing_approval_not_found', + }), + ); + + const response = await auth( + request(app) + .delete('/workspace/channels/bot/pairing-approvals') + .send({ senderId: 'sender-1' }), + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('channel_pairing_approval_not_found'); + }); + it('rejects requests with an invalid client ID', async () => { const { app, primaryService } = mount(); const invalidClient = (test: request.Test) => @@ -319,6 +373,14 @@ describe('workspace Channel management routes', () => { const pairingRequests = await invalidClient( request(app).get('/workspace/channels/bot/pairing-requests'), ); + const pairingApprovals = await invalidClient( + request(app).get('/workspace/channels/bot/pairing-approvals'), + ); + const revokePairingApproval = await invalidClient( + request(app) + .delete('/workspace/channels/bot/pairing-approvals') + .send({ senderId: 'sender-1' }), + ); expect(list.status).toBe(400); expect(list.body.code).toBe('invalid_client_id'); @@ -331,6 +393,8 @@ describe('workspace Channel management routes', () => { expect(startup.status).toBe(400); expect(approve.status).toBe(400); expect(pairingRequests.status).toBe(400); + expect(pairingApprovals.status).toBe(400); + expect(revokePairingApproval.status).toBe(400); expect(primaryService.list).not.toHaveBeenCalled(); expect(primaryService.upsert).not.toHaveBeenCalled(); expect(primaryService.remove).not.toHaveBeenCalled(); @@ -340,6 +404,8 @@ describe('workspace Channel management routes', () => { expect(primaryService.setStartup).not.toHaveBeenCalled(); expect(primaryService.approvePairing).not.toHaveBeenCalled(); expect(primaryService.pairingRequests).not.toHaveBeenCalled(); + expect(primaryService.pairingApprovals).not.toHaveBeenCalled(); + expect(primaryService.revokePairingApproval).not.toHaveBeenCalled(); }); it('rejects malformed names, revisions, secrets, and pairing codes', async () => { @@ -379,6 +445,11 @@ describe('workspace Channel management routes', () => { .post('/workspace/channels/bot/pairing-requests/approve') .send({ code: 'short' }), ); + const invalidSenderId = await auth( + request(app) + .delete('/workspace/channels/bot/pairing-approvals') + .send({ senderId: '' }), + ); expect(invalidName.body.code).toBe('invalid_channel_instance_name'); expect(unsafeName.status).toBe(400); @@ -388,9 +459,11 @@ describe('workspace Channel management routes', () => { ); expect(invalidSecret.body.code).toBe('channel_settings_invalid_secret'); expect(invalidPairing.body.code).toBe('invalid_channel_pairing_code'); + expect(invalidSenderId.body.code).toBe('invalid_channel_pairing_sender_id'); expect(primaryService.upsert).toHaveBeenCalledOnce(); expect(primaryService.start).not.toHaveBeenCalled(); expect(primaryService.approvePairing).not.toHaveBeenCalled(); + expect(primaryService.revokePairingApproval).not.toHaveBeenCalled(); }); it('sends only the name error when both name and body are invalid', async () => { @@ -401,6 +474,11 @@ describe('workspace Channel management routes', () => { .post('/workspace/channels/a%2Fb/pairing-requests/approve') .send({ code: 'short' }), ); + const revoke = await auth( + request(app) + .delete('/workspace/channels/a%2Fb/pairing-approvals') + .send({ senderId: '' }), + ); const upsert = await auth( request(app) .put('/workspace/channels/a%2Fb') @@ -419,6 +497,8 @@ describe('workspace Channel management routes', () => { expect(approve.status).toBe(400); expect(approve.body.code).toBe('invalid_channel_instance_name'); + expect(revoke.status).toBe(400); + expect(revoke.body.code).toBe('invalid_channel_instance_name'); expect(upsert.status).toBe(400); expect(upsert.body.code).toBe('invalid_channel_instance_name'); expect(remove.status).toBe(400); @@ -426,6 +506,7 @@ describe('workspace Channel management routes', () => { expect(startup.status).toBe(400); expect(startup.body.code).toBe('invalid_channel_instance_name'); expect(primaryService.approvePairing).not.toHaveBeenCalled(); + expect(primaryService.revokePairingApproval).not.toHaveBeenCalled(); expect(primaryService.upsert).not.toHaveBeenCalled(); expect(primaryService.remove).not.toHaveBeenCalled(); expect(primaryService.setStartup).not.toHaveBeenCalled(); diff --git a/packages/cli/src/serve/routes/workspace-channel-management.ts b/packages/cli/src/serve/routes/workspace-channel-management.ts index 5df6b2fa092..13e23e61da1 100644 --- a/packages/cli/src/serve/routes/workspace-channel-management.ts +++ b/packages/cli/src/serve/routes/workspace-channel-management.ts @@ -182,6 +182,21 @@ function parsePairingCode( return code.trim().toUpperCase(); } +function parsePairingSenderId( + body: Record, + res: Response, +): string | undefined { + const senderId = body['senderId']; + if (typeof senderId !== 'string' || senderId.length === 0) { + res.status(400).json({ + error: '`senderId` must be a non-empty string.', + code: 'invalid_channel_pairing_sender_id', + }); + return undefined; + } + return senderId; +} + function errorCode(error: unknown): string | undefined { if (!error || typeof error !== 'object') return undefined; try { @@ -203,6 +218,7 @@ const ERROR_STATUS = new Map([ ['untrusted_workspace', 403], ['channel_instance_not_found', 404], ['channel_pairing_request_not_found', 404], + ['channel_pairing_approval_not_found', 404], ['channel_pairing_not_enabled', 409], ['channel_settings_conflict', 409], ['channel_runtime_owner_mismatch', 409], @@ -272,6 +288,8 @@ export function registerWorkspaceChannelManagementRoutes( const register = (prefix: string, resolveRuntime: RuntimeResolver) => { const pairingRead = deps.mutate({ strict: true }); const pairingApprove = deps.mutate({ strict: true }); + const pairingApprovalsRead = deps.mutate({ strict: true }); + const pairingRevoke = deps.mutate({ strict: true }); const upsert = deps.mutate({ strict: true }); const remove = deps.mutate({ strict: true }); const startup = deps.mutate({ strict: true }); @@ -347,6 +365,44 @@ export function registerWorkspaceChannelManagementRoutes( }, ); + app.get( + `${prefix}/channels/:name/pairing-approvals`, + pairingApprovalsRead, + async (req, res) => { + const resolved = await target(req, res); + if (!resolved || !validateClient(req, res, resolved.runtime)) return; + const name = parseInstanceName(req, res); + if (!name) return; + try { + noStore(res); + res.status(200).json(await resolved.service.pairingApprovals(name)); + } catch (error) { + sendManagementError(res, error); + } + }, + ); + + app.delete( + `${prefix}/channels/:name/pairing-approvals`, + pairingRevoke, + async (req, res) => { + const resolved = await target(req, res); + if (!resolved || !validateClient(req, res, resolved.runtime)) return; + const name = parseInstanceName(req, res); + if (!name) return; + const senderId = parsePairingSenderId(deps.safeBody(req), res); + if (!senderId) return; + try { + noStore(res); + res + .status(200) + .json(await resolved.service.revokePairingApproval(name, senderId)); + } catch (error) { + sendManagementError(res, error); + } + }, + ); + app.put(`${prefix}/channels/:name`, upsert, async (req, res) => { const resolved = await target(req, res); if (!resolved || !validateClient(req, res, resolved.runtime)) return; diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 279a5a39769..5688f691f4b 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -128,7 +128,10 @@ import type { DaemonChannelMutationResult, DaemonChannelPairingApprovalRequest, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, DaemonChannelPairingRequestsSnapshot, + DaemonChannelPairingRevocationRequest, + DaemonChannelPairingRevocationResult, DaemonChannelsSnapshot, DaemonChannelStartupRequest, DaemonChannelTypeCatalog, @@ -3649,6 +3652,35 @@ export class DaemonClient { ); } + workspaceChannelPairingApprovals( + name: string, + opts?: DaemonChannelManagementOptions, + ): Promise { + return this.jsonRequest( + `/workspace/channels/${urlEncode(name)}/pairing-approvals`, + 'GET /workspace/channels/:name/pairing-approvals', + { clientId: opts?.clientId, timeoutMs: opts?.timeoutMs, mode: 'rest' }, + ); + } + + revokeWorkspaceChannelPairingApproval( + name: string, + request: DaemonChannelPairingRevocationRequest, + opts?: DaemonChannelManagementOptions, + ): Promise { + return this.jsonRequest( + `/workspace/channels/${urlEncode(name)}/pairing-approvals`, + 'DELETE /workspace/channels/:name/pairing-approvals', + { + method: 'DELETE', + body: request, + clientId: opts?.clientId, + timeoutMs: opts?.timeoutMs ?? CHANNEL_CONTROL_DEFAULT_TIMEOUT_MS, + mode: 'rest', + }, + ); + } + private workspaceChannelAction( name: string, action: 'start' | 'stop' | 'restart', @@ -4741,6 +4773,31 @@ export class WorkspaceDaemonClient { ); } + workspaceChannelPairingApprovals( + name: string, + opts?: DaemonChannelManagementOptions, + ): Promise { + return this.channelRequest( + `/channels/${urlEncode(name)}/pairing-approvals`, + 'GET /workspaces/:workspace/channels/:name/pairing-approvals', + undefined, + opts, + ); + } + + revokeWorkspaceChannelPairingApproval( + name: string, + request: DaemonChannelPairingRevocationRequest, + opts?: DaemonChannelManagementOptions, + ): Promise { + return this.channelRequest( + `/channels/${urlEncode(name)}/pairing-approvals`, + 'DELETE /workspaces/:workspace/channels/:name/pairing-approvals', + { method: 'DELETE', body: request }, + opts, + ); + } + private channelAction( name: string, action: 'start' | 'stop' | 'restart', diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index e32cf249b86..4a0be90a017 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -370,6 +370,9 @@ export type { DaemonChannelPairingRequestsSnapshot, DaemonChannelPairingApprovalRequest, DaemonChannelPairingApprovalResult, + DaemonChannelPairingApprovalsSnapshot, + DaemonChannelPairingRevocationRequest, + DaemonChannelPairingRevocationResult, DaemonChannelManagementOptions, DaemonChannelWorkerGroupSnapshot, DaemonChannelWorkerSnapshot, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 7604bca1adf..e4beb5aa195 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2994,6 +2994,19 @@ export interface DaemonChannelPairingApprovalResult approved: DaemonChannelPairingRequest; } +export interface DaemonChannelPairingApprovalsSnapshot { + senderIds: string[]; +} + +export interface DaemonChannelPairingRevocationRequest { + senderId: string; +} + +export interface DaemonChannelPairingRevocationResult + extends DaemonChannelPairingApprovalsSnapshot { + revoked: string; +} + export interface DaemonChannelManagementOptions { clientId?: string; timeoutMs?: number; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 0cab0bd1693..5b24bb2df6a 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -74,6 +74,9 @@ export { type DaemonChannelPairingRequestsSnapshot, type DaemonChannelPairingApprovalRequest, type DaemonChannelPairingApprovalResult, + type DaemonChannelPairingApprovalsSnapshot, + type DaemonChannelPairingRevocationRequest, + type DaemonChannelPairingRevocationResult, type DaemonChannelManagementOptions, type DaemonSessionRecapResult, type DaemonShellCommandResult, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 87fc1d82eed..b24f4d8f1db 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -7265,6 +7265,10 @@ describe('DaemonClient', () => { await client.approveWorkspaceChannelPairing('bot/name', { code: 'ABCDEFGH', }); + await client.workspaceChannelPairingApprovals('bot/name'); + await client.revokeWorkspaceChannelPairingApproval('bot/name', { + senderId: 'sender/1', + }); expect(calls.map(({ method, url }) => [method, url])).toEqual([ ['GET', 'http://daemon/workspace/channel-types'], @@ -7280,9 +7284,18 @@ describe('DaemonClient', () => { 'POST', 'http://daemon/workspace/channels/bot%2Fname/pairing-requests/approve', ], + [ + 'GET', + 'http://daemon/workspace/channels/bot%2Fname/pairing-approvals', + ], + [ + 'DELETE', + 'http://daemon/workspace/channels/bot%2Fname/pairing-approvals', + ], ]); expect(calls[1]?.headers['x-qwen-client-id']).toBe('reader'); expect(calls[2]?.headers['x-qwen-client-id']).toBe('writer'); + expect(JSON.parse(calls[11]!.body!)).toEqual({ senderId: 'sender/1' }); }); it('uses the exact qualified workspace routes', async () => { @@ -7300,6 +7313,10 @@ describe('DaemonClient', () => { config: { type: 'dingtalk' }, }); await workspace.workspaceChannelPairingRequests('bot'); + await workspace.workspaceChannelPairingApprovals('bot'); + await workspace.revokeWorkspaceChannelPairingApproval('bot', { + senderId: 'sender-1', + }); expect(calls.map(({ method, url }) => [method, url])).toEqual([ ['GET', 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/channel-types'], @@ -7313,9 +7330,20 @@ describe('DaemonClient', () => { 'GET', 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/channels/bot/pairing-requests', ], + [ + 'GET', + 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/channels/bot/pairing-approvals', + ], + [ + 'DELETE', + 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/channels/bot/pairing-approvals', + ], ]); expect(calls[1]?.headers['x-qwen-client-id']).toBe('reader'); expect(calls[2]?.headers['x-qwen-client-id']).toBe('writer'); + expect(JSON.parse(calls[6]!.body!)).toEqual({ + senderId: 'sender-1', + }); }); }); }); diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 63151544478..2e110db031f 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -26,6 +26,9 @@ import type { DaemonChannelDelivery, DaemonChannelNotifyRequest, DaemonChannelNotifyResult, + DaemonChannelPairingApprovalsSnapshot, + DaemonChannelPairingRevocationRequest, + DaemonChannelPairingRevocationResult, DaemonChannelDeliveryErrorCode, DaemonChannelDeliveryResultData, DaemonChannelDeliveryResultEvent, @@ -220,6 +223,9 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever();