diff --git a/CHANGELOG.md b/CHANGELOG.md index fcfb184c0..24580629c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 生效。 diff --git a/deploy/install.sh b/deploy/install.sh index 07f55e41c..5c9761a66 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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}" @@ -655,6 +656,7 @@ start_stack() { cat "$log" rm -f "$log" wait_health || true + refresh_slot_services return fi cat "$log" @@ -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 @@ -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}" diff --git a/docker/kin-kvm/guest/kin-guest-setup b/docker/kin-kvm/guest/kin-guest-setup index 9f1790df8..441df0c05 100644 --- a/docker/kin-kvm/guest/kin-guest-setup +++ b/docker/kin-kvm/guest/kin-guest-setup @@ -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() { diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md index 968f15ac1..99ebe9c18 100644 --- a/docs/PANEL_API.md +++ b/docs/PANEL_API.md @@ -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` | @@ -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 健康探测缓存 | diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md new file mode 100644 index 000000000..4920ccdb0 --- /dev/null +++ b/docs/TELEMETRY.md @@ -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. diff --git a/scripts/fleet-services.mjs b/scripts/fleet-services.mjs new file mode 100644 index 000000000..adcfa2128 --- /dev/null +++ b/scripts/fleet-services.mjs @@ -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) diff --git a/scripts/panel-upgrade.sh b/scripts/panel-upgrade.sh index 4d2225df7..69c538793 100755 --- a/scripts/panel-upgrade.sh +++ b/scripts/panel-upgrade.sh @@ -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 diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 36bbfb877..19ff72aca 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -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, }), @@ -3559,6 +3571,8 @@ 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 || {} @@ -3566,10 +3580,32 @@ export function createPanelHandler(ctx) { 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, @@ -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, }), ) } diff --git a/src/lib/cluster/remote-slot-files.mjs b/src/lib/cluster/remote-slot-files.mjs index 10ef5f0d1..4e79def34 100644 --- a/src/lib/cluster/remote-slot-files.mjs +++ b/src/lib/cluster/remote-slot-files.mjs @@ -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, @@ -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// path → node path: the home's `.claude` lives in the host-owned `claude/` mount. */ export function remoteSlotPath(rel) { @@ -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}`, { @@ -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') } +} + /** 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))) diff --git a/src/lib/cluster/remote-slot.mjs b/src/lib/cluster/remote-slot.mjs index 92cce7e87..2e85879be 100644 --- a/src/lib/cluster/remote-slot.mjs +++ b/src/lib/cluster/remote-slot.mjs @@ -66,11 +66,18 @@ import { vmNodeId, } from './placement.mjs' import { openSftp, readRemoteFileNoFollow, runRemote, shellQuote, writeRemoteFile } from './remote-fs.mjs' -import { pushSlotFiles, reconcileSlotCredentials, remoteSlotPath, removeRemoteSlotDir } from './remote-slot-files.mjs' +import { + pushSlotBinaries, + pushSlotFiles, + reconcileSlotCredentials, + remoteSlotPath, + removeRemoteSlotDir, +} from './remote-slot-files.mjs' import { REMOTE_WORKER_BIN, slotImageSpec } from './slot-image.mjs' /** Bump when the slot's mount layout changes; a running container with an older label is recreated. */ -const SLOT_LAYOUT = '2' +const SLOT_LAYOUT = '3' +const LIVE_BIN = '/opt/kin-live' function failure(err, fallbackCode = 'remote_slot_failed') { return { ok: false, code: err?.code || fallbackCode, error: String(err?.message || err) } @@ -164,15 +171,22 @@ export function slotEgressNames(vmId) { * (kincli) could not open the root-owned file and crash-looped on EACCES. The * user is part of the digest so a helper created under the old spec is rebuilt. */ -export function remoteEgressContainer({ vmId, proxyId, dir, imageRef, body, host }) { +export function remoteEgressContainer({ vmId, proxyId, dir, imageRef, body, host, binaryDigest = '' }) { const user = `${host.uid}:${host.gid}` - const digest = crypto.createHash('sha256').update(body).update(imageRef).update(user).digest('hex').slice(0, 16) + const digest = crypto + .createHash('sha256') + .update(body) + .update(imageRef) + .update(user) + .update(binaryDigest) + .digest('hex') + .slice(0, 16) return { digest, spec: { Image: imageRef, User: user, - Cmd: ['/usr/local/bin/kin-egress', '-config', '/etc/kin-egress/egress.json'], + Cmd: [`${LIVE_BIN}/kin-egress`, '-config', '/etc/kin-egress/egress.json'], Labels: { 'kin.egress': '1', 'kin.egress.vm': vmId, @@ -182,7 +196,7 @@ export function remoteEgressContainer({ vmId, proxyId, dir, imageRef, body, host }, HostConfig: { NetworkMode: 'host', - Binds: [`${dir}:/etc/kin-egress:ro`], + Binds: [`${dir}:/etc/kin-egress:ro`, `${path.posix.join(dir, '..', 'slot-bin')}:${LIVE_BIN}:ro`], RestartPolicy: { Name: 'unless-stopped' }, ReadonlyRootfs: true, CapDrop: ['ALL'], @@ -207,7 +221,7 @@ export function canReuseRemoteEgress(existing, digest) { * kin-egress (host-network container from the slot image) + iptables REDIRECT, * exactly like egress.mjs does on the control plane. */ -export async function ensureRemoteEgress(session, vm, { imageRef }) { +export async function ensureRemoteEgress(session, vm, { imageRef, binaryDigest = '' }) { const proxy = vm.proxy const blocked = proxyBlockedReason(proxy) if (blocked) return { ok: false, code: blocked, error: blocked } @@ -229,6 +243,12 @@ export async function ensureRemoteEgress(session, vm, { imageRef }) { return { ok: false, code: 'egress_sudo_required', error: '节点需要 root 或免密 sudo 才能为 SOCKS5 出口写 iptables' } } const names = slotEgressNames(vm.id) + // Keep the helper's executable on the persistent mount even when egress is + // enabled independently of a slot start/update. + await runRemote( + session.client, + `umask 077 && mkdir -p ${shellQuote(`${remoteSlotDir(session.host, vm.id)}/slot-bin`)}`, + ) const net = await ensureRemoteNetwork(session.docker, { name: names.network, bridge: names.bridge, @@ -248,7 +268,15 @@ export async function ensureRemoteEgress(session, vm, { imageRef }) { const body = `${JSON.stringify(cfg, null, 2)}\n` // Beside the slot tree, outside every guest bind: the guest never sees the proxy password. const dir = `${remoteSlotDir(session.host, vm.id)}/egress` - const { digest, spec } = remoteEgressContainer({ vmId: vm.id, proxyId, dir, imageRef, body, host: session.host }) + const { digest, spec } = remoteEgressContainer({ + vmId: vm.id, + proxyId, + dir, + imageRef, + body, + host: session.host, + binaryDigest, + }) const existing = await inspectContainerOrNull(session.docker, names.egress) if (!canReuseRemoteEgress(existing, digest)) { await runRemote(session.client, `umask 077 && mkdir -p ${shellQuote(dir)} && chmod 700 ${shellQuote(dir)}`) @@ -289,7 +317,8 @@ export async function setRemoteProxyEgressEnabled(vm, projectRoot, enabled) { return { ok: true } } const image = slotImageSpec(projectRoot, vm.kernel || 'ubuntu-24.04') - return ensureRemoteEgress(session, vm, { imageRef: image.ref }) + const binaries = await pushSlotBinaries(vm, projectRoot, session) + return ensureRemoteEgress(session, vm, { imageRef: image.ref, binaryDigest: binaries.digest }) } for (const name of new Set([current, legacy])) { const existing = await inspectContainerOrNull(session.docker, name) @@ -448,6 +477,7 @@ function kvmSlotContainerBody(vm, { image, network, remoteDir, routing, accel, s `${remoteDir}/claude:/slot/claude`, `${remoteDir}/run:/slot/run`, `${remoteDir}/kvm:/slot/kvm`, + `${remoteDir}/slot-bin:/opt/kin-guest/opt/kin-live:ro`, ], NetworkMode: network, RestartPolicy: { Name: 'unless-stopped' }, @@ -508,6 +538,7 @@ export function slotContainerBody(vm, { image, network, remoteDir, user, routing `${remoteDir}/run:/run/kin`, `${remoteDir}/machine-id:/etc/machine-id:ro`, `${remoteDir}/machine-id:/var/lib/dbus/machine-id:ro`, + `${remoteDir}/slot-bin:${LIVE_BIN}:ro`, ], NetworkMode: network, RestartPolicy: { Name: 'unless-stopped' }, @@ -741,7 +772,8 @@ export function codexRoleMatches(info, vm) { async function startRemoteTelemetry(docker, vm, name) { const target = { ...vm, runtime: { ...(vm.runtime || {}), container: name, type: runtimeKind(vm) } } - const { Cmd, User } = slotExecCmd(target, [REMOTE_WORKER_BIN, 'telemetry', '--config', '/run/kin/worker.json'], { + const worker = isKvmRuntime(vm) ? '/opt/kin-payload/opt/kin-live/kin-worker' : REMOTE_WORKER_BIN + const { Cmd, User } = slotExecCmd(target, [worker, 'telemetry', '--config', '/run/kin/worker.json'], { detach: true, }) await execDetached(docker, name, Cmd, { user: User }).catch(() => {}) @@ -766,7 +798,9 @@ export async function startRemoteSlot(vm, projectRoot, { recreate = false, routi if (!probe.ok) return { ok: false, code: 'kvm_unavailable', error: probe.error } accel = probe.accel } - const eg = await ensureRemoteEgress(session, vm, { imageRef: egressImage || image }) + const binaries = + typeof pushSlotBinaries === 'function' ? await pushSlotBinaries(vm, projectRoot, session) : { digest: '' } + const eg = await ensureRemoteEgress(session, vm, { imageRef: egressImage || image, binaryDigest: binaries.digest }) if (!eg.ok) return eg const name = containerName(vm.id) const relays = await clusterManager().ensureSlotRelays(nodeId, vm.id) @@ -844,7 +878,12 @@ export async function reloadRemoteSlot(vm, projectRoot, { routing } = {}) { const p = await prepare(vm, projectRoot, routing) if (p.fail) return p.fail const { nodeId, session, image, user, remoteDir, slotDir } = p - const eg = await ensureRemoteEgress(session, vm, { imageRef: p.egressImage || image }) + const binaries = + typeof pushSlotBinaries === 'function' ? await pushSlotBinaries(vm, projectRoot, session) : { digest: '' } + const eg = await ensureRemoteEgress(session, vm, { + imageRef: p.egressImage || image, + binaryDigest: binaries.digest, + }) if (!eg.ok) return eg const name = containerName(vm.id) const existing = await inspectContainerOrNull(session.docker, name) @@ -896,6 +935,39 @@ export async function reloadRemoteSlot(vm, projectRoot, { routing } = {}) { } } +/** Update a node slot's Go services without recreating its VM/container. */ +export async function restartRemoteServices(vm, projectRoot, { telemetryEnabled } = {}) { + try { + const session = await nodeSession(vmNodeId(vm)) + const remoteDir = remoteSlotDir(session.host, vm.id) + const slotDir = path.join(projectRoot, 'vms', vm.id) + const name = vm.runtime?.container || containerName(vm.id) + const binaries = await pushSlotBinaries(vm, projectRoot, session) + await pushSlotFiles(vm, slotDir, ['run', 'seed'], session) + const target = { ...vm, runtime: { ...(vm.runtime || {}), container: name, type: runtimeKind(vm) } } + const stop = 'pkill -TERM -x kin-worker 2>/dev/null || true' + await execDetached(session.docker, name, slotExecCmd(target, ['sh', '-c', stop]).Cmd, { + user: slotExecCmd(target, ['sh', '-c', stop]).User, + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + if (telemetryEnabled === true) await startRemoteTelemetry(session.docker, vm, name) + const image = slotImageSpec(projectRoot, vm.kernel || 'ubuntu-24.04') + const egress = await ensureRemoteEgress(session, vm, { + imageRef: image.ref, + binaryDigest: binaries.digest, + }) + if (!egress.ok) return { ok: false, error: egress.error, code: egress.code || 'egress_update_failed' } + return { + ok: true, + action: telemetryEnabled === true ? 'restarted' : 'stopped', + remote_dir: remoteDir, + binary_digest: binaries.digest || null, + } + } catch (err) { + return failure(err, 'service_update_failed') + } +} + export async function stopRemoteSlot(vm) { try { const session = await nodeSession(vmNodeId(vm)) diff --git a/src/lib/cluster/slot-image.mjs b/src/lib/cluster/slot-image.mjs index 9f0d42255..d5cf0d088 100644 --- a/src/lib/cluster/slot-image.mjs +++ b/src/lib/cluster/slot-image.mjs @@ -27,7 +27,8 @@ import { codexKernelBinPath } from '../transport/codex-kernel-supervisor.mjs' import { ClusterError } from './ssh-link.mjs' import { tarStream } from './tar.mjs' -export const REMOTE_WORKER_BIN = '/usr/local/bin/kin-worker' +// Service binaries are supplied by the persistent per-slot slot-bin mount. +export const REMOTE_WORKER_BIN = '/opt/kin-live/kin-worker' function binOr(envKey, fallback) { const env = String(process.env[envKey] || '').trim() diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs index 6b60f9d0c..4d8de1743 100644 --- a/src/lib/protocol/seed-policy.mjs +++ b/src/lib/protocol/seed-policy.mjs @@ -30,6 +30,16 @@ export function isTelemetryEnabled(pol = {}) { */ export const NONESSENTIAL_TRAFFIC_ENV_KEY = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +// Keep a visible, editable copy of the Claude opt-out setting next to the +// generated settings.json. The file is intentionally plain text so operators +// can inspect or replace it without having to edit JSON. +export const DEFAULT_CLAUDE_SETTING = `${NONESSENTIAL_TRAFFIC_ENV_KEY}=1` + +export function normalizeClaudeSetting(value) { + if (typeof value !== 'string') return DEFAULT_CLAUDE_SETTING + return value.replace(/\r\n?/g, '\n').replace(/\n+$/g, '') +} + export const TELEMETRY_KILL_ENV = Object.freeze({ DISABLE_TELEMETRY: '1', }) @@ -141,6 +151,7 @@ export function defaultSeedPolicy(partial = {}) { reject_client_metadata_identity: partial.reject_client_metadata_identity !== false, theme: partial.theme || 'dark', extra_env: partial.extra_env && typeof partial.extra_env === 'object' ? partial.extra_env : {}, + claude_setting: normalizeClaudeSetting(partial.claude_setting), settings_json_override: partial.settings_json_override && typeof partial.settings_json_override === 'object' ? partial.settings_json_override diff --git a/src/lib/vm/fleet-update.mjs b/src/lib/vm/fleet-update.mjs index afdd70fea..b9f2ed5ef 100644 --- a/src/lib/vm/fleet-update.mjs +++ b/src/lib/vm/fleet-update.mjs @@ -8,8 +8,46 @@ import { runtimeKind } from './runtime-kind.mjs' import { reloadSlotReady, slotExec } from './slot-runtime.mjs' import { collectSlotIdentity } from './guest-identity.mjs' import { rustKernelHealth } from '../transport/rust-kernel-client.mjs' +import { restartTelemetrySidecar } from './slot-process-status.mjs' +import { vmNodeId } from '../cluster/placement.mjs' +import { restartRemoteServices } from '../cluster/remote-slot.mjs' +import { + ensureProxyEgress, + inspectEgressProcess, + isLocalEgressProxy, + stopEgressProcess, + waitEgressExit, + waitPortReleased, +} from './egress.mjs' -const ACTIONS = new Set(['collect', 'reload', 'roll']) +const ACTIONS = new Set(['collect', 'reload', 'roll', 'telemetry']) + +async function restartLocalServices({ projectRoot, vm, enabled }) { + const telemetry = await restartTelemetrySidecar({ projectRoot, vm, enabled }) + if (!telemetry.ok) return telemetry + if (!isLocalEgressProxy(vm.proxy)) { + const proxyId = vm.proxy?.id + const running = inspectEgressProcess(projectRoot, proxyId) + const stopped = stopEgressProcess(projectRoot, proxyId) + if (!stopped.ok) return { ok: false, action: 'egress_stop_failed', error: stopped.error } + if (running.ok && running.pid) { + const exited = await waitEgressExit(running.pid) + if (!exited.ok) return { ok: false, action: 'egress_stop_failed', error: exited.error } + const spec = String(running.listen_tcp || '') + const colon = spec.lastIndexOf(':') + const host = colon > 0 ? spec.slice(0, colon) : '' + const port = colon > 0 ? Number(spec.slice(colon + 1)) : 0 + if (host && Number.isFinite(port) && port > 0) { + const portFree = await waitPortReleased(host, port) + if (!portFree.ok) return { ok: false, action: 'egress_stop_failed', error: portFree.error } + } + } + stopEgressProcess(projectRoot, proxyId) + const started = await ensureProxyEgress(projectRoot, vm.proxy) + if (!started?.ok) return { ok: false, action: 'egress_start_failed', error: started?.error || 'kin-egress failed' } + } + return { ...telemetry, egress: 'restarted' } +} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -64,6 +102,8 @@ export async function updateOneSlot( reloadFn = reloadSlotReady, collectFn = collectSlotIdentity, healthFn = rustKernelHealth, + telemetryFn, + telemetryEnabled, readyTimeoutMs = 15000, } = {}, ) { @@ -89,6 +129,22 @@ export async function updateOneSlot( return result } } + if (action === 'telemetry') { + const enabled = + telemetryEnabled === undefined ? vm.seed_policy?.telemetry_disabled === false : telemetryEnabled === true + const vmRef = getVm(projectRoot, id) || vm + const changed = telemetryFn + ? await telemetryFn({ projectRoot, vm: vmRef, telemetryEnabled: enabled, enabled }) + : vmNodeId(vm) + ? await restartRemoteServices(vmRef, projectRoot, { telemetryEnabled: enabled }) + : await restartLocalServices({ projectRoot, vm: vmRef, enabled }) + result.telemetry = changed + if (!changed?.ok) { + result.error = changed?.error || 'telemetry service update failed' + result.code = changed?.code || 'telemetry_update_failed' + return result + } + } if (action === 'collect' || action === 'roll') { const collected = await collectFn(projectRoot, getVm(projectRoot, id) || vm) result.collect = collected @@ -108,7 +164,18 @@ export async function updateOneSlot( export async function runFleetUpdate( projectRoot, - { action = 'roll', ids = null, concurrency = 4, routing = {}, reloadFn, collectFn, healthFn, readyTimeoutMs } = {}, + { + action = 'roll', + ids = null, + concurrency = 4, + routing = {}, + reloadFn, + collectFn, + healthFn, + telemetryFn, + telemetryEnabled, + readyTimeoutMs, + } = {}, ) { const nextAction = ACTIONS.has(action) ? action : 'roll' const conc = Math.max(1, Math.min(8, Number(concurrency) || 4)) @@ -120,6 +187,8 @@ export async function runFleetUpdate( reloadFn, collectFn, healthFn, + telemetryFn, + telemetryEnabled, readyTimeoutMs, }), ) diff --git a/src/lib/vm/slot-process-status.mjs b/src/lib/vm/slot-process-status.mjs index 3e48b2173..f3c27882d 100644 --- a/src/lib/vm/slot-process-status.mjs +++ b/src/lib/vm/slot-process-status.mjs @@ -9,6 +9,7 @@ import { runtimeKind } from './runtime-kind.mjs' import { slotExecArgv } from './slot-exec.mjs' const exec = promisify(execFile) +export const SLOT_WORKER_BIN = '/usr/local/bin/kin-worker' const PROBE = String.raw` set -eu main=$(tr '\000' '\n' < /proc/1/cmdline | sed -n '1p') @@ -86,7 +87,7 @@ export async function ensureTelemetrySidecar({ projectRoot, vm, run = exec, time const target = { ...vm, runtime: { ...(vm.runtime || {}), container: slotContainerName({ vm }), type: kind } } await run( 'docker', - slotExecArgv(target, ['/usr/local/bin/kin-worker', 'telemetry', '--config', '/run/kin/worker.json'], { + slotExecArgv(target, [SLOT_WORKER_BIN, 'telemetry', '--config', '/run/kin/worker.json'], { detach: true, }), { timeout: timeoutMs, maxBuffer: 4096, encoding: 'utf8', ...(env ? { env } : {}) }, @@ -96,3 +97,35 @@ export async function ensureTelemetrySidecar({ projectRoot, vm, run = exec, time return { ok: false, action: 'start_failed', error: String(error?.message || error) } } } + +/** Replace only the Go telemetry child; the slot container and Rust process stay up. */ +export async function restartTelemetrySidecar({ projectRoot, vm, enabled, run = exec, timeoutMs = 15_000 } = {}) { + if (!projectRoot || !isSafeSlotId(vm?.id)) return { ok: false, action: 'invalid_vm' } + const kind = runtimeKind(vm) + if (kind !== 'docker' && kind !== 'kvm') return { ok: false, action: 'unsupported_runtime' } + const env = slotHost(vm).dockerEnv() + const target = { ...vm, runtime: { ...(vm.runtime || {}), container: slotContainerName({ vm }), type: kind } } + const stop = 'pkill -TERM -x kin-worker 2>/dev/null || true' + try { + await run('docker', slotExecArgv(target, ['sh', '-c', stop]), { + timeout: timeoutMs, + maxBuffer: 4096, + encoding: 'utf8', + ...(env ? { env } : {}), + }) + if (enabled !== true) return { ok: true, action: 'stopped' } + await run( + 'docker', + slotExecArgv(target, [SLOT_WORKER_BIN, 'telemetry', '--config', '/run/kin/worker.json'], { detach: true }), + { + timeout: timeoutMs, + maxBuffer: 4096, + encoding: 'utf8', + ...(env ? { env } : {}), + }, + ) + return { ok: true, action: 'restarted' } + } catch (error) { + return { ok: false, action: 'restart_failed', error: String(error?.message || error) } + } +} diff --git a/src/lib/vm/slot-seed.mjs b/src/lib/vm/slot-seed.mjs index bcf43d304..f4cd28e70 100644 --- a/src/lib/vm/slot-seed.mjs +++ b/src/lib/vm/slot-seed.mjs @@ -46,10 +46,19 @@ export function writeSlotSeedFiles(projectRoot, vm, seedPolicy = null) { cli_version: OFFICIAL_CLI_VERSION, }) fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify(settings, null, 2)) + fs.writeFileSync(path.join(claudeDir, 'claude.setting'), `${pol.claude_setting}\n`, { mode: 0o600 }) fs.writeFileSync(path.join(claudeDir, 'kin-seed.json'), JSON.stringify(seedDoc, null, 2)) let telemetry = { wrote: false } try { telemetry = syncWorkerTelemetry({ ...vm, seed_policy: pol }, projectRoot) } catch {} - return { wrote: true, homeDir, seed_policy: pol, settings, kin_seed: seedDoc, telemetry } + return { + wrote: true, + homeDir, + seed_policy: pol, + settings, + claude_setting: pol.claude_setting, + kin_seed: seedDoc, + telemetry, + } } diff --git a/test/unit/fleet-update.test.mjs b/test/unit/fleet-update.test.mjs index 9a1e4d97e..2228cff21 100644 --- a/test/unit/fleet-update.test.mjs +++ b/test/unit/fleet-update.test.mjs @@ -81,6 +81,25 @@ test('degraded worker without OAuth still counts as up', () => { assert.equal(isWorkerProcessUp({ code: 'worker_unavailable' }), false) }) +test('fleet telemetry uses injected telemetryFn with options object', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-fleet-tel-')) + writeSlot(root, 'vm-01', { node_id: 'node-a' }) + const calls = [] + const report = await runFleetUpdate(root, { + action: 'telemetry', + telemetryFn: async (opts) => { + calls.push(opts) + return { ok: true, action: 'restarted' } + }, + }) + assert.equal(report.ok_count, 1) + assert.equal(calls.length, 1) + assert.equal(calls[0].vm.id, 'vm-01') + assert.equal(calls[0].projectRoot, root) + assert.equal(calls[0].enabled, calls[0].telemetryEnabled) + fs.rmSync(root, { recursive: true, force: true }) +}) + test('fleet collect skips reload', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-fleet-c-')) writeSlot(root, 'vm-01') diff --git a/test/unit/panel-upgrade-script.test.mjs b/test/unit/panel-upgrade-script.test.mjs index 66dac37cd..3728bed73 100644 --- a/test/unit/panel-upgrade-script.test.mjs +++ b/test/unit/panel-upgrade-script.test.mjs @@ -24,7 +24,9 @@ if (args[0] === 'compose') { } else if (args[0] === 'inspect') { console.log(process.env.SAME_CONTAINER === '1' ? 'old-id' : 'new-id') } else if (args[0] === 'exec') { - const result = spawnSync(process.execPath, args.slice(3), { stdio: 'inherit' }) + const cmd = args.slice(3) + if (cmd.some((part) => String(part).includes('fleet-services.mjs'))) process.exit(0) + const result = spawnSync(process.execPath, cmd, { stdio: 'inherit' }) process.exit(result.status ?? 1) } else { process.exit(2) } ` diff --git a/test/unit/seed-policy.test.mjs b/test/unit/seed-policy.test.mjs index 692e6a791..390110c1d 100644 --- a/test/unit/seed-policy.test.mjs +++ b/test/unit/seed-policy.test.mjs @@ -7,8 +7,14 @@ import { buildSeedSettingsEnv, stripLegacyScriptEnv, LEGACY_SCRIPT_ENV_KEYS, + DEFAULT_CLAUDE_SETTING, } from '../../src/lib/protocol/seed-policy.mjs' +test('default seed policy includes the editable claude setting', () => { + assert.equal(defaultSeedPolicy().claude_setting, DEFAULT_CLAUDE_SETTING) + assert.equal(defaultSeedPolicy({ claude_setting: 'FOO=bar\r\n' }).claude_setting, 'FOO=bar') +}) + test('explicit opt-in removes telemetry kill switches; omitted policy retains them', () => { assert.equal(buildSlotSettingsEnv(standardSeedPolicy()).DISABLE_TELEMETRY, '1') assert.equal(buildSlotSettingsEnv(standardSeedPolicy({ telemetry_disabled: false })).DISABLE_TELEMETRY, undefined) diff --git a/test/unit/slot-seed.test.mjs b/test/unit/slot-seed.test.mjs index 7fa801ac8..0dbcacb3c 100644 --- a/test/unit/slot-seed.test.mjs +++ b/test/unit/slot-seed.test.mjs @@ -42,6 +42,7 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => const out = writeSlotSeedFiles(root, vm) assert.equal(out.wrote, true) const settings = JSON.parse(fs.readFileSync(path.join(leftover, 'settings.json'), 'utf8')) + const claudeSetting = fs.readFileSync(path.join(leftover, 'claude.setting'), 'utf8') const seed = JSON.parse(fs.readFileSync(path.join(leftover, 'kin-seed.json'), 'utf8')) assert.equal(settings.env.DISABLE_TELEMETRY, undefined) assert.equal(settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1') @@ -52,6 +53,8 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => assert.equal(settings.env.TZ, 'America/Los_Angeles') assert.equal(settings.autoUpdates, false) assert.equal(settings.grove_enabled, false) + assert.equal(claudeSetting, 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n') + assert.equal(out.claude_setting, 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1') assert.equal(seed.seed_policy.grove_enabled, false) assert.equal(seed.seed_policy.disable_nonessential_traffic, true) assert.equal(seed.schema, KIN_SEED_SCHEMA) @@ -63,6 +66,20 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => fs.rmSync(root, { recursive: true, force: true }) }) +test('writeSlotSeedFiles preserves an editable claude.setting', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-slot-setting-')) + const vm = { + id: 'vm-setting', + seed_policy: standardSeedPolicy({ claude_setting: 'FOO=bar\r\nBAZ=qux\n' }), + } + writeSlotSeedFiles(root, vm) + assert.equal( + fs.readFileSync(path.join(root, 'vms', vm.id, 'cli-home', '.claude', 'claude.setting'), 'utf8'), + 'FOO=bar\nBAZ=qux\n', + ) + fs.rmSync(root, { recursive: true, force: true }) +}) + test('applySeedAfterOfficialInit uses the same writer', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-init-seed-')) const vm = { diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx index fd9175b54..2ff1573f9 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -3,31 +3,38 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { telemetryEnabled, telemetrySeedFlags, } from '@/features/vm/telemetry-policy' +export const DEFAULT_CLAUDE_SETTING = + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1' + const REJECT_FLAGS: [string, string][] = [ ['reject_client_settings', '拒客户端 settings'], ['reject_client_metadata_identity', '拒客户端身份'], ] -type SeedPolicy = Record +type SeedPolicy = Record export const SEED_PRESETS: Record = { standard: { ...telemetrySeedFlags(false), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: true, reject_client_metadata_identity: true, }, open: { ...telemetrySeedFlags(true), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: false, reject_client_metadata_identity: false, }, strict: { ...telemetrySeedFlags(false), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: true, reject_client_metadata_identity: true, }, @@ -62,6 +69,10 @@ export function matchSeedPreset(policy: Record): string { function normalize(policy: Record): SeedPolicy { const out: SeedPolicy = { ...telemetrySeedFlags(telemetryEnabled(policy)) } + out.claude_setting = + typeof policy.claude_setting === 'string' + ? policy.claude_setting + : DEFAULT_CLAUDE_SETTING for (const [k] of REJECT_FLAGS) { out[k] = k in policy ? !!policy[k] : !!SEED_PRESETS.standard[k] } @@ -91,7 +102,8 @@ export function SeedPolicyCard({ const saved = normalize(policy) const dirty = telemetryEnabled(draft) !== telemetryEnabled(saved) || - REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) + REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) || + draft.claude_setting !== saved.claude_setting const on = telemetryEnabled(draft) return ( @@ -169,6 +181,27 @@ export function SeedPolicyCard({ : ' 外部初装「同步遥测」关着,换票不会改写这里的遥测开关。'}

+
+ +