mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-10-19 09:13:24 +00:00
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
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:
parent
3f0857eb7d
commit
b510fa9aec
7 changed files with 145 additions and 2 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 }`。
|
||||
|
||||
### 配置
|
||||
|
||||
| 方法与路径 | 说明 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.(),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue