Repository navigation
种子页可编辑当前槽配置,并记录遥测上传 #610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
种子页可编辑当前槽配置,并记录遥测上传 #610
Changes from all commits
ce99542
d7edadf
a7c3dfb
8997953
bb0a63c
a467c45
acb82f4
ef9a730
09a125f
0302892
e615b2f
85db559
2c62e8e
7776c55
c399c2d
f8c0246
635008b
9e61a36
3099699
fb2bd5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # Product | ||
|
|
||
| <!-- impeccable:product-schema 1 --> | ||
|
|
||
| ## Platform | ||
|
|
||
| web | ||
|
|
||
| ## Users | ||
|
|
||
| 本机管理员。在深色运维控制台里打开某个虚拟机槽位,核对并改写该槽 Claude Code 的种子配置。 | ||
|
|
||
| ## Product Purpose | ||
|
|
||
| vm2api 把 Claude / Codex 账号做成可调度的虚拟机槽,并对外提供兼容上游的 API。种子页的成功标准:管理员不用进容器,就能看见当前槽 `settings.json` 里实际生效的配置项,改完能播种,并实时看到遥测开或关。 | ||
|
|
||
| ## Positioning | ||
|
|
||
| 种子不是一份预设表单。它读当前槽家目录里已经写好的 Claude 配置,按文件内容逐项显示,再把改动写回同一份文件。 | ||
|
|
||
| ## Operating Context | ||
|
|
||
| 管理员在 `/vm/$id` 的「种子」页签工作。数据来自该槽 `cli-home/.claude/settings.json`、`claude.setting` 和种子策略。遥测进程状态来自槽详情里的实时观测。 | ||
|
|
||
| ## Capabilities and Constraints | ||
|
|
||
| - 显示当前槽 `settings.json` 的真实键值,而不是只显示种子策略里的默认串。 | ||
| - `claude.setting` 以可编辑文本提供,保存时写回并重新播种。 | ||
| - 遥测开、关状态实时刷新;配置开关和进程是否在跑是两件事。 | ||
| - 「拒客户端 settings」和「拒客户端身份」按当前策略准确显示,并可切换。 | ||
| - 平台是现有 React 面板,不另起一套视觉系统。 | ||
|
|
||
| ## Product Principles | ||
|
|
||
| - 槽内文件是事实,表单默认值不是。 | ||
| - 开和关都要能被看见,未知不能伪装成关闭。 | ||
| - 能改的项和只读观测分开。 | ||
| - 播种是显式动作,不在切换开关时偷偷写盘。 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # VM telemetry service | ||
|
|
||
| Telemetry is an explicit seed-policy opt-in. A newly created VM is disabled; | ||
| setting `telemetry_disabled` to `false` writes the VM's `worker.json` identity | ||
| and starts the Go `kin-worker telemetry` service. Setting it back to `true` | ||
| stops that child process and leaves the VM and its Rust kernel running. | ||
|
|
||
| The service reads credentials only from the VM's own Claude home. Its feature | ||
| payload uses facts collected from that VM (including its timezone, OS, | ||
| hardware, shell and runtime versions), never controller-host defaults or a | ||
| synthetic fingerprint. Requests leave through the VM's bound egress, so the | ||
| service observes the VM's actual network address. Uploads are periodic and are idle-gated | ||
| by `run/telemetry.touch`. | ||
|
|
||
| `claude.setting` is a plain, editable seed file beside `settings.json` and is | ||
| initialized with: | ||
|
|
||
| ```text | ||
| CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 | ||
| ``` | ||
|
|
||
| ## Service-only updates | ||
|
|
||
| Go payloads are copied atomically into each slot's persistent `slot-bin` | ||
| directory. Cluster containers bind that directory at `/opt/kin-live` (and the | ||
| KVM guest payload at `/opt/kin-live`). The panel/fleet `telemetry` action | ||
| restarts only the telemetry child and the `kin-egress` helper; it does not | ||
| rebuild a VM, replace `vms/`, rotate credentials, or restart the Rust kernel. | ||
|
|
||
| An update must verify the new binary hash and the replacement process before | ||
| reporting success. A failed service restart is reported per slot so the old VM | ||
| identity remains intact and can be rolled back by republishing the previous | ||
| binary. | ||
|
|
||
| For a controlled rollout, run `scripts/migrate-legacy-cluster-slots.mjs` in | ||
| the control-plane container. Pass VM IDs after the root and concurrency | ||
| arguments to limit the rollout. It performs the same migration used by the | ||
| one-click installer: legacy slot and egress containers are stopped, renamed to | ||
| `*-legacy-<timestamp>`, and kept as stopped rollback copies; replacement | ||
| containers use the persistent `/opt/kin-live` mount. Container IDs therefore | ||
| change, but the VM, credentials, `vms/`, routing, and network identity do not. | ||
| After this one-time migration, subsequent updates atomically replace only the | ||
| Go binaries in `slot-bin` and restart the service processes. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs' | ||
|
|
||
| const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api' | ||
| const concurrency = Number(process.argv[3] || 4) | ||
| const report = await runFleetUpdate(root, { | ||
| action: 'telemetry', | ||
| concurrency, | ||
| }) | ||
| console.log(JSON.stringify(report)) | ||
| if (report.failed_count) process.exit(1) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| /** | ||
| * Migrate legacy cluster slot containers to the persistent Go-service mount. | ||
| * This never rebuilds a VM: the old container is renamed and retained as a | ||
| * stopped rollback copy, while the replacement reuses its image and settings. | ||
| */ | ||
| import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs' | ||
|
|
||
| const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api' | ||
| const concurrency = Number(process.argv[3] || process.env.KIN_FLEET_CONCURRENCY || 4) | ||
| const ids = process.argv | ||
| .slice(4) | ||
| .flatMap((value) => String(value).split(',')) | ||
| .map((value) => value.trim()) | ||
| .filter(Boolean) | ||
|
|
||
| const report = await runFleetUpdate(root, { | ||
| action: 'telemetry', | ||
| concurrency, | ||
| ids: ids.length ? ids : null, | ||
| }) | ||
| console.log(JSON.stringify(report)) | ||
| if (report.failed_count) process.exitCode = 1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3524,25 +3524,36 @@ 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 {} | ||
| return json( | ||
| res, | ||
| 200, | ||
| panel.ok({ | ||
| vm_id: id, | ||
| seed_policy: defaultSeedPolicy(vm.seed_policy || {}), | ||
| ...seedTelemetryContract(defaultSeedPolicy(vm.seed_policy || {})), | ||
| settings_json, | ||
| kin_seed, | ||
| cli_home: homeDir, | ||
| }), | ||
| ) | ||
| const seed_policy = defaultSeedPolicy(vm.seed_policy || {}) | ||
| if (claude_setting !== null) seed_policy.claude_setting = claude_setting | ||
| const debug = new URL(req.url || '/', 'http://localhost').searchParams.get('debug') === '1' | ||
| const payload = { | ||
| vm_id: id, | ||
| seed_policy, | ||
| ...seedTelemetryContract(seed_policy), | ||
| settings_json, | ||
| claude_setting, | ||
| kin_seed, | ||
| cli_home: homeDir, | ||
| } | ||
| if (debug) payload.telemetry_log = readTelemetryUploadLog(cfg.paths.project, id) | ||
| return json(res, 200, panel.ok(payload)) | ||
| } | ||
| // PUT /api/panel/vms/:id/seed-settings | ||
| if (req.method === 'PUT' && /^\/api\/panel\/vms\/[^/]+\/seed-settings$/.test(p)) { | ||
|
|
@@ -3559,17 +3570,27 @@ export function createPanelHandler(ctx) { | |
| if (body.theme !== undefined) merged.theme = body.theme | ||
| if (body.extra_env !== undefined) merged.extra_env = body.extra_env | ||
| if (body.settings_json_override !== undefined) merged.settings_json_override = body.settings_json_override | ||
| if (body.claude_setting !== undefined) merged.claude_setting = body.claude_setting | ||
| if (body['claude.setting'] !== undefined) merged.claude_setting = body['claude.setting'] | ||
| const next = defaultSeedPolicy(merged) | ||
| if (body.settings_json_override !== undefined) next.settings_json_override = body.settings_json_override | ||
| if (body.extra_env !== undefined) next.extra_env = body.extra_env || {} | ||
| vm.seed_policy = next | ||
| vm.updated_at = new Date().toISOString() | ||
| atomicWriteJson(vmPath, vm, { mode: 0o600 }) | ||
| const written = writeSlotSeedFiles(cfg.paths.project, vm, next) | ||
| if (next.telemetry_disabled === false && telemetryWasOff) { | ||
| try { | ||
| await reloadSlotReady(vm, cfg.paths.project, { routing: ctx.routingConfig }) | ||
| } catch {} | ||
| let service = null | ||
| if ( | ||
| vm.status === 'running' && | ||
| (next.telemetry_disabled !== telemetryWasOff || body.telemetry_service_restart === true) | ||
| ) { | ||
| const report = await runFleetUpdate(cfg.paths.project, { | ||
| action: 'telemetry', | ||
| ids: [id], | ||
| concurrency: 1, | ||
| routing: ctx.routingConfig, | ||
| }) | ||
| service = report.items?.[0] || report | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seed save restarts kin-egressHigh Severity Toggling telemetry on a seed save now runs fleet Additional Locations (2)Triggered by learned rule: Slot-bin v3; telemetry fleet restarts Go only Reviewed by Cursor Bugbot for commit fb2bd5f. Configure here. |
||
| } | ||
| return json( | ||
| res, | ||
|
|
@@ -3579,7 +3600,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, | ||
| }), | ||
| ) | ||
| } | ||
|
|
@@ -5754,3 +5777,13 @@ export function createPanelHandler(ctx) { | |
| return false | ||
| } | ||
| } | ||
|
|
||
| function readTelemetryUploadLog(projectRoot, id) { | ||
| if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,80}$/.test(String(id || ''))) return [] | ||
| try { | ||
| const text = fs.readFileSync(path.join(projectRoot, 'vms', id, 'run', 'telemetry-upload.log'), 'utf8') | ||
| return text.split(/\r?\n/).filter(Boolean).slice(-40) | ||
| } catch { | ||
| return [] | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Agentic Security Review
Severity: HIGH
Host GET
/api/panel/vms/:id/seed-settingsnowreadFileSyncscli-home/.claude/claude.setting(and, with?debug=1,vms/<id>/run/telemetry-upload.log). PUT save writes the sameclaude.settingpath viawriteSlotSeedFilesusingwriteFileSync. Both directories are guest-writable bind mounts (/home/kincli,/run/kin). Cluster host I/O already treats the final path component as attacker-controlled and requires O_NOFOLLOW / temp+rename so a guest cannot redirect the control plane.Impact: A process in the slot can replace those names with a symlink (for example to a sibling slot's
credentials.jsonorinternal.token). An admin opening or saving the seed page then leaks or overwrites the target in the host namespace. A FIFO on the same path can also stall the shared Node process.Reviewed by Cursor Security Reviewer for commit fb2bd5f. Configure here.