diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index a36d2833d..8a1761a40 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -318,6 +318,16 @@ Takes a device offline in the cloud registry. Offline devices stay listed and do On success, `data` is `{ ok: true }`. +#### `DELETE /api/v1/remote-control/devices/{id}` + +Removes a device from the cloud registry: it disappears from the device list and its tunnel, if still connected, is disconnected. Deactivating keeps an offline device listed; deleting removes it entirely. When the target is this machine, the local tunnel is stopped first and the machine lock is held while deleting, so the device cannot re-register behind a successful response. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string | Device identifier | + +On success, `data` is `{ ok: true }`. + ### Config | Method and path | Description | diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index db668d10f..a4fb7d040 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -318,6 +318,16 @@ curl -s -H "Authorization: Bearer $TOKEN" \ 成功时 `data` 为 `{ ok: true }`。 +#### `DELETE /api/v1/remote-control/devices/{id}` + +把设备从云端注册表移除:设备从列表中消失,仍在连接的隧道会被断开。下线只是把设备置为离线并保留在列表中;删除则把它整个移除。当目标设备是本机时,先停止本地隧道并持有本机锁完成删除,确保设备无法在成功响应背后重新注册。 + +| 参数 | 位置 | 类型 | 说明 | +| --- | --- | --- | --- | +| `id` | path | string | 设备标识 | + +成功时 `data` 为 `{ ok: true }`。 + ### 配置 | 方法与路径 | 说明 | diff --git a/packages/kap-server/src/routes/remoteControl.ts b/packages/kap-server/src/routes/remoteControl.ts index e19be3d7d..c16aab500 100644 --- a/packages/kap-server/src/routes/remoteControl.ts +++ b/packages/kap-server/src/routes/remoteControl.ts @@ -53,6 +53,14 @@ interface RemoteControlRouteHost { reply: { send(payload: unknown): unknown }, ) => Promise | void, ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; } export interface RemoteControlRouteOptions { @@ -282,6 +290,51 @@ export function registerRemoteControlRoutes( deactivateDeviceRoute.options, deactivateDeviceRoute.handler as Parameters[2], ); + + const deleteDeviceRoute = defineRoute( + { + method: 'DELETE', + path: '/remote-control/devices/{id}', + params: remoteControlDeviceParamsSchema, + success: { data: remoteControlDeviceMutationResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Delete a Remote Control device', + tags: ['remote-control'], + }, + async (req, reply) => { + await handleDeviceRoute(req, reply, async () => { + const status = opts.service.status(); + const holder = await inspectRemoteControlLock(opts.homeDir); + const isLocal = + req.params.id === createKimiDeviceId(opts.homeDir) || + status.deviceId === req.params.id || + holder?.deviceId === req.params.id; + if (!isLocal) { + await opts.devices.deleteDevice(req.params.id); + return { ok: true }; + } + if (status.state !== 'off') { + await opts.service.disable(); + } + const gate = await acquireRemoteControlStartGate(opts.homeDir, opts.devices); + try { + await opts.devices.deleteDevice(req.params.id); + await opts.service.disable(); + } finally { + await gate.release(); + } + return { ok: true }; + }); + }, + ); + app.delete( + deleteDeviceRoute.path, + deleteDeviceRoute.options, + deleteDeviceRoute.handler as Parameters[2], + ); } const LOCK_RELEASE_POLL_MS = 100; diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index f6385d960..aeb789e40 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -32,6 +32,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "DELETE", "/api/v1/providers/{provider_id}", ], + [ + "DELETE", + "/api/v1/remote-control/devices/{id}", + ], [ "DELETE", "/api/v1/workspaces/{workspace_id}", diff --git a/packages/kap-server/test/remoteControl.test.ts b/packages/kap-server/test/remoteControl.test.ts index 6a8021f61..de7a2b7d6 100644 --- a/packages/kap-server/test/remoteControl.test.ts +++ b/packages/kap-server/test/remoteControl.test.ts @@ -322,11 +322,20 @@ describe('server-v2 /api/v1/remote-control', () => { ); expect(((await deactivatedRes.json()) as Envelope<{ ok: true }>).data.ok).toBe(true); + const deletedRes = await authedFetch( + server as RunningServer, + base, + '/api/v1/remote-control/devices/device-1', + { method: 'DELETE' }, + ); + expect(((await deletedRes.json()) as Envelope<{ ok: true }>).data.ok).toBe(true); + expect(cloud.requests.map((request) => `${request.method} ${request.pathname}`)).toEqual([ 'POST /coding-relay/auth/exchange', 'GET /coding-relay/v1/remote/devices', 'PATCH /coding-relay/v1/remote/devices/device-1', 'POST /coding-relay/v1/remote/devices/device-1/deactivate', + 'DELETE /coding-relay/v1/remote/devices/device-1', ]); expect(JSON.parse(cloud.requests[2]!.body)).toEqual({ alias: 'Renamed device' }); expect(cloud.requests.slice(1).every((request) => request.cookie === 'rc_session=one')).toBe( @@ -374,6 +383,48 @@ describe('server-v2 /api/v1/remote-control', () => { await cloud.close(); }); + it('stops the local tunnel when the current device is deleted', async () => { + await rm(remoteControlLockPath(home as string), { force: true }); + const cloud = await startDeviceCloud({ tunnel: true }); + vi.stubEnv( + 'KIMI_CODE_REMOTE_CONTROL_RELAY_URL', + `http://127.0.0.1:${cloud.port}/coding-relay/`, + ); + + const currentState = async (): Promise => { + const res = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); + return ((await res.json()) as Envelope).data.state; + }; + + const enabled = await postRemoteControl(true); + expect(enabled.data.state).toBe('on'); + const currentId = enabled.data.device_id; + expect(currentId).toBeTruthy(); + + const otherRes = await authedFetch( + server as RunningServer, + base, + '/api/v1/remote-control/devices/other-device', + { method: 'DELETE' }, + ); + expect(((await otherRes.json()) as Envelope<{ ok: true }>).data.ok).toBe(true); + expect(await currentState()).toBe('on'); + + const ownRes = await authedFetch( + server as RunningServer, + base, + `/api/v1/remote-control/devices/${currentId}`, + { method: 'DELETE' }, + ); + expect(((await ownRes.json()) as Envelope<{ ok: true }>).data.ok).toBe(true); + expect(await currentState()).toBe('off'); + await waitFor(async () => (await currentState()) === 'off'); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await currentState()).toBe('off'); + + await cloud.close(); + }); + it('waits for the lock holder to release before answering current-device deactivation', async () => { await rm(remoteControlLockPath(home as string), { force: true }); const cloud = await startDeviceCloud(); @@ -476,6 +527,7 @@ describe('server-v2 /api/v1/remote-control', () => { if (path === '/remote-control/devices/:id/deactivate') handler = next as typeof handler; }, patch: () => {}, + delete: () => {}, }; registerRemoteControlRoutes(app as never, { service, devices, homeDir }); @@ -516,6 +568,7 @@ describe('server-v2 /api/v1/remote-control', () => { if (path === '/remote-control/devices/:id/deactivate') handler = next as typeof handler; }, patch: () => {}, + delete: () => {}, }; registerRemoteControlRoutes(app as never, { service, devices, homeDir }); @@ -559,6 +612,7 @@ describe('server-v2 /api/v1/remote-control', () => { if (path === '/remote-control/devices/:id/deactivate') handler = next as typeof handler; }, patch: () => {}, + delete: () => {}, }; registerRemoteControlRoutes(app as never, { service, devices, homeDir }); @@ -604,6 +658,7 @@ describe('server-v2 /api/v1/remote-control', () => { }, post: () => {}, patch: () => {}, + delete: () => {}, }; registerRemoteControlRoutes(app as never, { service, @@ -654,6 +709,7 @@ describe('server-v2 /api/v1/remote-control', () => { if (path === '/remote-control/devices/:id/deactivate') handler = next as typeof handler; }, patch: () => {}, + delete: () => {}, }; registerRemoteControlRoutes(app as never, { service, devices, homeDir }); diff --git a/packages/remote-control/src/device-client.ts b/packages/remote-control/src/device-client.ts index 8db5ccc3a..ca7ff8cc9 100644 --- a/packages/remote-control/src/device-client.ts +++ b/packages/remote-control/src/device-client.ts @@ -109,6 +109,12 @@ export class RemoteControlDeviceClient { ); } + async deleteDevice(deviceId: string): Promise { + await this.request(`/v1/remote/devices/${encodeURIComponent(deviceId)}`, { + method: 'DELETE', + }); + } + private async context(): Promise { const auth = resolveKimiRemoteControlAuth({ configuredOAuthHost: this.options.configuredOAuthHost?.(), diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index 9e94176f2..1519688e6 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -974,7 +974,7 @@ describe('Remote Control device client', () => { response.end(JSON.stringify({ devices: [device], max_devices: 3 })); return; } - if (pathname === '/coding-relay/v1/remote/devices/device%2Fone' && request.method === 'PATCH') { + if (pathname === '/coding-relay/v1/remote/devices/device%2Fone' && (request.method === 'PATCH' || request.method === 'DELETE')) { response.writeHead(204); response.end(); return; @@ -995,8 +995,9 @@ describe('Remote Control device client', () => { await expect(client.listDevices()).resolves.toEqual({ devices: [device], max_devices: 3 }); await client.renameDevice('device/one', 'Renamed'); + await client.deleteDevice('device/one'); - expect(requests).toHaveLength(3); + expect(requests).toHaveLength(4); expect(JSON.parse(requests[0]!.body)).toEqual({ access_token: TOKEN.accessToken, refresh_token: TOKEN.refreshToken, @@ -1005,8 +1006,11 @@ describe('Remote Control device client', () => { expect(requests.slice(1).map((request) => request.cookie)).toEqual([ 'rc_session=one; theme=dark', 'rc_session=one; theme=dark', + 'rc_session=one; theme=dark', ]); expect(JSON.parse(requests[2]!.body)).toEqual({ alias: 'Renamed' }); + expect(requests[3]!.method).toBe('DELETE'); + expect(requests[3]!.pathname).toBe('/coding-relay/v1/remote/devices/device%2Fone'); }); it('projects only the documented device fields from the relay payload', async () => {