Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c155f30
feat: 第一个可以运行的 remote-control 服务器
claude-code-best Apr 8, 2026
e4e1e9c
chore: docker 部署脚本完毕
claude-code-best Apr 8, 2026
e020f39
feat: 样式和 /rc 恢复
claude-code-best Apr 8, 2026
0afac98
feat: 修整后端代码
claude-code-best Apr 8, 2026
495ce88
feat: 完成鉴权, 列表逻辑
claude-code-best Apr 8, 2026
855cc4b
fix: 修复 ask user question 的工具调用弹窗
claude-code-best Apr 8, 2026
e508941
test: add comprehensive server-side tests for remote-control-server
claude-code-best Apr 9, 2026
333ea0b
test: add route integration tests and disconnect-monitor tests
claude-code-best Apr 9, 2026
3304558
test: add session-ingress and worker-events route tests
claude-code-best Apr 9, 2026
38d5e16
test: add SSE writer, WS handler message conversion, and route edge-c…
claude-code-best Apr 9, 2026
1446742
fix: 修复历史数据回显
claude-code-best Apr 9, 2026
324103e
Potential fix for pull request finding 'CodeQL / Clear-text logging o…
claude-code-best Apr 9, 2026
b1aaefa
Potential fix for pull request finding 'CodeQL / Clear-text logging o…
claude-code-best Apr 9, 2026
d407d39
build: 调整构建过程
claude-code-best Apr 9, 2026
8c1fb25
build: 修复 alpine 权限问题
claude-code-best Apr 9, 2026
ab67e36
build: 删除权限添加
claude-code-best Apr 9, 2026
442f639
build: 修复 user 的定义问题
claude-code-best Apr 9, 2026
0f86c66
fix: 修复 crypto.randomUUID 的 https 需要
claude-code-best Apr 9, 2026
821ad5d
fix: 修复服务器的路径问题
claude-code-best Apr 9, 2026
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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
dist
.git
.githooks
.github
docs
*.md
packages/remote-control-server/data/*.db
packages/remote-control-server/data/*.db-wal
packages/remote-control-server/data/*.db-shm
.claude
77 changes: 77 additions & 0 deletions .github/workflows/release-rcs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Release RCS Docker Image

on:
push:
tags:
- 'rcs-v*'

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/remote-control-server

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#rcs-v}" >> "$GITHUB_OUTPUT"

- name: Generate tags
id: tags
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
TAGS="${IMAGE}:${VERSION}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
if [ -n "$MAJOR" ] && [ -n "$MINOR" ]; then
TAGS="${TAGS},${IMAGE}:${MAJOR}.${MINOR}"
fi
TAGS="${TAGS},${IMAGE}:latest"
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"

- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
file: packages/remote-control-server/Dockerfile
push: false
load: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Verify image
run: |
IMAGE_TAG=$(echo "${{ steps.tags.outputs.tags }}" | cut -d',' -f1)
docker run -d --name rcs-test -p 3000:3000 "$IMAGE_TAG"
sleep 5
curl -sf http://localhost:3000/health || { docker logs rcs-test; exit 1; }
docker stop rcs-test
docker rm rcs-test

- name: Push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: packages/remote-control-server/Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ src/utils/vendor/
__pycache__/
*.pyc
logs

data
322 changes: 321 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"check:unused": "knip-bun",
"health": "bun run scripts/health-check.ts",
"postinstall": "node scripts/postinstall.cjs",
"docs:dev": "npx mintlify dev"
"docs:dev": "npx mintlify dev",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/remote-control-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data
30 changes: 30 additions & 0 deletions packages/remote-control-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ---- Stage 1: 安装依赖 + 构建 ----
FROM oven/bun:1 AS builder
WORKDIR /app

COPY packages/remote-control-server/package.json ./package.json
RUN bun install

COPY packages/remote-control-server/src ./src
RUN bun build src/index.ts --outfile=dist/server.js --target=bun

# ---- Stage 2: 运行时 ----
FROM oven/bun:1-slim AS runtime

RUN addgroup --system app && adduser --system --ingroup app app

WORKDIR /app

COPY --from=builder /app/dist/server.js ./server.js
COPY packages/remote-control-server/web ./web

VOLUME /app/data

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD bun run -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"

USER app

CMD ["bun", "run", "server.js"]
5 changes: 5 additions & 0 deletions packages/remote-control-server/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- [x] 权限面板的审批逻辑没有相应到
- [x] Loading 改为 anthropic 相关的动画, 借鉴 tui 的
- [x] 多轮数据时, 非常多的空白和重复消息
- [x] 历史数据没有拉取
- [x] 数据重新拉取没有把审批弹窗等显示出来
27 changes: 27 additions & 0 deletions packages/remote-control-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@anthropic/remote-control-server",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"build:web": "cd web && bun run build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.7.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0"
}
}
162 changes: 162 additions & 0 deletions packages/remote-control-server/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";

// Mock config before importing modules that depend on it
const mockConfig = {
port: 3000,
host: "0.0.0.0",
apiKeys: ["test-key-1", "test-key-2"],
baseUrl: "",
pollTimeout: 8,
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
};

mock.module("../config", () => ({
config: mockConfig,
getBaseUrl: () => "http://localhost:3000",
}));

import { validateApiKey, hashApiKey } from "../auth/api-key";
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
import { issueToken, resolveToken } from "../auth/token";
import { storeReset, storeCreateUser } from "../store";

// ---------- api-key ----------

describe("validateApiKey", () => {
test("validates a configured API key", () => {
expect(validateApiKey("test-key-1")).toBe(true);
expect(validateApiKey("test-key-2")).toBe(true);
});

test("rejects unknown key", () => {
expect(validateApiKey("unknown-key")).toBe(false);
});

test("rejects undefined", () => {
expect(validateApiKey(undefined)).toBe(false);
});

test("rejects empty string", () => {
expect(validateApiKey("")).toBe(false);
});
});

describe("hashApiKey", () => {
test("produces consistent SHA-256 hex", () => {
const hash = hashApiKey("my-key");
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashApiKey("my-key")).toBe(hash);
});

test("different keys produce different hashes", () => {
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
});
});

// ---------- jwt ----------

describe("JWT", () => {
// JWT reads process.env.RCS_API_KEYS directly (not via config)
const originalKeys = process.env.RCS_API_KEYS;

beforeEach(() => {
process.env.RCS_API_KEYS = "jwt-test-secret";
});

afterAll(() => {
process.env.RCS_API_KEYS = originalKeys;
});

describe("generateWorkerJwt", () => {
test("produces a three-part base64url token", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
expect(parts).toHaveLength(3);
for (const part of parts) {
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
}
});

test("contains correct header", () => {
const token = generateWorkerJwt("ses_123", 3600);
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
expect(header.alg).toBe("HS256");
expect(header.typ).toBe("JWT");
});

test("throws when no API key configured", () => {
delete process.env.RCS_API_KEYS;
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});

describe("verifyWorkerJwt", () => {
test("verifies a valid token", () => {
const token = generateWorkerJwt("ses_abc", 3600);
const payload = verifyWorkerJwt(token);
expect(payload).not.toBeNull();
expect(payload!.session_id).toBe("ses_abc");
expect(payload!.role).toBe("worker");
expect(payload!.iat).toBeGreaterThan(0);
expect(payload!.exp).toBeGreaterThan(payload!.iat);
});

test("returns null for expired token", () => {
const token = generateWorkerJwt("ses_old", -10);
expect(verifyWorkerJwt(token)).toBeNull();
});

test("returns null for malformed token (not 3 parts)", () => {
expect(verifyWorkerJwt("a.b")).toBeNull();
expect(verifyWorkerJwt("just-a-string")).toBeNull();
});

test("returns null for tampered signature", () => {
const token = generateWorkerJwt("ses_123", 3600);
const parts = token.split(".");
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
expect(verifyWorkerJwt(tampered)).toBeNull();
});

test("returns null for wrong signing key", () => {
const token = generateWorkerJwt("ses_123", 3600);
process.env.RCS_API_KEYS = "wrong-key";
expect(verifyWorkerJwt(token)).toBeNull();
process.env.RCS_API_KEYS = "jwt-test-secret";
});
});
});

// ---------- token ----------

describe("issueToken / resolveToken", () => {
beforeEach(() => {
storeReset();
});

test("issues and resolves a token", () => {
storeCreateUser("alice");
const { token, expires_in } = issueToken("alice");
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
expect(expires_in).toBe(86400);
expect(resolveToken(token)).toBe("alice");
});

test("returns null for unknown token", () => {
expect(resolveToken("nonexistent")).toBeNull();
});

test("returns null for undefined token", () => {
expect(resolveToken(undefined)).toBeNull();
});

test("tokens are unique", () => {
storeCreateUser("alice");
const t1 = issueToken("alice").token;
const t2 = issueToken("alice").token;
expect(t1).not.toBe(t2);
});
});
Loading
Loading