diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..629754c59 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,38 @@ +# Product + + + +## Platform + +web + +## Users + +本机管理员。在深色运维控制台里打开某个虚拟机槽位,核对并改写该槽 Claude Code 的种子配置。 + +## Product Purpose + +vm2api 把 Claude / Codex 账号做成可调度的虚拟机槽,并对外提供兼容上游的 API。种子页的成功标准:管理员不用进容器,就能看见当前槽 `settings.json` 里实际生效的配置项,改完能播种,并实时看到遥测开或关。 + +## Positioning + +种子不是一份预设表单。它读当前槽家目录里已经写好的 Claude 配置,按文件内容逐项显示,再把改动写回同一份文件。 + +## Operating Context + +管理员在 `/vm/$id` 的「种子」页签工作。数据来自该槽 `cli-home/.claude/settings.json`、`claude.setting` 和种子策略。遥测进程状态来自槽详情里的实时观测。 + +## Capabilities and Constraints + +- 显示当前槽 `settings.json` 的真实键值,而不是只显示种子策略里的默认串。 +- `claude.setting` 以可编辑文本提供,保存时写回并重新播种。 +- 遥测开、关状态实时刷新;配置开关和进程是否在跑是两件事。 +- 「拒客户端 settings」和「拒客户端身份」按当前策略准确显示,并可切换。 +- 平台是现有 React 面板,不另起一套视觉系统。 + +## Product Principles + +- 槽内文件是事实,表单默认值不是。 +- 开和关都要能被看见,未知不能伪装成关闭。 +- 能改的项和只读观测分开。 +- 播种是显式动作,不在切换开关时偷偷写盘。 diff --git a/deploy/install.sh b/deploy/install.sh index 07f55e41c..571661b45 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,21 @@ start_stack() { exit 1 } +refresh_slot_services() { + # The control-plane image carries the freshly built Go binaries. Publish them + # into persistent slot-bin mounts and restart only kin-worker/kin-egress. + # A stale kin-egress can keep the old binary and its listen port after this + # refresh. Leaving that as a warning reports the upgrade done while the old + # exit is still serving traffic. + if ! docker exec "$SERVICE_NAME" test -f /opt/vm2api/scripts/fleet-services.mjs >/dev/null 2>&1; then + err "控制面缺少 Go 服务刷新脚本,不能确认 kin-egress 已换到当前二进制" + exit 1 + elif ! docker exec "$SERVICE_NAME" node /opt/vm2api/scripts/fleet-services.mjs; then + err "Go slot service refresh failed; VM containers were left untouched, but kin-egress may still be the previous binary" + exit 1 + fi +} + print_banner() { echo "" echo -e "${CYAN}==============================================${NC}" 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 2390218dc..aa031469a 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..f9bdf716c --- /dev/null +++ b/docs/TELEMETRY.md @@ -0,0 +1,43 @@ +# VM telemetry service + +Telemetry is an explicit seed-policy opt-in. A newly created VM is disabled; +setting `telemetry_disabled` to `false` writes the VM's `worker.json` identity +and starts the Go `kin-worker telemetry` service. Setting it back to `true` +stops that child process and leaves the VM and its Rust kernel running. + +The service reads credentials only from the VM's own Claude home. Its feature +payload uses facts collected from that VM (including its timezone, OS, +hardware, shell and runtime versions), never controller-host defaults or a +synthetic fingerprint. Requests leave through the VM's bound egress, so the +service observes the VM's actual network address. Uploads are periodic and are idle-gated +by `run/telemetry.touch`. + +`claude.setting` is a plain, editable seed file beside `settings.json` and is +initialized with: + +```text +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +``` + +## Service-only updates + +Go payloads are copied atomically into each slot's persistent `slot-bin` +directory. Cluster containers bind that directory at `/opt/kin-live` (and the +KVM guest payload at `/opt/kin-live`). The panel/fleet `telemetry` action +restarts only the telemetry child and the `kin-egress` helper; it does not +rebuild a VM, replace `vms/`, rotate credentials, or restart the Rust kernel. + +An update must verify the new binary hash and the replacement process before +reporting success. A failed service restart is reported per slot so the old VM +identity remains intact and can be rolled back by republishing the previous +binary. + +For a controlled rollout, run `scripts/migrate-legacy-cluster-slots.mjs` in +the control-plane container. Pass VM IDs after the root and concurrency +arguments to limit the rollout. It performs the same migration used by the +one-click installer: legacy slot and egress containers are stopped, renamed to +`*-legacy-`, and kept as stopped rollback copies; replacement +containers use the persistent `/opt/kin-live` mount. Container IDs therefore +change, but the VM, credentials, `vms/`, routing, and network identity do not. +After this one-time migration, subsequent updates atomically replace only the +Go binaries in `slot-bin` and restart the service processes. 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/migrate-legacy-cluster-slots.mjs b/scripts/migrate-legacy-cluster-slots.mjs new file mode 100644 index 000000000..3f5a275de --- /dev/null +++ b/scripts/migrate-legacy-cluster-slots.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/** + * Migrate legacy cluster slot containers to the persistent Go-service mount. + * This never rebuilds a VM: the old container is renamed and retained as a + * stopped rollback copy, while the replacement reuses its image and settings. + */ +import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs' + +const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api' +const concurrency = Number(process.argv[3] || process.env.KIN_FLEET_CONCURRENCY || 4) +const ids = process.argv + .slice(4) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean) + +const report = await runFleetUpdate(root, { + action: 'telemetry', + concurrency, + ids: ids.length ? ids : null, +}) +console.log(JSON.stringify(report)) +if (report.failed_count) process.exitCode = 1 diff --git a/scripts/panel-upgrade.sh b/scripts/panel-upgrade.sh index 4d2225df7..65fa1bb1c 100755 --- a/scripts/panel-upgrade.sh +++ b/scripts/panel-upgrade.sh @@ -138,6 +138,15 @@ attempt=0 while [ "$attempt" -lt 45 ]; do if probe verify "$TAG" "$BASELINE"; then [ -d .git ] || printf '%s\n' "${TAG#v}" > VERSION + # Replace the Go worker/egress payloads in-place; no VM/container rebuild. + # Older control-plane images may not carry the helper yet; retry on the + # next image update instead of invalidating an otherwise verified upgrade. + if ! docker exec "$CONTAINER" test -f /opt/vm2api/scripts/fleet-services.mjs >/dev/null 2>&1; then + echo "Go service refresh helper is not present in the control plane; retry after the next image update" >&2 + elif ! docker exec "$CONTAINER" node /opt/vm2api/scripts/fleet-services.mjs; then + echo "Go service-only update failed; inspect the per-slot report" >&2 + exit 1 + fi echo "Control plane restarted and verified at $TAG" echo "Previously healthy slot kernels verified: $BASELINE" exit 0 diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs index 1351ee257..4a622e112 100644 --- a/src/lib/admin/panel-api.mjs +++ b/src/lib/admin/panel-api.mjs @@ -475,6 +475,12 @@ export function validatePersonaRoutingPatch(body = {}) { if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') { problems.push('cch_enabled 必须是布尔') } + if (Object.prototype.hasOwnProperty.call(compat, 'auto_compact_window') && compat.auto_compact_window != null) { + const windowTokens = Number(compat.auto_compact_window) + if (!Number.isInteger(windowTokens) || windowTokens < 100_000 || windowTokens > 1_000_000) { + problems.push('auto_compact_window 必须是 100000–1000000 的整数') + } + } if ( compat.thinking_display != null && compat.thinking_display !== '' && @@ -1406,6 +1412,9 @@ function presentCompatibility(compat) { .trim() .toLowerCase() out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized' + const windowTokens = Number(out.auto_compact_window) + out.auto_compact_window = + Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null return out } diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 36bbfb877..e2fb1027f 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 { + // Missing file stays null. The policy default is not this slot's file. + } try { kin_seed = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'kin-seed.json'), 'utf8')) } catch {} + const seed_policy = defaultSeedPolicy(vm.seed_policy || {}) + if (claude_setting !== null) seed_policy.claude_setting = claude_setting return json( res, 200, panel.ok({ vm_id: id, - seed_policy: defaultSeedPolicy(vm.seed_policy || {}), - ...seedTelemetryContract(defaultSeedPolicy(vm.seed_policy || {})), + seed_policy, + ...seedTelemetryContract(seed_policy), settings_json, + claude_setting, kin_seed, cli_home: homeDir, }), @@ -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,18 @@ 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 } return json( res, @@ -3579,7 +3601,9 @@ export function createPanelHandler(ctx) { seed_policy: next, ...seedTelemetryContract(next), settings_json: written.settings || null, + claude_setting: written.claude_setting || '', kin_seed: written.kin_seed || null, + service, }), ) } diff --git a/src/lib/cluster/docker-remote.mjs b/src/lib/cluster/docker-remote.mjs index c53849a51..3c52116a0 100644 --- a/src/lib/cluster/docker-remote.mjs +++ b/src/lib/cluster/docker-remote.mjs @@ -328,6 +328,16 @@ export async function removeContainer(connect, id) { await dockerJson(connect, { method: 'DELETE', path: `${containerPath(id)}?force=1&v=1`, timeoutMs: 60_000 }, [204]) } +/** Preserve the container object for a rollback instead of deleting it. */ +export async function renameContainer(connect, id, name) { + if (!NAME_RE.test(String(name || ''))) throw new ClusterError(400, 'invalid_name', '容器名不合法') + await dockerJson( + connect, + { method: 'POST', path: `${containerPath(id)}/rename?name=${encodeURIComponent(name)}`, timeoutMs: 30_000 }, + [204], + ) +} + export async function containerLogs(connect, id, { tail = 200 } = {}) { const n = Math.min(5000, Math.max(1, Number.parseInt(tail, 10) || 200)) const inspect = await dockerJson(connect, { path: `${containerPath(id)}/json` }) diff --git a/src/lib/cluster/remote-slot-files.mjs b/src/lib/cluster/remote-slot-files.mjs index 10ef5f0d1..6cd44e702 100644 --- a/src/lib/cluster/remote-slot-files.mjs +++ b/src/lib/cluster/remote-slot-files.mjs @@ -18,6 +18,7 @@ */ 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' @@ -33,11 +34,17 @@ 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']) /** Local vms// path → node path: the home's `.claude` lives in the host-owned `claude/` mount. */ export function remoteSlotPath(rel) { @@ -57,7 +64,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}`, { @@ -82,6 +89,7 @@ export async function pushSlotFiles(vm, slotDir, sets = ['run', 'seed'], session const remote = remoteSlotDir(s.host, vm.id) const owner = ownerFor(s, vm) const pushed = [] + const digest = crypto.createHash('sha256') for (const rel of sets.flatMap((name) => SLOT_FILE_SETS[name] || [])) { const local = path.join(slotDir, rel) let data @@ -97,6 +105,31 @@ 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 local = + process.env[name === 'kin-worker' ? 'KIN_WORKER_BIN' : 'KIN_EGRESS_BIN'] || path.join(projectRoot, 'bin', name) + 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..9ca2b9387 100644 --- a/src/lib/cluster/remote-slot.mjs +++ b/src/lib/cluster/remote-slot.mjs @@ -54,6 +54,7 @@ import { inspectNetworkOrNull, removeContainer, removeNetwork, + renameContainer, } from './docker-remote.mjs' import { awaitSlotImageBuild, @@ -66,16 +67,127 @@ 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) } } +const MIGRATION_CONFIG_KEYS = Object.freeze([ + 'Hostname', 'Domainname', 'User', 'AttachStdin', 'AttachStdout', 'AttachStderr', 'Tty', 'OpenStdin', + 'StdinOnce', 'Env', 'Cmd', 'Image', 'Volumes', 'WorkingDir', 'Entrypoint', 'Labels', 'StopSignal', + 'StopTimeout', 'Shell', 'ExposedPorts', +]) +const MIGRATION_HOST_CONFIG_KEYS = Object.freeze([ + 'Binds', 'LogConfig', 'NetworkMode', 'PortBindings', 'PublishAllPorts', 'Privileged', 'ReadonlyRootfs', + 'Dns', 'DnsOptions', 'DnsSearch', 'ExtraHosts', 'GroupAdd', 'IpcMode', 'Links', 'Memory', 'MemorySwap', + 'MemoryReservation', 'MemorySwappiness', 'NanoCpus', 'CpuShares', 'CpuQuota', 'CpuPeriod', 'CpusetCpus', + 'CpusetMems', 'PidsLimit', 'RestartPolicy', 'SecurityOpt', 'ShmSize', 'UTSMode', 'UsernsMode', 'PidMode', + 'Devices', 'DeviceCgroupRules', 'Init', 'OomKillDisable', 'OomScoreAdj', 'Runtime', 'Sysctls', 'Tmpfs', + 'VolumesFrom', 'CgroupnsMode', +]) + +function bindDestination(bind) { + const parts = String(bind || '').split(':') + return parts.length >= 2 ? parts[1] : '' +} + +function replaceBindMount(binds, source, destination, removeDestinations = []) { + const remove = new Set([destination, ...removeDestinations]) + const next = (binds || []).filter((bind) => !remove.has(bindDestination(bind))) + next.push(`${source}:${destination}:ro`) + return next +} + +/** Build a replacement body from inspect data without changing VM-owned state. */ +export function legacyServiceContainerBody( + info, + { name, sourceDir, destination = LIVE_BIN, removeDestinations = [], command, layout } = {}, +) { + const body = {} + for (const key of MIGRATION_CONFIG_KEYS) { + if (info?.Config && info.Config[key] !== undefined) body[key] = structuredClone(info.Config[key]) + } + body.Image = info?.Config?.Image + body.Labels = { ...(info?.Config?.Labels || {}) } + if (layout) body.Labels['kin.vm.layout'] = SLOT_LAYOUT + if (command) body.Cmd = command + const host = info?.HostConfig || {} + body.HostConfig = {} + for (const key of MIGRATION_HOST_CONFIG_KEYS) { + if (host[key] !== undefined) body.HostConfig[key] = structuredClone(host[key]) + } + body.HostConfig.Binds = replaceBindMount(host.Binds, sourceDir, destination, removeDestinations) + const networkMode = String(host.NetworkMode || '') + const endpoint = info?.NetworkSettings?.Networks?.[networkMode] + if (endpoint && networkMode && !['host', 'none', 'bridge', 'default'].includes(networkMode)) { + body.NetworkingConfig = { + EndpointsConfig: { + [networkMode]: { + ...(endpoint.IPAddress ? { IPAMConfig: { IPv4Address: endpoint.IPAddress } } : {}), + ...(endpoint.Aliases ? { Aliases: endpoint.Aliases } : {}), + }, + }, + } + } + return body +} + +function migrationBackupName(name, stamp = Date.now()) { + const suffix = `-legacy-${stamp}` + return `${String(name).slice(0, Math.max(1, 63 - suffix.length))}${suffix}` +} + +function hasBindDestination(info, destination, source = '') { + return (info?.Mounts || []).some( + (mount) => mount.Destination === destination && mount.RW === false && (!source || mount.Source === source), + ) +} + +export function needsLegacyServiceMigration(info, { sourceDir = '' } = {}) { + return !!info && !hasBindDestination(info, LIVE_BIN, sourceDir) +} + +export async function migrateRemoteContainer(docker, name, body, { wasRunning, ops = {} } = {}) { + const inspect = ops.inspect || inspectContainerOrNull + const action = ops.action || containerAction + const rename = ops.rename || renameContainer + const create = ops.create || createContainerRaw + const existing = await inspect(docker, name) + if (!existing) return { ok: true, action: 'absent', name } + const backup = migrationBackupName(name) + if (await inspect(docker, backup)) throw new Error(`legacy migration backup already exists: ${backup}`) + const oldId = existing.Id + if (wasRunning) await action(docker, name, 'stop', { t: 30 }) + await rename(docker, name, backup) + let createdId = null + try { + createdId = await create(docker, name, body) + if (wasRunning) await action(docker, createdId, 'start') + const next = await inspect(docker, name) + if (!next || (wasRunning && !next.State?.Running) || (!wasRunning && next.State?.Running)) { + throw new Error(`migrated container did not preserve state: ${name}`) + } + return { ok: true, action: 'migrated', name, old_id: oldId, new_id: next.Id, backup, was_running: wasRunning } + } catch (error) { + if (createdId) await rename(docker, createdId, migrationBackupName(`${name}-failed`)).catch(() => {}) + await rename(docker, backup, name).catch(() => {}) + if (wasRunning) await action(docker, name, 'start').catch(() => {}) + throw error + } +} + /** * Layout 1 kept `.claude` inside the guest-writable home. Carry its credential into * the host-owned `claude/` mount once, unless the new location already has one. @@ -164,15 +276,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 +301,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 +326,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 +348,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 +373,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 +422,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 +582,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 +643,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 +877,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 +903,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 +983,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 +1040,81 @@ export async function reloadRemoteSlot(vm, projectRoot, { routing } = {}) { } } +/** Update a node slot's Go services without recreating its VM/container. */ +export async function migrateRemoteServiceContainers(vm, projectRoot) { + try { + const session = await nodeSession(vmNodeId(vm)) + const remoteDir = remoteSlotDir(session.host, vm.id) + const slotBin = `${remoteDir}/slot-bin` + const binaries = await pushSlotBinaries(vm, projectRoot, session) + const migrated = [] + const slotName = vm.runtime?.container || containerName(vm.id) + const slot = await inspectContainerOrNull(session.docker, slotName) + if (needsLegacyServiceMigration(slot, { sourceDir: slotBin })) { + const body = legacyServiceContainerBody(slot, { + name: slotName, + sourceDir: slotBin, + removeDestinations: ['/usr/local/bin/kin-worker'], + layout: true, + }) + migrated.push( + await migrateRemoteContainer(session.docker, slotName, body, { + wasRunning: !!slot.State?.Running, + }), + ) + } + const egressName = slotEgressNames(vm.id).egress + const egress = await inspectContainerOrNull(session.docker, egressName) + if (needsLegacyServiceMigration(egress, { sourceDir: slotBin })) { + const body = legacyServiceContainerBody(egress, { + name: egressName, + sourceDir: slotBin, + removeDestinations: ['/usr/local/bin/kin-egress'], + command: [`${LIVE_BIN}/kin-egress`, '-config', '/etc/kin-egress/egress.json'], + }) + migrated.push( + await migrateRemoteContainer(session.docker, egressName, body, { + wasRunning: !!egress.State?.Running, + }), + ) + } + return { ok: true, action: migrated.length ? 'migrated' : 'unchanged', migrated, binary_digest: binaries.digest || null } + } catch (err) { + return failure(err, 'legacy_service_migration_failed') + } +} + +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 migration = await migrateRemoteServiceContainers(vm, projectRoot) + if (!migration.ok) return migration + 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 egress = slotEgressNames(vm.id).egress + const egressInfo = await inspectContainerOrNull(session.docker, egress) + if (egressInfo?.State?.Running) await containerAction(session.docker, egress, 'restart') + return { + ok: true, + action: telemetryEnabled === true ? 'restarted' : 'stopped', + remote_dir: remoteDir, + binary_digest: migration.binary_digest, + migration: migration.migrated, + } + } 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/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index 68e698084..8a3689db7 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -35,7 +35,7 @@ import { createClaudeMessageAssembler, applyClaudeSSELineToMessage, } from './convert.mjs' -import { sanitizeInboundBody, defaultSeedPolicy } from './seed-policy.mjs' +import { sanitizeInboundBody, defaultSeedPolicy, callerAutoCompactWindow } from './seed-policy.mjs' import { fingerprintRequest } from './client-fingerprint.mjs' import { validateOfficialModel } from './models.mjs' import { handleCodexProtocol, handleCodexSearch } from './handle-codex.mjs' @@ -618,11 +618,13 @@ export function createHandleProtocol(deps) { ) } + const autoCompactWindow = callerAutoCompactWindow(inbound) let ctx = { path: pathName, protocol, body: sanitizeInboundBody(inbound, defaultSeedPolicy()), headers: { ...req.headers }, + autoCompactWindow, } ctx = applyIntercept(cfg.intercept.rules, 'before_convert', ctx) const bodyCheck = validateRequestBody(protocol, ctx.body) @@ -1272,6 +1274,7 @@ export function createHandleProtocol(deps) { preserveSystem: officialPassthrough, preserveThinkingBlocks: preserveCliHopThinkingBlocks, preserveCallerEnvironment, + autoCompactWindow: ctx.autoCompactWindow, }) hopBody = await materializeRemoteImageSources(hopBody) if (panelRewrite) { diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs index 95326c71b..7e9be7668 100644 --- a/src/lib/protocol/outbound-attempt.mjs +++ b/src/lib/protocol/outbound-attempt.mjs @@ -190,11 +190,16 @@ export function prepareCliHopBody( preserveSystem = false, preserveThinkingBlocks = false, preserveCallerEnvironment = false, + autoCompactWindow = null, } = {}, ) { if (requestContext?.purpose === 'auto_mode_classifier') return prepareClassifierBody(canonicalBody, { stream }) if (preserveSystem) validateCacheBreakpoints(canonicalBody) let body = officialMessagesBody(canonicalBody, { stream }) + const windowTokens = Number(autoCompactWindow) + if (Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000) { + body.auto_compact_window = windowTokens + } delete body.metadata if (!preserveSystem) { const leftover = stripCliOwnedSystem(body.system, { preserveCallerEnvironment }) diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs index 6b60f9d0c..d3b82dde5 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', }) @@ -107,6 +117,16 @@ export function buildSlotSettingsEnv(pol = {}, { timezone, locale, extra } = {}) return out } +export function callerAutoCompactWindow(body = {}) { + const sources = [body?.settings?.env, body?.claude_settings?.env, body?.env] + for (const source of sources) { + if (!source || typeof source !== 'object' || Array.isArray(source)) continue + const value = Number(source.CLAUDE_CODE_AUTO_COMPACT_WINDOW) + if (Number.isInteger(value) && value >= 100_000 && value <= 1_000_000) return value + } + return null +} + export function buildSeedSettingsEnv(pol = {}, extra = {}) { return buildSlotSettingsEnv(pol, { extra }) } @@ -141,6 +161,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/transport/rust-kernel-supervisor.mjs b/src/lib/transport/rust-kernel-supervisor.mjs index 3c70bf3e4..4dd986d56 100644 --- a/src/lib/transport/rust-kernel-supervisor.mjs +++ b/src/lib/transport/rust-kernel-supervisor.mjs @@ -37,6 +37,11 @@ import { } from '../oauth/oauth-credentials.mjs' import { slotHost } from '../vm/slot-host.mjs' +function normalizeAutoCompactWindow(value) { + const windowTokens = Number(value) + return Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null +} + const starts = new Map() const KVM_HEALTH_MS = { kvm: 120_000, tcg: 480_000 } @@ -693,6 +698,13 @@ export function writeKernelConfig( const previousMax = Number(previous.max_request_bytes) const requestBytes = routing == null && previousMax > 0 ? previousMax : maxRequestBytes(routing) const cchEnabled = routing == null ? previous.cch_enabled !== false : routing?.compatibility?.cch_enabled !== false + const previousWindow = Number(previous.auto_compact_window) + const autoCompactWindow = + routing == null + ? Number.isInteger(previousWindow) && previousWindow >= 100_000 && previousWindow <= 1_000_000 + ? previousWindow + : null + : normalizeAutoCompactWindow(routing?.compatibility?.auto_compact_window) const sessionIdleTtlSeconds = routing == null ? normalizeSessionIdleTtlSeconds(previous.session_idle_ttl_seconds) @@ -726,6 +738,7 @@ export function writeKernelConfig( cli_version: OFFICIAL_CLI_VERSION, default_cache_ttl: defaultCacheTtl, cch_enabled: cchEnabled, + auto_compact_window: autoCompactWindow, } if (tz) config.timezone = tz diff --git a/src/lib/vm/egress.mjs b/src/lib/vm/egress.mjs index 8068cd158..aac230a1c 100644 --- a/src/lib/vm/egress.mjs +++ b/src/lib/vm/egress.mjs @@ -243,24 +243,61 @@ function readPid(file) { } } -function ownsEgressProcess(pid, configPath) { +function processExe(pid) { + const exe = fs.readlinkSync(`/proc/${pid}/exe`) + const deleted = exe.endsWith(' (deleted)') + return { path: deleted ? exe.slice(0, -' (deleted)'.length) : exe, deleted } +} + +function ownsEgressProcess(pid, configPath, { allowDeleted = false } = {}) { if (!pidAlive(pid)) return false try { // PID files survive container restarts; a live PID may belong to another // process or another proxy. Verify both before reusing or signaling it. - const exe = fs.readlinkSync(`/proc/${pid}/exe`).replace(/ \(deleted\)$/, '') + // A replaced binary keeps the old inode in this process. Reusing it leaves + // the new file unrun and its listen port permanently busy. + const exe = processExe(pid) + if (exe.deleted && !allowDeleted) return false const args = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0') const configIndex = args.indexOf('-config') - if (path.basename(exe) !== 'kin-egress' || configIndex <= 0 || !args[configIndex + 1]) return false + if ( + !args.some((arg) => path.basename(String(arg || '').replace(/ \(deleted\)$/, '')).startsWith('kin-egress')) || + configIndex <= 0 || + !args[configIndex + 1] + ) + return false const argument = args[configIndex + 1] const actualConfig = path.isAbsolute(argument) ? path.resolve(argument) : path.resolve(fs.readlinkSync(`/proc/${pid}/cwd`), argument) - return actualConfig === path.resolve(configPath) + return path.normalize(actualConfig) === path.normalize(path.resolve(configPath)) } catch { return false } } + +function egressProcessPids() { + if (process.platform !== 'linux') return [] + const pids = [] + for (const name of fs.readdirSync('/proc')) { + if (!/^[1-9][0-9]*$/.test(name)) continue + try { + const args = fs.readFileSync(`/proc/${name}/cmdline`).toString('utf8').split('\0') + if (args.some((arg) => path.basename(arg.replace(/ \(deleted\)$/, '')).startsWith('kin-egress'))) + pids.push(Number(name)) + } catch { + // Raced with exit, or a process whose /proc we cannot read. + } + } + return pids +} + +function staleEgressPids(configPath, exceptPid = 0) { + const wanted = path.resolve(configPath) + return egressProcessPids().filter( + (pid) => pid !== exceptPid && ownsEgressProcess(pid, wanted, { allowDeleted: true }), + ) +} export function inspectEgressNetwork(proxyId, run = docker) { const name = networkName(proxyId) if (!name) return null @@ -421,7 +458,8 @@ export async function startEgressProcess({ const listenTcp = `${listenHost}:${tcpPort}` const listenDns = `${listenHost}:${dnsPort}` const existing = readPid(pidFile) - if (ownsEgressProcess(existing, cfgPath)) { + const configMatches = ownsEgressProcess(existing, cfgPath) + if (configMatches) { try { const old = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) if ( @@ -430,24 +468,25 @@ export async function startEgressProcess({ old.proxy_url === proxyUrl && (old.dns_upstream || '') === dnsUpstream && JSON.stringify(old.dns_empty_types || []) === JSON.stringify(dnsEmptyTypes) && - !!old.domain_forward === !!domainForward + !!old.domain_forward === !!domainForward && + staleEgressPids(cfgPath, existing).length === 0 ) { return { ok: true, pid: existing, reused: true, configPath: cfgPath } } } catch {} - try { - process.kill(existing, 'SIGTERM') - } catch {} - const stopped = await waitEgressExit(existing) - if (!stopped.ok) return { ok: false, error: stopped.error, pid: existing } - if (tcpPort > 0) { - const portFree = await waitPortReleased(listenHost, tcpPort) - if (!portFree.ok) return { ok: false, error: portFree.error, pid: existing } - } - if (dnsPort > 0) { - const dnsFree = await waitPortReleased(listenHost, dnsPort) - if (!dnsFree.ok) return { ok: false, error: dnsFree.error, pid: existing } - } + } + const superseded = [...new Set([...(configMatches ? [existing] : []), ...staleEgressPids(cfgPath)])] + for (const pid of superseded) { + const stopped = await stopOwnedEgress(pid) + if (!stopped.ok) return { ok: false, error: stopped.error, pid } + } + if (tcpPort > 0) { + const portFree = await waitPortReleased(listenHost, tcpPort) + if (!portFree.ok) return { ok: false, error: portFree.error, pid: superseded[0] || existing } + } + if (dnsPort > 0) { + const dnsFree = await waitPortReleased(listenHost, dnsPort) + if (!dnsFree.ok) return { ok: false, error: dnsFree.error, pid: superseded[0] || existing } } if (!fs.existsSync(bin)) { return { ok: false, error: `kin-egress binary not found: ${bin}` } @@ -471,22 +510,35 @@ export async function startEgressProcess({ export function stopEgressProcess(projectRoot, proxyId) { const dir = egressRunDir(projectRoot, proxyId) const pidFile = path.join(dir, 'egress.pid') - const pid = readPid(pidFile) - if (ownsEgressProcess(pid, path.join(dir, 'egress.json'))) { + const cfgPath = path.join(dir, 'egress.json') + const recorded = readPid(pidFile) + const pids = [...new Set([...staleEgressPids(cfgPath), ...(ownsEgressProcess(recorded, cfgPath) ? [recorded] : [])])] + const pending = [] + for (const pid of pids) { try { process.kill(pid, 'SIGTERM') } catch (error) { if (error.code !== 'ESRCH') return { ok: false, error: `egress_stop_failed: ${error.code || error.message}` } } // Keep the PID while termination is pending so policy reconciliation can verify/retry it. - if (ownsEgressProcess(pid, path.join(dir, 'egress.json'))) return { ok: true } + if (ownsEgressProcess(pid, cfgPath, { allowDeleted: true })) pending.push(pid) } + if (pending.length) return { ok: true, pending } try { fs.rmSync(pidFile, { force: true }) } catch (error) { return { ok: false, error: `egress_pid_cleanup_failed: ${error.code || error.message}` } } - return { ok: true } + return { ok: true, pending } +} + +async function stopOwnedEgress(pid) { + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if (error.code !== 'ESRCH') return { ok: false, error: `egress_stop_failed: ${error.code || error.message}` } + } + return waitEgressExit(pid) } export function inspectEgressProcess(projectRoot, proxyId) { @@ -606,26 +658,32 @@ function spawnDetachedEgress(bin, cfgPath) { }) } -function portListening(host, port) { +function portListening(host, port, ownerPid = 0) { const h = host || '127.0.0.1' // A TCP connect is traffic to the transparent forwarder, not a passive // readiness check. Inspect LISTEN sockets without entering its data path. - const r = sh(['ss', '-H', '-ltn', `sport = :${Number(port)}`], { timeout: 2000 }) + const probe = ownerPid + ? ['ss', '-H', '-ltnp', `sport = :${Number(port)}`] + : ['ss', '-H', '-ltn', `sport = :${Number(port)}`] + const r = sh(probe, { timeout: 2000 }) return ( r.ok && r.stdout.split('\n').some((line) => { - const local = line.trim().split(/\s+/)[3] - return ( + const fields = line.trim().split(/\s+/) + const local = fields[3] + const addressMatches = local === `${h}:${port}` || local === `0.0.0.0:${port}` || local === `*:${port}` || local === `[::]:${port}` - ) + if (!addressMatches) return false + if (!ownerPid) return true + return new RegExp(`pid=${ownerPid}(?:\\D|$)`).test(fields.at(-1) || '') }) ) } -function waitListen(host, port, timeoutMs = 8000) { +function waitListen(host, port, timeoutMs = 8000, ownerPid = 0) { const start = Date.now() while (Date.now() - start < timeoutMs) { - if (portListening(host, port)) return true + if (portListening(host, port, ownerPid)) return true sleepMsSync(100) } return false @@ -686,6 +744,8 @@ async function startProxyEgress( dnsUpstream = configuredDnsUpstream(), dnsEmptyTypes = configuredDnsEmptyTypes(), bin, + ports, + listenHost: listenHostOverride, } = {}, ) { const blocked = proxyBlockedReason(proxy) @@ -695,23 +755,23 @@ async function startProxyEgress( if (!proxyId || !proxyUrl) return { ok: false, error: 'bound SOCKS5 id and url required; refusing fallback' } const net = ensureEgressNetwork(proxyId, runDocker) if (!net.ok) return net - const listenHost = net.gateway || gatewayFromSubnet(net.subnet) + const listenHost = listenHostOverride || net.gateway || gatewayFromSubnet(net.subnet) if (!listenHost) return { ok: false, error: 'egress network gateway missing' } - const ports = portsForProxy(proxyId) + const selectedPorts = ports || portsForProxy(proxyId) const chain = chainName(proxyId) const plan = iptablesPlan({ chain, bridge: net.bridge, subnet: net.subnet, - tcpPort: ports.tcp, - dnsPort: ports.dns, + tcpPort: selectedPorts.tcp, + dnsPort: selectedPorts.dns, }) const started = await startEgressProcess({ projectRoot, proxyId, proxyUrl, - tcpPort: ports.tcp, - dnsPort: ports.dns, + tcpPort: selectedPorts.tcp, + dnsPort: selectedPorts.dns, listenHost, dnsUpstream, dnsEmptyTypes, @@ -719,8 +779,8 @@ async function startProxyEgress( ...(bin ? { bin } : {}), }) if (!started.ok) return started - if (!waitListen(listenHost, ports.tcp)) - return { ok: false, error: `kin-egress ${listenHost}:${ports.tcp} did not listen` } + if (!waitListen(listenHost, selectedPorts.tcp, 8000, selectedPorts.tcp > 0 ? started.pid : 0)) + return { ok: false, error: `kin-egress ${listenHost}:${selectedPorts.tcp} did not listen`, pid: started.pid } const rules = applyIptables(plan, runIptables) if (!rules.ok) { stopEgressProcess(projectRoot, proxyId) @@ -732,7 +792,7 @@ async function startProxyEgress( bridge: net.bridge, subnet: net.subnet, gateway: listenHost, - ports, + ports: selectedPorts, pid: started.pid, proxy_id: proxyId, } diff --git a/src/lib/vm/fleet-update.mjs b/src/lib/vm/fleet-update.mjs index afdd70fea..0333dc1a0 100644 --- a/src/lib/vm/fleet-update.mjs +++ b/src/lib/vm/fleet-update.mjs @@ -8,8 +8,33 @@ 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, isLocalEgressProxy, stopEgressProcess, waitEgressExit } from './egress.mjs' -const ACTIONS = new Set(['collect', 'reload', 'roll']) +const ACTIONS = new Set(['collect', 'reload', 'roll', 'telemetry']) + +export async function restartLocalEgress(projectRoot, vm, opts = {}) { + if (isLocalEgressProxy(vm.proxy)) return { ok: true, egress: 'local' } + const stopped = stopEgressProcess(projectRoot, vm.proxy?.id) + if (!stopped.ok) return { ok: false, action: 'egress_stop_failed', error: stopped.error } + for (const pid of stopped.pending || []) { + const exited = await waitEgressExit(pid) + if (!exited.ok) return { ok: false, action: 'egress_stop_failed', error: exited.error, pid } + } + const started = await ensureProxyEgress(projectRoot, vm.proxy, opts) + if (!started?.ok) return { ok: false, action: 'egress_start_failed', error: started?.error || 'kin-egress failed' } + return { ok: true, egress: 'restarted', pid: started.pid } +} + +async function restartLocalServices({ projectRoot, vm, enabled, egressOpts = {} }) { + const telemetry = await restartTelemetrySidecar({ projectRoot, vm, enabled }) + if (!telemetry.ok) return telemetry + const egress = await restartLocalEgress(projectRoot, vm, egressOpts) + if (!egress.ok) return egress + return { ...telemetry, egress: egress.egress } +} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -64,6 +89,8 @@ export async function updateOneSlot( reloadFn = reloadSlotReady, collectFn = collectSlotIdentity, healthFn = rustKernelHealth, + telemetryFn, + telemetryEnabled, readyTimeoutMs = 15000, } = {}, ) { @@ -89,6 +116,22 @@ export async function updateOneSlot( return result } } + if (action === 'telemetry') { + const enabled = + telemetryEnabled === undefined ? vm.seed_policy?.telemetry_disabled === false : telemetryEnabled === true + const changed = await (telemetryFn || (vmNodeId(vm) ? restartRemoteServices : restartLocalServices))({ + projectRoot, + vm: getVm(projectRoot, id) || vm, + telemetryEnabled: enabled, + 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 +151,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 +174,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/egress.test.mjs b/test/unit/egress.test.mjs index d6e26c9a8..5cd66da5e 100644 --- a/test/unit/egress.test.mjs +++ b/test/unit/egress.test.mjs @@ -39,14 +39,22 @@ import { hostProxyUrlForVm } from '../../src/lib/vm/slot-host.mjs' // Process-ownership tests need the native helper so /proc//exe identifies it. const egressBin = process.env.KIN_EGRESS_BIN || path.resolve(import.meta.dirname, '../../bin/kin-egress') -async function waitForProcess(predicate) { - for (let i = 0; i < 100; i++) { +async function waitForProcess(predicate, attempts = 100) { + for (let i = 0; i < attempts; i++) { if (predicate()) return - await new Promise((resolve) => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 50)) } assert.fail('process state did not converge') } +async function reservePort() { + const listener = net.createServer() + await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)) + const port = listener.address().port + await new Promise((resolve) => listener.close(resolve)) + return port +} + function egressFixture(t, proxyId = 'px-process') { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-eg-process-')) const dir = egressRunDir(root, proxyId) @@ -224,27 +232,60 @@ test('an egress helper can be reused, replaced and stopped only by its own proxy assert.equal(fs.existsSync(fx.pidFile), false) }) -test('a running egress remains identifiable after its binary was replaced', { +test('a replaced egress binary is restarted onto the new file', { skip: process.platform !== 'linux', }, async (t) => { const fx = egressFixture(t) const bin = path.join(fx.root, 'kin-egress') + const replacement = path.join(fx.root, 'kin-egress-next') fs.copyFileSync(egressBin, bin) + fs.copyFileSync(egressBin, replacement) fs.chmodSync(bin, 0o755) + fs.chmodSync(replacement, 0o755) const started = await fx.start({ bin }) - await waitForProcess(() => { - try { - return fs.readlinkSync(`/proc/${started.pid}/exe`) === bin - } catch { - return false - } - }) + await waitForProcess(() => inspectEgressProcess(fx.root, fx.base.proxyId).ok) fs.unlinkSync(bin) assert.match(fs.readlinkSync(`/proc/${started.pid}/exe`), / \(deleted\)$/) - assert.equal(inspectEgressProcess(fx.root, fx.base.proxyId).ok, true) - assert.equal((await fx.start({ bin })).reused, true) - assert.equal(stopEgressProcess(fx.root, fx.base.proxyId).ok, true) + assert.equal(inspectEgressProcess(fx.root, fx.base.proxyId).ok, false) + const restarted = await fx.start({ bin: replacement }) + assert.equal(restarted.ok, true, restarted.error) + assert.equal(restarted.reused, false) + assert.notEqual(restarted.pid, started.pid) await waitForProcess(() => !fs.existsSync(`/proc/${started.pid}`)) + let exe = 'missing' + try { + exe = fs.readlinkSync(`/proc/${restarted.pid}/exe`) + } catch (error) { + exe = error.code || 'gone' + } + assert.equal(exe, replacement, JSON.stringify({ old: started.pid, next: restarted.pid, exe })) + assert.equal(stopEgressProcess(fx.root, fx.base.proxyId).ok, true) +}) + +test('a stale pidfile does not hide an egress process still holding its port', { + skip: process.platform !== 'linux', +}, async (t) => { + const fx = egressFixture(t) + const orphanBin = path.join(fx.root, 'kin-egress') + fs.copyFileSync(egressBin, orphanBin) + fs.chmodSync(orphanBin, 0o755) + const port = await reservePort() + const orphan = await fx.start({ bin: orphanBin, tcpPort: port, dnsPort: 0 }) + assert.equal(orphan.ok, true, orphan.error) + await waitForProcess(() => inspectEgressProcess(fx.root, fx.base.proxyId).ok) + fs.unlinkSync(orphanBin) + fs.writeFileSync(fx.pidFile, '999999999\n') + assert.match(fs.readlinkSync(`/proc/${orphan.pid}/exe`), / \(deleted\)$/) + assert.equal(inspectEgressProcess(fx.root, fx.base.proxyId).ok, false) + const recovered = await fx.start({ tcpPort: port, dnsPort: 0 }) + assert.equal(recovered.ok, true, recovered.error) + assert.equal(recovered.reused, false) + assert.notEqual(recovered.pid, orphan.pid) + await waitForProcess(() => !fs.existsSync(`/proc/${orphan.pid}`)) + assert.equal(inspectEgressProcess(fx.root, fx.base.proxyId).pid, recovered.pid) + assert.equal(Number(fs.readFileSync(fx.pidFile, 'utf8')), recovered.pid) + assert.equal(JSON.parse(fs.readFileSync(fx.configPath, 'utf8')).listen_tcp, `127.0.0.1:${port}`) + assert.equal(stopEgressProcess(fx.root, fx.base.proxyId).ok, true) }) test('redundant separators in an egress config path do not change its owner', { @@ -264,7 +305,7 @@ test('redundant separators in an egress config path do not change its owner', { await once(child, 'spawn') fs.writeFileSync(fx.pidFile, String(child.pid)) assert.equal(inspectEgressProcess(fx.root, fx.base.proxyId).ok, true) - const reused = await fx.start() + const reused = await fx.start({ listenHost: '127.0.0.1', tcpPort: 0, dnsPort: 0 }) assert.equal(reused.reused, true) assert.equal(reused.pid, child.pid) assert.equal(stopEgressProcess(fx.root, fx.base.proxyId).ok, true) diff --git a/test/unit/fleet-update.test.mjs b/test/unit/fleet-update.test.mjs index 9a1e4d97e..451099f67 100644 --- a/test/unit/fleet-update.test.mjs +++ b/test/unit/fleet-update.test.mjs @@ -2,8 +2,10 @@ import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' +import net from 'node:net' import path from 'node:path' -import { runFleetUpdate, fleetStatus, isWorkerProcessUp } from '../../src/lib/vm/fleet-update.mjs' +import { runFleetUpdate, fleetStatus, isWorkerProcessUp, restartLocalEgress } from '../../src/lib/vm/fleet-update.mjs' +import { startEgressProcess, inspectEgressProcess } from '../../src/lib/vm/egress.mjs' function writeSlot(root, id, extra = {}) { const dir = path.join(root, 'vms') @@ -99,3 +101,65 @@ test('fleet collect skips reload', async () => { assert.equal(fleetStatus(root)[0].id, 'vm-01') fs.rmSync(root, { recursive: true, force: true }) }) + +test('fleet refresh replaces an egress process whose binary was deleted', { + skip: process.platform !== 'linux', +}, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-fleet-egress-')) + const oldBin = path.join(root, 'kin-egress-old') + const newBin = path.join(root, 'kin-egress') + const source = process.env.KIN_EGRESS_BIN || path.resolve(import.meta.dirname, '../../bin/kin-egress') + fs.copyFileSync(source, oldBin) + fs.copyFileSync(source, newBin) + fs.chmodSync(oldBin, 0o755) + fs.chmodSync(newBin, 0o755) + const proxy = { id: 'px-fleet', scheme: 'socks5', host: '192.0.2.1', port: 1080, url: 'socks5://192.0.2.1:1080' } + writeSlot(root, 'vm-01', { proxy }) + const probe = net.createServer() + const port = await new Promise((resolve) => probe.listen(0, '127.0.0.1', () => resolve(probe.address().port))) + await new Promise((resolve) => probe.close(resolve)) + const previous = process.env.KIN_EGRESS_BIN + process.env.KIN_EGRESS_BIN = newBin + const orphan = await startEgressProcess({ + projectRoot: root, + proxyId: proxy.id, + proxyUrl: 'socks5h://192.0.2.1:1080', + listenHost: '127.0.0.1', + tcpPort: port, + dnsPort: 0, + bin: oldBin, + }) + assert.equal(orphan.ok, true, orphan.error) + fs.unlinkSync(oldBin) + fs.writeFileSync(path.join(root, 'data/run/egress/px-fleet/egress.pid'), '999999999\n') + const egressOpts = { + runDocker: (args) => + args[0] === 'network' && args[1] === 'inspect' ? { ok: true, stdout: '127.0.0.0/8|127.0.0.1' } : { ok: true }, + runIptables: () => ({ ok: true }), + listenHost: '127.0.0.1', + ports: { tcp: port, dns: 0 }, + bin: newBin, + } + try { + const report = await runFleetUpdate(root, { + action: 'telemetry', + telemetryFn: async ({ projectRoot, vm }) => restartLocalEgress(projectRoot, vm, egressOpts), + healthFn: async () => ({ ok: true }), + }) + assert.equal(report.failed_count, 0, JSON.stringify(report.items)) + const current = inspectEgressProcess(root, proxy.id) + assert.equal(current.ok, true, JSON.stringify(current)) + assert.notEqual(current.pid, orphan.pid) + assert.equal(fs.readlinkSync(`/proc/${current.pid}/exe`), newBin) + assert.equal(fs.existsSync(`/proc/${orphan.pid}`), false) + assert.equal( + JSON.parse(fs.readFileSync(path.join(root, 'data/run/egress/px-fleet/egress.json'), 'utf8')).listen_tcp, + `127.0.0.1:${port}`, + ) + process.kill(current.pid, 'SIGTERM') + } finally { + if (previous === undefined) delete process.env.KIN_EGRESS_BIN + else process.env.KIN_EGRESS_BIN = previous + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/test/unit/go-worker-client.test.mjs b/test/unit/go-worker-client.test.mjs index 43a1a32e1..388d601a8 100644 --- a/test/unit/go-worker-client.test.mjs +++ b/test/unit/go-worker-client.test.mjs @@ -14,6 +14,8 @@ import { restoreUncommittedHop, semanticStatusForStreamError, } from '../../src/lib/transport/go-worker-client.mjs' +import { prepareCliHopBody } from '../../src/lib/protocol/outbound-attempt.mjs' +import { callerAutoCompactWindow, defaultSeedPolicy, sanitizeInboundBody } from '../../src/lib/protocol/seed-policy.mjs' import { extractOpenaiUsage } from '../../src/lib/protocol/openai-usage.mjs' import { callRustKernel, rustKernelHealth } from '../../src/lib/transport/rust-kernel-client.mjs' import { forwardApi, readApiJson } from '../../src/lib/transport/api-kernel-client.mjs' @@ -164,6 +166,29 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare' assert.deepEqual(out.body.system, body.system) }) +test('cli-hop keeps the caller compact window outside settings', () => { + const inbound = { + model: 'claude-opus-5-5', + messages: [{ role: 'user', content: 'hi' }], + settings: { theme: 'light', env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000', TZ: 'UTC' } }, + } + const autoCompactWindow = callerAutoCompactWindow(inbound) + const sanitized = sanitizeInboundBody(inbound, defaultSeedPolicy()) + assert.equal(callerAutoCompactWindow(sanitized), null) + const prepared = prepareCliHopBody(sanitized, { autoCompactWindow }) + const out = finalizeWorkerPayload({ + body: prepared, + reqHeaders: {}, + exec: { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }, + identity: null, + cliHop: true, + }) + assert.equal(sanitized.settings, undefined) + assert.equal(out.body.settings, undefined) + assert.equal(out.body.env, undefined) + assert.equal(out.body.auto_compact_window, 1000000) +}) + test('setup-token cli-hop summarizes hidden thinking by default and keeps updates when passthrough', () => { const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } } const updates = finalizeWorkerPayload({ diff --git a/test/unit/kernel-router.test.mjs b/test/unit/kernel-router.test.mjs index cc7d880c9..4d6706486 100644 --- a/test/unit/kernel-router.test.mjs +++ b/test/unit/kernel-router.test.mjs @@ -631,6 +631,7 @@ test('writeKernelConfig separates container paths from host socket paths', () => assert.equal(doc.https_proxy, undefined) assert.equal(doc.slots_per_worker, WRAP_SLOT_MAX) assert.equal(doc.default_cache_ttl, '1h') + assert.equal(doc.auto_compact_window, null) assert.match(written.socketPath.replace(/\\/g, '/'), /vms\/vm-09\/run\/kernel\.sock$/) assert.equal(fs.readFileSync(written.tokenPath, 'utf8').trim(), 'tok') const exec = { vmId: 'vm-09', homeDir: path.join(root, 'vms', 'vm-09', 'cli-home'), vm: { id: 'vm-09' } } @@ -702,6 +703,7 @@ test('writeKernelConfig cli-hop writes local_cli without secrets', () => { assert.equal(doc.system_layout, 'identity') assert.equal(doc.cli_version, OFFICIAL_CLI_VERSION) assert.equal(doc.default_cache_ttl, '5m') + assert.equal(doc.auto_compact_window, null) assert.equal(doc.timezone, 'America/New_York') assert.equal(doc.proxy_url, '') assert.equal(doc.proxy_required, false) diff --git a/test/unit/panel-vm-seed-settings.test.mjs b/test/unit/panel-vm-seed-settings.test.mjs new file mode 100644 index 000000000..5926df0bb --- /dev/null +++ b/test/unit/panel-vm-seed-settings.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createPanelHandler } from '../../src/lib/admin/panel-routes.mjs' +import { DEFAULT_CLAUDE_SETTING } from '../../src/lib/protocol/seed-policy.mjs' + +function seed(root, id) { + const dir = path.join(root, 'vms') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync( + path.join(dir, `${id}.json`), + JSON.stringify({ + id, + name: id, + status: 'stopped', + seed_policy: { claude_setting: 'FROM_POLICY=1' }, + }), + ) +} + +function handler(project) { + const response = {} + const handlePanel = createPanelHandler({ + cfg: { paths: { project }, vm: { id: null } }, + requireAuth(req) { + req.apiKeyKind = 'master' + req.panelRole = 'admin' + return true + }, + json(_res, status, payload) { + response.status = status + response.body = payload + return true + }, + readBody: async () => ({}), + proxyPool: { unbindVm() {} }, + }) + return { handlePanel, response } +} + +test('GET seed-settings keeps a missing claude.setting null', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-get-')) + try { + seed(root, 'vm-01') + const { handlePanel, response } = handler(root) + await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings')) + assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400)) + assert.equal(response.body.data.claude_setting, null) + assert.equal(response.body.data.settings_json, null) + assert.equal(response.body.data.seed_policy.claude_setting, 'FROM_POLICY=1') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('GET seed-settings returns an empty claude.setting file as empty', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-empty-')) + try { + seed(root, 'vm-01') + const dir = path.join(root, 'vms', 'vm-01', 'cli-home', '.claude') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'claude.setting'), '\n') + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ env: { TZ: 'UTC' } })) + const { handlePanel, response } = handler(root) + await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings')) + assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400)) + assert.equal(response.body.data.claude_setting, '') + assert.equal(response.body.data.seed_policy.claude_setting, '') + assert.equal(response.body.data.settings_json.env.TZ, 'UTC') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/test/unit/remote-slot-migration.test.mjs b/test/unit/remote-slot-migration.test.mjs new file mode 100644 index 000000000..ed9861419 --- /dev/null +++ b/test/unit/remote-slot-migration.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + legacyServiceContainerBody, + migrateRemoteContainer, + needsLegacyServiceMigration, +} from '../../src/lib/cluster/remote-slot.mjs' + +const oldSlot = { + Id: 'old-slot-id', + State: { Running: true }, + Config: { + Image: 'vm2api/kin-slot-ubuntu:old', + User: '1000:1000', + Cmd: ['/opt/kin/kin-kernel', '--gateway-worker', '--config', '/run/kin/kernel.json'], + Labels: { 'kin.vm': '1', 'kin.vm.layout': '2' }, + }, + HostConfig: { + Binds: ['/srv/vm-05/run:/run/kin', '/srv/vm-05/old-worker:/usr/local/bin/kin-worker'], + NetworkMode: 'kin-05-net', + ReadonlyRootfs: true, + RestartPolicy: { Name: 'unless-stopped' }, + }, + NetworkSettings: { Networks: { 'kin-05-net': { IPAddress: '172.20.0.2', Aliases: ['kin-05'] } } }, + Mounts: [{ Destination: '/run/kin', Source: '/srv/vm-05/run', RW: true }], +} + +test('legacy container body replaces old service bind and preserves slot settings', () => { + const body = legacyServiceContainerBody(oldSlot, { + sourceDir: '/srv/vm-05/slot-bin', + removeDestinations: ['/usr/local/bin/kin-worker'], + layout: true, + }) + assert.equal(body.Image, oldSlot.Config.Image) + assert.equal(body.User, oldSlot.Config.User) + assert.equal(body.Labels['kin.vm.layout'], '3') + assert.deepEqual(body.HostConfig.Binds, ['/srv/vm-05/run:/run/kin', '/srv/vm-05/slot-bin:/opt/kin-live:ro']) + assert.deepEqual(body.NetworkingConfig.EndpointsConfig['kin-05-net'].IPAMConfig, { IPv4Address: '172.20.0.2' }) + assert.equal(needsLegacyServiceMigration(oldSlot, { sourceDir: '/srv/vm-05/slot-bin' }), true) +}) + +test('legacy egress command points at the persistent service mount', () => { + const body = legacyServiceContainerBody(oldSlot, { + sourceDir: '/srv/vm-05/slot-bin', + removeDestinations: ['/usr/local/bin/kin-egress'], + command: ['/opt/kin-live/kin-egress', '-config', '/etc/kin-egress/egress.json'], + }) + assert.deepEqual(body.Cmd, ['/opt/kin-live/kin-egress', '-config', '/etc/kin-egress/egress.json']) +}) + +test('migration keeps the old container as a stopped rollback copy', async () => { + const state = new Map([['kin-05', structuredClone(oldSlot)]]) + let sequence = 0 + const inspect = async (_docker, name) => state.get(name) || null + const action = async (_docker, name, operation) => { + const value = [...state.values()].find((item) => item.Id === name) || state.get(name) + if (operation === 'stop') value.State.Running = false + if (operation === 'start') value.State.Running = true + } + const rename = async (_docker, from, to) => { + const value = state.get(from) + state.delete(from) + state.set(to, value) + } + const create = async (_docker, name, body) => { + const id = `new-${++sequence}` + state.set(name, { Id: id, State: { Running: false }, Config: body, Mounts: [{ Destination: '/opt/kin-live', RW: false }] }) + return id + } + const result = await migrateRemoteContainer( + {}, + 'kin-05', + legacyServiceContainerBody(oldSlot, { sourceDir: '/srv/vm-05/slot-bin', layout: true }), + { wasRunning: true, ops: { inspect, action, rename, create } }, + ) + assert.equal(result.ok, true) + assert.equal(result.action, 'migrated') + assert.equal(state.get('kin-05').State.Running, true) + assert.equal(state.get(result.backup).State.Running, false) +}) 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/settings/protocol-pane.tsx b/web/src/features/settings/protocol-pane.tsx index 1e38d8cf7..4a2b13e4f 100644 --- a/web/src/features/settings/protocol-pane.tsx +++ b/web/src/features/settings/protocol-pane.tsx @@ -3,7 +3,7 @@ import { SESSION_IDLE_TTL_SECONDS_MAX, SESSION_IDLE_TTL_SECONDS_MIN, } from '@/types/panel-routing' -import { Brain, Check, Clock3, Database, Hash, ShieldCheck } from 'lucide-react' +import { Brain, Check, Clock3, Database, Scaling, ShieldCheck } from 'lucide-react' import { cacheTtlFromCompat, detectProxiedOfficialCcFromCompat, @@ -32,6 +32,12 @@ const CLAUDE_CODE_FINGERPRINT: [string, string][] = [ ['计费块', 'cc_version = UA 版本'], ] +function autoCompactWindowValue(value: unknown) { + const tokens = Number(value) + if (tokens === 200_000 || tokens === 500_000 || tokens === 1_000_000) return String(tokens) + return 'model' +} + export function ProtocolPane({ compat, inference, @@ -188,7 +194,34 @@ export function ProtocolPane({ + + + onCompatChange({ + ...compat, + auto_compact_window: value === 'model' ? null : Number(value), + }) + } + /> + + + diff --git a/web/src/features/vm/detail-page.tsx b/web/src/features/vm/detail-page.tsx index 2a82fd87b..c8b5fc32b 100644 --- a/web/src/features/vm/detail-page.tsx +++ b/web/src/features/vm/detail-page.tsx @@ -129,8 +129,15 @@ function VmDetailBody({ enabled: perms.known && !perms.dashboard, }) const proxies = useQuery({ ...proxiesQueryOptions(), enabled: perms.admin }) - const detail = useQuery(vmQueryOptions(id)) - const seed = useQuery({ ...vmSeedQueryOptions(id), enabled: perms.admin }) + const detail = useQuery({ + ...vmQueryOptions(id), + refetchInterval: wantedTab === 'seed' ? 2000 : false, + }) + const seed = useQuery({ + ...vmSeedQueryOptions(id), + enabled: perms.admin && wantedTab === 'seed', + refetchInterval: wantedTab === 'seed' ? 2000 : false, + }) const routing = useQuery({ ...routingQueryOptions(), enabled: perms.admin }) usePoolSeatStream() @@ -260,8 +267,6 @@ function VmDetailBody({ // 本地代理固定第一。已绑到本槽的留在下拉里,否则正在用它时列表里没有。 const free = proxiesForVmBind(pool, id, 5, vm.node_id ?? null) const bindTarget = pickBindTarget(bindId, free) - const pol = - (seed.data?.seed_policy as Record | undefined) || {} const syncTelemetry = (routing.data?.official_cc as { sync_telemetry?: boolean } | undefined) ?.sync_telemetry === true @@ -564,13 +569,11 @@ function VmDetailBody({ }} onDelete={() => setConfirmDel(true)} /> - -

- 官方 Claude Code 初装之后的后置覆写。遥测只看这一只开关。 -

+ {seed.data ? ( saveSeed.mutate(next)} diff --git a/web/src/features/vm/queries.ts b/web/src/features/vm/queries.ts index 8acc260b1..c8a917ed9 100644 --- a/web/src/features/vm/queries.ts +++ b/web/src/features/vm/queries.ts @@ -6,6 +6,7 @@ import type { VmCreateOptions, VmCredentialResponse, VmDetailPayload, + VmSeedSettings, VmsListResponse, } from '@/types/panel-vm' import { api } from '@/lib/api' @@ -46,9 +47,10 @@ export function vmSeedQueryOptions(id: string) { return queryOptions({ queryKey: ['panel', 'vm', id, 'seed'] as const, queryFn: () => - api>( + api( `/api/panel/vms/${encodeURIComponent(id)}/seed-settings` ), + refetchInterval: false, }) } diff --git a/web/src/features/vm/seed-policy-card.test.ts b/web/src/features/vm/seed-policy-card.test.ts index f6ac473ff..69a3333c1 100644 --- a/web/src/features/vm/seed-policy-card.test.ts +++ b/web/src/features/vm/seed-policy-card.test.ts @@ -1,18 +1,19 @@ import { describe, expect, it } from 'vitest' -import { alignTelemetryDraft } from './seed-policy-card' +import { seedSaveBody } from './seed-settings' import { telemetryEnabled } from './telemetry-policy' -describe('seed card follows the telemetry bit', () => { +describe('seed save follows the telemetry bit', () => { it('drops contradictory mirrors so the switch stays on', () => { - const draft = alignTelemetryDraft({ + const body = seedSaveBody({ telemetry_disabled: false, disable_nonessential_traffic: false, do_not_track: true, reject_client_settings: true, reject_client_metadata_identity: true, + claude_setting: 'A=1', }) - expect(telemetryEnabled(draft)).toBe(true) - expect(draft.disable_nonessential_traffic).toBe(true) - expect(draft.do_not_track).toBe(false) + expect(telemetryEnabled(body)).toBe(true) + expect(body.disable_nonessential_traffic).toBe(true) + expect(body.do_not_track).toBe(false) }) }) diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx index fd9175b54..561e01544 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -1,179 +1,252 @@ import { useEffect, useState } from 'react' +import type { VmKernelSnapshot, VmSeedSettings } from '@/types/panel-vm' +import { cn } from '@/lib/utils' 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' + REJECT_FLAGS, + seedDraftOf, + seedSaveBody, + settingsRows, + sameDraft, + telemetryLive, + type SeedDraft, +} from '@/features/vm/seed-settings' +import { telemetryEnabled } from '@/features/vm/telemetry-policy' -const REJECT_FLAGS: [string, string][] = [ - ['reject_client_settings', '拒客户端 settings'], - ['reject_client_metadata_identity', '拒客户端身份'], -] +type ObservedTelemetry = NonNullable -type SeedPolicy = Record - -export const SEED_PRESETS: Record = { - standard: { - ...telemetrySeedFlags(false), - reject_client_settings: true, - reject_client_metadata_identity: true, - }, - open: { - ...telemetrySeedFlags(true), - reject_client_settings: false, - reject_client_metadata_identity: false, - }, - strict: { - ...telemetrySeedFlags(false), - reject_client_settings: true, - reject_client_metadata_identity: true, - }, -} - -const PRESET_LABELS: [string, string, string][] = [ - ['standard', '标准', '拒客户端覆写,遥测关;需手动开启'], - ['open', '开放', '不拒客户端;遥测开,非必要流量=1'], - ['strict', '关闭遥测', '关遥测 + DNT;非必要流量=0'], -] - -/** Mirrors follow the telemetry bit. Reject flags are left alone. */ -export function alignTelemetryDraft(draft: SeedPolicy): SeedPolicy { - return { ...draft, ...telemetrySeedFlags(telemetryEnabled(draft)) } -} - -/** - * 预设名 → 提交给后端的种子策略。`theme: 'dark'` 是固定值(非用户可选项)。 - */ -export function seedPolicyOf(name: string): Record { - return { ...(SEED_PRESETS[name] || SEED_PRESETS.standard), theme: 'dark' } -} - -export function matchSeedPreset(policy: Record): string { - const on = telemetryEnabled(policy) - for (const [name, preset] of Object.entries(SEED_PRESETS)) { - if (telemetryEnabled(preset) !== on) continue - if (REJECT_FLAGS.every(([k]) => !!preset[k] === !!policy[k])) return name - } - return 'custom' +function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) { + return ( + + {tone !== 'unknown' ? ( + + ) : null} + + ) } -function normalize(policy: Record): SeedPolicy { - const out: SeedPolicy = { ...telemetrySeedFlags(telemetryEnabled(policy)) } - for (const [k] of REJECT_FLAGS) { - out[k] = k in policy ? !!policy[k] : !!SEED_PRESETS.standard[k] - } - return out +function ToggleRow({ + id, + label, + detail, + checked, + disabled, + onCheckedChange, +}: { + id: string + label: string + detail: string + checked: boolean + disabled: boolean + onCheckedChange: (next: boolean) => void +}) { + return ( +
+
+ +

{detail}

+
+
+ + {checked ? '开' : '关'} + + +
+
+ ) } export function SeedPolicyCard({ - policy, + seed, + telemetry, onSave, saving, syncTelemetry = false, }: { - policy: Record - onSave: (next: SeedPolicy) => void + seed: VmSeedSettings + telemetry?: ObservedTelemetry | null + onSave: (next: Record) => void saving: boolean /** External official_cc.sync_telemetry. True means the next init turns telemetry back on. */ syncTelemetry?: boolean }) { - const [draft, setDraft] = useState(() => - alignTelemetryDraft(normalize(policy)) - ) + const saved = seedDraftOf(seed) + const [draft, setDraft] = useState(saved) useEffect(() => { - setDraft(alignTelemetryDraft(normalize(policy))) - }, [policy]) + setDraft(seedDraftOf(seed)) + }, [seed]) - const current = matchSeedPreset(draft) - const saved = normalize(policy) - const dirty = - telemetryEnabled(draft) !== telemetryEnabled(saved) || - REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) + const dirty = !sameDraft(draft, saved) const on = telemetryEnabled(draft) + const live = telemetryLive(seed, telemetry) + const fileSetting = + typeof seed.claude_setting === 'string' ? seed.claude_setting : '' + const rows = settingsRows(seed.settings_json, fileSetting) + const fileMissing = seed.settings_json == null + + const patch = (next: Partial) => + setDraft((current) => ({ ...current, ...next })) return ( - - - 种子策略 - - -
-
- {PRESET_LABELS.map(([key, label, desc]) => ( - - ))} - {current === 'custom' ? ( - - ) : null} -
-

- {current === 'custom' - ? '当前组合不匹配任何预设。' - : PRESET_LABELS.find(([k]) => k === current)?.[2]} +

+
+
+

当前槽 settings.json

+

+ {fileMissing ? '文件未写入' : `${rows.length} 项`} +

+
+ {fileMissing ? ( +

+ 这槽还没有 settings.json。播种之后,这里显示写进文件的项。 +

+ ) : rows.length === 0 ? ( +

+ 文件在,但没有 env 项。

+ ) : ( +
+ + + {rows.map((row) => ( + + + + + ))} + +
+ + {row.inClaudeSetting ? ( + + ) : ( + + )} + {row.key} + + + {row.value === '' ? '空' : row.value} +
+
+ )} +
+ +