Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 38 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Product

<!-- impeccable:product-schema 1 -->

## Platform

web

## Users

本机管理员。在深色运维控制台里打开某个虚拟机槽位,核对并改写该槽 Claude Code 的种子配置。

## Product Purpose

vm2api 把 Claude / Codex 账号做成可调度的虚拟机槽,并对外提供兼容上游的 API。种子页的成功标准:管理员不用进容器,就能看见当前槽 `settings.json` 里实际生效的配置项,改完能播种,并实时看到遥测开或关。

## Positioning

种子不是一份预设表单。它读当前槽家目录里已经写好的 Claude 配置,按文件内容逐项显示,再把改动写回同一份文件。

## Operating Context

管理员在 `/vm/$id` 的「种子」页签工作。数据来自该槽 `cli-home/.claude/settings.json`、`claude.setting` 和种子策略。遥测进程状态来自槽详情里的实时观测。

## Capabilities and Constraints

- 显示当前槽 `settings.json` 的真实键值,而不是只显示种子策略里的默认串。
- `claude.setting` 以可编辑文本提供,保存时写回并重新播种。
- 遥测开、关状态实时刷新;配置开关和进程是否在跑是两件事。
- 「拒客户端 settings」和「拒客户端身份」按当前策略准确显示,并可切换。
- 平台是现有 React 面板,不另起一套视觉系统。

## Product Principles

- 槽内文件是事实,表单默认值不是。
- 开和关都要能被看见,未知不能伪装成关闭。
- 能改的项和只读观测分开。
- 播种是显式动作,不在切换开关时偷偷写盘。
18 changes: 18 additions & 0 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ start_stack() {
fi
if KIN_AUTO_SYNC_WRAP="$auto_sync_wrap" compose up -d; then
wait_health || true
refresh_slot_services
return
fi
err "docker compose 失败。日志: docker logs ${SERVICE_NAME}"
Expand All @@ -655,6 +656,7 @@ start_stack() {
cat "$log"
rm -f "$log"
wait_health || true
refresh_slot_services
return
fi
cat "$log"
Expand All @@ -664,6 +666,7 @@ start_stack() {
if KIN_AUTO_SYNC_WRAP="$auto_sync_wrap" compose up -d --build; then
rm -f "$log"
wait_health || true
refresh_slot_services
return
fi
guide_changelog_error
Expand All @@ -676,6 +679,21 @@ start_stack() {
exit 1
}

refresh_slot_services() {
# The control-plane image carries the freshly built Go binaries. Publish them
# into persistent slot-bin mounts and restart only kin-worker/kin-egress.
# A stale kin-egress can keep the old binary and its listen port after this
# refresh. Leaving that as a warning reports the upgrade done while the old
# exit is still serving traffic.
if ! docker exec "$SERVICE_NAME" test -f /opt/vm2api/scripts/fleet-services.mjs >/dev/null 2>&1; then
err "控制面缺少 Go 服务刷新脚本,不能确认 kin-egress 已换到当前二进制"
exit 1
elif ! docker exec "$SERVICE_NAME" node /opt/vm2api/scripts/fleet-services.mjs; then
err "Go slot service refresh failed; VM containers were left untouched, but kin-egress may still be the previous binary"
exit 1
fi
}

print_banner() {
echo ""
echo -e "${CYAN}==============================================${NC}"
Expand Down
3 changes: 3 additions & 0 deletions docker/kin-kvm/guest/kin-guest-setup
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ link_payload() {
if [ -e /opt/kin-payload/opt/kin ] || [ -d /opt/kin-payload/opt/kin ]; then
ln -sfn /opt/kin-payload/opt/kin /opt/kin
fi
if [ -e /opt/kin-payload/opt/kin-live ] || [ -d /opt/kin-payload/opt/kin-live ]; then
ln -sfn /opt/kin-payload/opt/kin-live /opt/kin-live
fi
}

apply_identity() {
Expand Down
4 changes: 2 additions & 2 deletions docs/PANEL_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
| POST | `/vms/:id/oauth/generate-auth-url` | PKCE 授权链接;无 SOCKS5 拒绝。`{ flavor: "claude_code" }` 为官方 Claude Code 授权页。`{ flavor: "setup_token" }` 为 inference-only PKCE,不启槽内 CLI |
| POST | `/vms/:id/oauth/exchange-code` | 粘贴授权码,经槽代理换票。完整 OAuth 才排队初装。flavor 以 session 为准 |
| GET/POST | `/vms/:id/official-cc-bootstrap` | 初装进度 / `{ manual:true }` 再跑 |
| GET/PUT | `/vms/:id/seed-settings` | 播种;强制保留 telemetry/bedrock/vertex 等 env |
| GET/PUT | `/vms/:id/seed-settings` | 播种;强制保留 telemetry/bedrock/vertex 等 env;`claude_setting` 可编辑并写入槽内 `.claude/claude.setting` |
| POST | `/vms/:id/collect-identity` | guest 采集(locale/tz/`guest_machine_id`) |
| POST | `/vms/:id/reload` | 重载该槽 worker |
| GET | `/wrap-cli` | kernel / wrap 样本 inspect:`ok, dir, kernel_bin, glibc_shim, wrapper, meta, kernel`。`kernel.source` 为 `configured`(仓内 `KIN_KERNEL_BIN` / `bin/kin-kernel`)或 `sample` |
Expand All @@ -103,7 +103,7 @@
| POST | `/vms/create` | 种子 VM + Claude Code home;依赖私有 `kin-computer`。未安装返回 400 `computer_service_missing`,身份生成失败返回 400 `computer_create_failed`。Docker 槽可在 `machine` 里可选传 `docker_cpus`(见下) |
| POST | `/vms/import` | sessionKey 导入(必须已有 VM+代理) |
| GET | `/vms/fleet-status` | 全槽更新状态 |
| POST | `/vms/fleet-update` | 全槽 roll / 采集 |
| POST | `/vms/fleet-update` | 全槽 roll / 采集;`action=telemetry` 只更新 Go telemetry/egress 服务,不重建 VM |
| POST | `/vms/reconcile-fingerprints` | 用官方 `~/.claude.json` 对齐指纹 |
| POST | `/probe` | 全量额度探测 |
| GET/POST | `/health-probe` | 读/跑官方 hello 健康探测缓存 |
Expand Down
43 changes: 43 additions & 0 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# VM telemetry service

Telemetry is an explicit seed-policy opt-in. A newly created VM is disabled;
setting `telemetry_disabled` to `false` writes the VM's `worker.json` identity
and starts the Go `kin-worker telemetry` service. Setting it back to `true`
stops that child process and leaves the VM and its Rust kernel running.

The service reads credentials only from the VM's own Claude home. Its feature
payload uses facts collected from that VM (including its timezone, OS,
hardware, shell and runtime versions), never controller-host defaults or a
synthetic fingerprint. Requests leave through the VM's bound egress, so the
service observes the VM's actual network address. Uploads are periodic and are idle-gated
by `run/telemetry.touch`.

`claude.setting` is a plain, editable seed file beside `settings.json` and is
initialized with:

```text
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
```

## Service-only updates

Go payloads are copied atomically into each slot's persistent `slot-bin`
directory. Cluster containers bind that directory at `/opt/kin-live` (and the
KVM guest payload at `/opt/kin-live`). The panel/fleet `telemetry` action
restarts only the telemetry child and the `kin-egress` helper; it does not
rebuild a VM, replace `vms/`, rotate credentials, or restart the Rust kernel.

An update must verify the new binary hash and the replacement process before
reporting success. A failed service restart is reported per slot so the old VM
identity remains intact and can be rolled back by republishing the previous
binary.

For a controlled rollout, run `scripts/migrate-legacy-cluster-slots.mjs` in
the control-plane container. Pass VM IDs after the root and concurrency
arguments to limit the rollout. It performs the same migration used by the
one-click installer: legacy slot and egress containers are stopped, renamed to
`*-legacy-<timestamp>`, and kept as stopped rollback copies; replacement
containers use the persistent `/opt/kin-live` mount. Container IDs therefore
change, but the VM, credentials, `vms/`, routing, and network identity do not.
After this one-time migration, subsequent updates atomically replace only the
Go binaries in `slot-bin` and restart the service processes.
10 changes: 10 additions & 0 deletions scripts/fleet-services.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs'

const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api'
const concurrency = Number(process.argv[3] || 4)
const report = await runFleetUpdate(root, {
action: 'telemetry',
concurrency,
})
console.log(JSON.stringify(report))
if (report.failed_count) process.exit(1)
24 changes: 24 additions & 0 deletions scripts/migrate-legacy-cluster-slots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env node

/**
* Migrate legacy cluster slot containers to the persistent Go-service mount.
* This never rebuilds a VM: the old container is renamed and retained as a
* stopped rollback copy, while the replacement reuses its image and settings.
*/
import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs'

const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api'
const concurrency = Number(process.argv[3] || process.env.KIN_FLEET_CONCURRENCY || 4)
const ids = process.argv
.slice(4)
.flatMap((value) => String(value).split(','))
.map((value) => value.trim())
.filter(Boolean)

const report = await runFleetUpdate(root, {
action: 'telemetry',
concurrency,
ids: ids.length ? ids : null,
})
console.log(JSON.stringify(report))
if (report.failed_count) process.exitCode = 1
9 changes: 9 additions & 0 deletions scripts/panel-upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ attempt=0
while [ "$attempt" -lt 45 ]; do
if probe verify "$TAG" "$BASELINE"; then
[ -d .git ] || printf '%s\n' "${TAG#v}" > VERSION
# Replace the Go worker/egress payloads in-place; no VM/container rebuild.
# Older control-plane images may not carry the helper yet; retry on the
# next image update instead of invalidating an otherwise verified upgrade.
if ! docker exec "$CONTAINER" test -f /opt/vm2api/scripts/fleet-services.mjs >/dev/null 2>&1; then
echo "Go service refresh helper is not present in the control plane; retry after the next image update" >&2
elif ! docker exec "$CONTAINER" node /opt/vm2api/scripts/fleet-services.mjs; then
echo "Go service-only update failed; inspect the per-slot report" >&2
exit 1
fi
echo "Control plane restarted and verified at $TAG"
echo "Previously healthy slot kernels verified: $BASELINE"
exit 0
Expand Down
9 changes: 9 additions & 0 deletions src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,12 @@ export function validatePersonaRoutingPatch(body = {}) {
if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') {
problems.push('cch_enabled 必须是布尔')
}
if (Object.prototype.hasOwnProperty.call(compat, 'auto_compact_window') && compat.auto_compact_window != null) {
const windowTokens = Number(compat.auto_compact_window)
if (!Number.isInteger(windowTokens) || windowTokens < 100_000 || windowTokens > 1_000_000) {
problems.push('auto_compact_window 必须是 100000–1000000 的整数')
}
}
if (
compat.thinking_display != null &&
compat.thinking_display !== '' &&
Expand Down Expand Up @@ -1406,6 +1412,9 @@ function presentCompatibility(compat) {
.trim()
.toLowerCase()
out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized'
const windowTokens = Number(out.auto_compact_window)
out.auto_compact_window =
Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null
return out
}

Expand Down
36 changes: 30 additions & 6 deletions src/lib/admin/panel-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3524,21 +3524,33 @@ export function createPanelHandler(ctx) {
if (!vm) return json(res, 404, { ok: false, error: { message: 'vm not found' } })
const homeDir = path.join(cfg.paths.project, 'vms', id, 'cli-home')
let settings_json = null
let claude_setting = null
let kin_seed = null
try {
settings_json = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf8'))
} catch {}
try {
claude_setting = fs
.readFileSync(path.join(homeDir, '.claude', 'claude.setting'), 'utf8')
.replace(/\r\n?/g, '\n')
.replace(/\n+$/g, '')
} catch {
// Missing file stays null. The policy default is not this slot's file.
}
try {
kin_seed = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'kin-seed.json'), 'utf8'))
} catch {}
const seed_policy = defaultSeedPolicy(vm.seed_policy || {})
if (claude_setting !== null) seed_policy.claude_setting = claude_setting
return json(
res,
200,
panel.ok({
vm_id: id,
seed_policy: defaultSeedPolicy(vm.seed_policy || {}),
...seedTelemetryContract(defaultSeedPolicy(vm.seed_policy || {})),
seed_policy,
...seedTelemetryContract(seed_policy),
settings_json,
claude_setting,
kin_seed,
cli_home: homeDir,
}),
Expand All @@ -3559,17 +3571,27 @@ export function createPanelHandler(ctx) {
if (body.theme !== undefined) merged.theme = body.theme
if (body.extra_env !== undefined) merged.extra_env = body.extra_env
if (body.settings_json_override !== undefined) merged.settings_json_override = body.settings_json_override
if (body.claude_setting !== undefined) merged.claude_setting = body.claude_setting
if (body['claude.setting'] !== undefined) merged.claude_setting = body['claude.setting']
const next = defaultSeedPolicy(merged)
if (body.settings_json_override !== undefined) next.settings_json_override = body.settings_json_override
if (body.extra_env !== undefined) next.extra_env = body.extra_env || {}
vm.seed_policy = next
vm.updated_at = new Date().toISOString()
atomicWriteJson(vmPath, vm, { mode: 0o600 })
const written = writeSlotSeedFiles(cfg.paths.project, vm, next)
if (next.telemetry_disabled === false && telemetryWasOff) {
try {
await reloadSlotReady(vm, cfg.paths.project, { routing: ctx.routingConfig })
} catch {}
let service = null
if (
vm.status === 'running' &&
(next.telemetry_disabled !== telemetryWasOff || body.telemetry_service_restart === true)
) {
const report = await runFleetUpdate(cfg.paths.project, {
action: 'telemetry',
ids: [id],
concurrency: 1,
routing: ctx.routingConfig,
})
service = report.items?.[0] || report
}
return json(
res,
Expand All @@ -3579,7 +3601,9 @@ export function createPanelHandler(ctx) {
seed_policy: next,
...seedTelemetryContract(next),
settings_json: written.settings || null,
claude_setting: written.claude_setting || '',
kin_seed: written.kin_seed || null,
service,
}),
)
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/cluster/docker-remote.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,16 @@ export async function removeContainer(connect, id) {
await dockerJson(connect, { method: 'DELETE', path: `${containerPath(id)}?force=1&v=1`, timeoutMs: 60_000 }, [204])
}

/** Preserve the container object for a rollback instead of deleting it. */
export async function renameContainer(connect, id, name) {
if (!NAME_RE.test(String(name || ''))) throw new ClusterError(400, 'invalid_name', '容器名不合法')
await dockerJson(
connect,
{ method: 'POST', path: `${containerPath(id)}/rename?name=${encodeURIComponent(name)}`, timeoutMs: 30_000 },
[204],
)
}

export async function containerLogs(connect, id, { tail = 200 } = {}) {
const n = Math.min(5000, Math.max(1, Number.parseInt(tail, 10) || 200))
const inspect = await dockerJson(connect, { path: `${containerPath(id)}/json` })
Expand Down
Loading