feat(kap-server): add remote control device deletion (#4192)
Some checks failed
CI / build (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test (5) (push) Has been cancelled
CI / test-pi-tui (push) Has been cancelled
CI / test-vscode-legacy (push) Has been cancelled
CI / test-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
Release / Release (push) Has been cancelled
Publish VS Code extension / Publish VSIX to marketplaces (push) Has been cancelled
CI / typecheck (push) Has been cancelled
Nix Build / Check flake.nix workspace sync (push) Has been cancelled
Release / Deploy docs (push) Has been cancelled
Release / Native release artifact (push) Has been cancelled
Nix Build / nix build .#kimi-code (push) Has been cancelled
Release / Publish native release assets (push) Has been cancelled

* feat(kap-server): add remote control device deletion

* fix(kap-server): coordinate with the local tunnel before deleting this machine's device
This commit is contained in:
liruifengv 2026-10-13 19:58:01 +08:00 • committed by GitHub
parent 3f0857eb7d
commit b510fa9aec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 145 additions and 2 deletions

View file

@ -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 |

View file

@ -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 }`。
### 配置
| 方法与路径 | 说明 |

View file

@ -53,6 +53,14 @@ interface RemoteControlRouteHost {
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
delete(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
export interface RemoteControlRouteOptions {
@ -282,6 +290,51 @@ export function registerRemoteControlRoutes(
deactivateDeviceRoute.options,
deactivateDeviceRoute.handler as Parameters<RemoteControlRouteHost['post']>[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<RemoteControlRouteHost['delete']>[2],
);
}
const LOCK_RELEASE_POLL_MS = 100;

View file

@ -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}",

View file

@ -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<string> => {
const res = await authedFetch(server as RunningServer, base, '/api/v1/remote-control');
return ((await res.json()) as Envelope<RemoteControlStatusWire>).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 });

View file

@ -109,6 +109,12 @@ export class RemoteControlDeviceClient {
);
}
async deleteDevice(deviceId: string): Promise<void> {
await this.request(`/v1/remote/devices/${encodeURIComponent(deviceId)}`, {
method: 'DELETE',
});
}
private async context(): Promise<RemoteControlDeviceContext> {
const auth = resolveKimiRemoteControlAuth({
configuredOAuthHost: this.options.configuredOAuthHost?.(),

View file

@ -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 () => {