From be8ff978b589c1078ca40e5f90cc4c334cf5a15c Mon Sep 17 00:00:00 2001
From: dofastted
Date: Mon, 19 Oct 2026 15:03:53 +0800
Subject: [PATCH 1/3] 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 968f15ac1..b807e0311 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 10ef5f0d1..7276601bd 100644
--- a/src/lib/cluster/remote-slot-files.mjs
+++ b/src/lib/cluster/remote-slot-files.mjs
@@ -33,7 +33,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({
: ' 外部初装「同步遥测」关着,换票不会改写这里的遥测开关。'}
+
+