Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 未发布

- 集群槽无需重建 VM 即可更新 Go 槽位服务(kin-worker / kin-egress):持久化 `slot-bin` 挂载推送二进制,fleet `action=telemetry` 热更新遥测与 egress。(#600)
- Codex 入站在平台探测前支持 CPR 式 `routing.codex.model_mappings` 精确模型映射;默认 `codex-auto-review` → `gpt-6-luna`(清空该键时回退 `gpt-6.1-sol`)。日志保留 `requested_model` / `upstream_model`;非法或非 GPT 目标 400,不 hop Anthropic。(#526)
- 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。
- 协议页新增「thinking 显示」,`routing.compatibility.thinking_display` 默认 `summarized`。调用方打开 thinking 却要求隐藏(`omitted`、`updates`,或默认隐藏的模型上没写 display)时,出站改成 `summarized`;显式 `passthrough` 保留调用方的值。已保存的 `routing.json` 不写这个键也按 summarized 生效。
Expand Down
11 changes: 11 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,14 @@ 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.
if ! docker exec "$SERVICE_NAME" node /opt/vm2api/scripts/fleet-services.mjs; then
warn "Go slot service refresh failed; VM containers were left untouched"
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
33 changes: 33 additions & 0 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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.
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)
5 changes: 5 additions & 0 deletions scripts/panel-upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ 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.
if ! 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
50 changes: 44 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 {
claude_setting = defaultSeedPolicy(vm.seed_policy || {}).claude_setting
}
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,41 @@ 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
}
if (service?.ok === false) {
return json(res, 502, {
ok: false,
error: {
message: service.error || 'telemetry service update failed',
code: service.code || 'telemetry_update_failed',
},
data: {
vm_id: id,
seed_policy: next,
service,
},
})
}
return json(
res,
Expand All @@ -3579,7 +3615,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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seed save toasts success after service failure

Medium Severity

PUT /seed-settings still returns HTTP 200 ok when the nested telemetry fleet update fails, and the seed card always toasts 已播种. Operators can believe the sidecar restarted after toggling telemetry even though service.ok is false.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7c3dfb. Configure here.

}),
)
}
Expand Down
48 changes: 46 additions & 2 deletions src/lib/cluster/remote-slot-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
*/

import fs from 'node:fs'
import crypto from 'node:crypto'
import path from 'node:path'
import { nodeSession, remoteSlotDir, remoteSlotOwner, vmNodeId } from './placement.mjs'
import { hasCredentialPresence, mirrorWorkerCredentialsToVm } from '../oauth/oauth-credentials.mjs'
import { EGRESS_BIN } from '../vm/egress.mjs'
import { readJsonCached } from '../vm/vm-file.mjs'
import {
openSftp,
Expand All @@ -33,11 +35,22 @@ import {

export const SLOT_FILE_SETS = Object.freeze({
run: ['run/kernel.json', 'run/worker.json', 'run/internal.token'],
seed: ['cli-home/.claude/settings.json', 'cli-home/.claude/kin-seed.json', 'machine-id'],
seed: [
'cli-home/.claude/settings.json',
'cli-home/.claude/claude.setting',
'cli-home/.claude/kin-seed.json',
'machine-id',
],
touch: ['run/telemetry.touch'],
})
const CREDENTIALS = 'cli-home/.claude/credentials.json'
const TOUCH_PUSH_MS = 30_000
const SLOT_BINARIES = Object.freeze(['kin-worker', 'kin-egress'])

function clusterBin(envKey, fallback) {
const value = process.env[envKey]
return value && String(value).trim() ? String(value).trim() : fallback
}

/** Local vms/<id>/ path → node path: the home's `.claude` lives in the host-owned `claude/` mount. */
export function remoteSlotPath(rel) {
Expand All @@ -57,7 +70,7 @@ async function prepareRemoteDir(session, vm) {
const dir = remoteSlotDir(session.host, vm.id)
const owner = remoteSlotOwner(session.host, vm)
// Mount roots only: nothing here resolves through a path the guest can rewrite.
const roots = [`${dir}/run`, `${dir}/claude`, `${dir}/cli-home`].map(shellQuote).join(' ')
const roots = [`${dir}/run`, `${dir}/claude`, `${dir}/cli-home`, `${dir}/slot-bin`].map(shellQuote).join(' ')
// Docker creates a missing bind source as root:root, which the slot uid cannot write.
const chown = session.host.uid === 0 ? ` && chown ${owner.uid}:${owner.gid} ${shellQuote(dir)} ${roots}` : ''
await runRemote(session.client, `umask 077 && mkdir -p ${roots} && chmod 700 ${shellQuote(dir)} ${roots}${chown}`, {
Expand Down Expand Up @@ -97,6 +110,37 @@ export async function pushSlotFiles(vm, slotDir, sets = ['run', 'seed'], session
return { pushed }
}

/** Publish Go service payloads into the persistent per-slot mount. */
export async function pushSlotBinaries(vm, projectRoot, session = null) {
const s = session || (await nodeSession(vmNodeId(vm)))
await prepareRemoteDir(s, vm)
const sftp = await openSftp(s.client)
const remote = `${remoteSlotDir(s.host, vm.id)}/slot-bin`
const owner = ownerFor(s, vm)
const pushed = []
const digest = crypto.createHash('sha256')
for (const name of SLOT_BINARIES) {
const defaultBin = path.join(projectRoot, 'bin', name)
const local =
name === 'kin-worker'
? clusterBin('KIN_CLUSTER_WORKER_BIN', clusterBin('KIN_WORKER_BIN', defaultBin))
: clusterBin(
'KIN_CLUSTER_EGRESS_BIN',
clusterBin('KIN_EGRESS_BIN', fs.existsSync(EGRESS_BIN) ? EGRESS_BIN : defaultBin),
)
let data
try {
data = fs.readFileSync(local)
} catch {
continue
}
await writeRemoteFile(sftp, `${remote}/${name}`, data, { mode: 0o755, owner })
digest.update(name).update(data)
pushed.push(name)
}
return { pushed, digest: digest.digest('hex') }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cluster binary digest is out of scope

High Severity

pushSlotBinaries updates a digest hasher that is created only inside pushSlotFiles, so the first digest.update throws ReferenceError. Cluster start, reload, and the new telemetry fleet action all call this path, so Go payloads never publish and those flows fail immediately.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a7c3dfb. Configure here.

}

/** Explicit import only: replace the remote credential with the local one. */
export async function pushSlotCredentials(vm, slotDir, session = null) {
const s = session || (await nodeSession(vmNodeId(vm)))
Expand Down
Loading
Loading