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({
: ' 外部初装「同步遥测」关着,换票不会改写这里的遥测开关。'}
+
+