From ce99542a8fc3ee76b7a23d71c9e2328efa982dfc Mon Sep 17 00:00:00 2001 From: dofastted Date: Mon, 19 Oct 2026 15:03:53 +0800 Subject: [PATCH 01/12] feat(seed): add editable claude.setting to seed policy --- docs/PANEL_API.md | 2 +- src/lib/admin/panel-routes.mjs | 19 ++++++++++-- src/lib/cluster/remote-slot-files.mjs | 7 ++++- src/lib/protocol/seed-policy.mjs | 11 +++++++ src/lib/vm/slot-seed.mjs | 11 ++++++- test/unit/seed-policy.test.mjs | 6 ++++ test/unit/slot-seed.test.mjs | 17 +++++++++++ web/src/features/vm/seed-policy-card.tsx | 37 ++++++++++++++++++++++-- web/src/types/panel-vm.ts | 1 + 9 files changed, 104 insertions(+), 7 deletions(-) diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md index 2390218dc..a073c2041 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` | diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 36bbfb877..2278ceafd 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -3524,21 +3524,33 @@ export function createPanelHandler(ctx) { if (!vm) return json(res, 404, { ok: false, error: { message: 'vm not found' } }) const homeDir = path.join(cfg.paths.project, 'vms', id, 'cli-home') let settings_json = null + let claude_setting = null let kin_seed = null try { settings_json = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf8')) } catch {} + try { + claude_setting = fs + .readFileSync(path.join(homeDir, '.claude', 'claude.setting'), 'utf8') + .replace(/\r\n?/g, '\n') + .replace(/\n+$/g, '') + } catch { + claude_setting = defaultSeedPolicy(vm.seed_policy || {}).claude_setting + } try { kin_seed = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'kin-seed.json'), 'utf8')) } catch {} + const seed_policy = defaultSeedPolicy(vm.seed_policy || {}) + if (claude_setting !== null) seed_policy.claude_setting = claude_setting return json( res, 200, panel.ok({ vm_id: id, - seed_policy: defaultSeedPolicy(vm.seed_policy || {}), - ...seedTelemetryContract(defaultSeedPolicy(vm.seed_policy || {})), + seed_policy, + ...seedTelemetryContract(seed_policy), settings_json, + claude_setting, kin_seed, cli_home: homeDir, }), @@ -3559,6 +3571,8 @@ export function createPanelHandler(ctx) { if (body.theme !== undefined) merged.theme = body.theme if (body.extra_env !== undefined) merged.extra_env = body.extra_env if (body.settings_json_override !== undefined) merged.settings_json_override = body.settings_json_override + if (body.claude_setting !== undefined) merged.claude_setting = body.claude_setting + if (body['claude.setting'] !== undefined) merged.claude_setting = body['claude.setting'] const next = defaultSeedPolicy(merged) if (body.settings_json_override !== undefined) next.settings_json_override = body.settings_json_override if (body.extra_env !== undefined) next.extra_env = body.extra_env || {} @@ -3579,6 +3593,7 @@ 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, }), ) diff --git a/src/lib/cluster/remote-slot-files.mjs b/src/lib/cluster/remote-slot-files.mjs index de427cd4a..7d64cf3fd 100644 --- a/src/lib/cluster/remote-slot-files.mjs +++ b/src/lib/cluster/remote-slot-files.mjs @@ -31,7 +31,12 @@ 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' diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs index 6b60f9d0c..4d8de1743 100644 --- a/src/lib/protocol/seed-policy.mjs +++ b/src/lib/protocol/seed-policy.mjs @@ -30,6 +30,16 @@ export function isTelemetryEnabled(pol = {}) { */ export const NONESSENTIAL_TRAFFIC_ENV_KEY = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +// Keep a visible, editable copy of the Claude opt-out setting next to the +// generated settings.json. The file is intentionally plain text so operators +// can inspect or replace it without having to edit JSON. +export const DEFAULT_CLAUDE_SETTING = `${NONESSENTIAL_TRAFFIC_ENV_KEY}=1` + +export function normalizeClaudeSetting(value) { + if (typeof value !== 'string') return DEFAULT_CLAUDE_SETTING + return value.replace(/\r\n?/g, '\n').replace(/\n+$/g, '') +} + export const TELEMETRY_KILL_ENV = Object.freeze({ DISABLE_TELEMETRY: '1', }) @@ -141,6 +151,7 @@ export function defaultSeedPolicy(partial = {}) { reject_client_metadata_identity: partial.reject_client_metadata_identity !== false, theme: partial.theme || 'dark', extra_env: partial.extra_env && typeof partial.extra_env === 'object' ? partial.extra_env : {}, + claude_setting: normalizeClaudeSetting(partial.claude_setting), settings_json_override: partial.settings_json_override && typeof partial.settings_json_override === 'object' ? partial.settings_json_override diff --git a/src/lib/vm/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/seed-policy.test.mjs b/test/unit/seed-policy.test.mjs index 692e6a791..390110c1d 100644 --- a/test/unit/seed-policy.test.mjs +++ b/test/unit/seed-policy.test.mjs @@ -7,8 +7,14 @@ import { buildSeedSettingsEnv, stripLegacyScriptEnv, LEGACY_SCRIPT_ENV_KEYS, + DEFAULT_CLAUDE_SETTING, } from '../../src/lib/protocol/seed-policy.mjs' +test('default seed policy includes the editable claude setting', () => { + assert.equal(defaultSeedPolicy().claude_setting, DEFAULT_CLAUDE_SETTING) + assert.equal(defaultSeedPolicy({ claude_setting: 'FOO=bar\r\n' }).claude_setting, 'FOO=bar') +}) + test('explicit opt-in removes telemetry kill switches; omitted policy retains them', () => { assert.equal(buildSlotSettingsEnv(standardSeedPolicy()).DISABLE_TELEMETRY, '1') assert.equal(buildSlotSettingsEnv(standardSeedPolicy({ telemetry_disabled: false })).DISABLE_TELEMETRY, undefined) diff --git a/test/unit/slot-seed.test.mjs b/test/unit/slot-seed.test.mjs index 7fa801ac8..0dbcacb3c 100644 --- a/test/unit/slot-seed.test.mjs +++ b/test/unit/slot-seed.test.mjs @@ -42,6 +42,7 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => const out = writeSlotSeedFiles(root, vm) assert.equal(out.wrote, true) const settings = JSON.parse(fs.readFileSync(path.join(leftover, 'settings.json'), 'utf8')) + const claudeSetting = fs.readFileSync(path.join(leftover, 'claude.setting'), 'utf8') const seed = JSON.parse(fs.readFileSync(path.join(leftover, 'kin-seed.json'), 'utf8')) assert.equal(settings.env.DISABLE_TELEMETRY, undefined) assert.equal(settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1') @@ -52,6 +53,8 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => assert.equal(settings.env.TZ, 'America/Los_Angeles') assert.equal(settings.autoUpdates, false) assert.equal(settings.grove_enabled, false) + assert.equal(claudeSetting, 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n') + assert.equal(out.claude_setting, 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1') assert.equal(seed.seed_policy.grove_enabled, false) assert.equal(seed.seed_policy.disable_nonessential_traffic, true) assert.equal(seed.schema, KIN_SEED_SCHEMA) @@ -63,6 +66,20 @@ test('writeSlotSeedFiles writes latest settings and workstation kin-seed', () => fs.rmSync(root, { recursive: true, force: true }) }) +test('writeSlotSeedFiles preserves an editable claude.setting', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-slot-setting-')) + const vm = { + id: 'vm-setting', + seed_policy: standardSeedPolicy({ claude_setting: 'FOO=bar\r\nBAZ=qux\n' }), + } + writeSlotSeedFiles(root, vm) + assert.equal( + fs.readFileSync(path.join(root, 'vms', vm.id, 'cli-home', '.claude', 'claude.setting'), 'utf8'), + 'FOO=bar\nBAZ=qux\n', + ) + fs.rmSync(root, { recursive: true, force: true }) +}) + test('applySeedAfterOfficialInit uses the same writer', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-init-seed-')) const vm = { diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx index fd9175b54..2ff1573f9 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -3,31 +3,38 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { telemetryEnabled, telemetrySeedFlags, } from '@/features/vm/telemetry-policy' +export const DEFAULT_CLAUDE_SETTING = + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1' + const REJECT_FLAGS: [string, string][] = [ ['reject_client_settings', '拒客户端 settings'], ['reject_client_metadata_identity', '拒客户端身份'], ] -type SeedPolicy = Record +type SeedPolicy = Record export const SEED_PRESETS: Record = { standard: { ...telemetrySeedFlags(false), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: true, reject_client_metadata_identity: true, }, open: { ...telemetrySeedFlags(true), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: false, reject_client_metadata_identity: false, }, strict: { ...telemetrySeedFlags(false), + claude_setting: DEFAULT_CLAUDE_SETTING, reject_client_settings: true, reject_client_metadata_identity: true, }, @@ -62,6 +69,10 @@ export function matchSeedPreset(policy: Record): string { function normalize(policy: Record): SeedPolicy { const out: SeedPolicy = { ...telemetrySeedFlags(telemetryEnabled(policy)) } + out.claude_setting = + typeof policy.claude_setting === 'string' + ? policy.claude_setting + : DEFAULT_CLAUDE_SETTING for (const [k] of REJECT_FLAGS) { out[k] = k in policy ? !!policy[k] : !!SEED_PRESETS.standard[k] } @@ -91,7 +102,8 @@ export function SeedPolicyCard({ const saved = normalize(policy) const dirty = telemetryEnabled(draft) !== telemetryEnabled(saved) || - REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) + REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) || + draft.claude_setting !== saved.claude_setting const on = telemetryEnabled(draft) return ( @@ -169,6 +181,27 @@ export function SeedPolicyCard({ : ' 外部初装「同步遥测」关着,换票不会改写这里的遥测开关。'}

+
+ +