diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72dfa50c6..1b4ae9510 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## 未发布
+- 槽位种子页:`settings.json` 的 env 行与 `theme` 可直接编辑并随播种写入;`autoUpdates`、`grove_enabled` 只读且播种时固定为 `false`;TZ/LANG/LC_ALL、遥测 kill-switch、Bedrock/Vertex、gateway hint 等同理只读(播种由契约重写)。缺 `CLAUDE_CODE_ATTRIBUTION_HEADER` 时默认 `0`。调试上传默认关,独立 query 拉最近 40 条上传,切换开关不重置未保存编辑;Go sidecar 写 `vms//run/telemetry-upload.log`,面板 `GET seed-settings?debug=1` 返回 `telemetry_log`。
- 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。
- fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/` 消失后再绑定新二进制;同一 SOCKS 出口的 stop/start 与 `ensureProxyEgress` 共用 per-proxy 锁,避免并发 telemetry 互相 SIGTERM。(#604)
- 面板:侧栏「GitHub / 文档」两枚链接不再折行,字体偏宽时「文档」不会逐字竖排,空间不够先缩箭头再省略文字;VM 列表搜索框关闭浏览器自动填充,不再被回填成登录邮箱。
diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md
index 99ebe9c18..dc8d8ed35 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;`claude_setting` 可编辑并写入槽内 `.claude/claude.setting` |
+| GET/PUT | `/vms/:id/seed-settings` | 播种;`settings.json` env 与 `theme` 经 PUT `extra_env` / `theme` 可编辑;`autoUpdates`/`grove_enabled` 播种固定;强制保留 telemetry/bedrock/vertex 等 env;`claude_setting` 可编辑并写入槽内 `.claude/claude.setting`。`GET ?debug=1` 额外返回 `telemetry_log`(最近 40 行脱敏上传审计,默认不带) |
| 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/docs/TELEMETRY.md b/docs/TELEMETRY.md
index 4920ccdb0..cd440e339 100644
--- a/docs/TELEMETRY.md
+++ b/docs/TELEMETRY.md
@@ -31,3 +31,13 @@ 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.
+
+## Upload audit log (debug)
+
+Each successful or failed `PostBatch` / GrowthBook eval append one line to
+`vms//run/telemetry-upload.log` (next to `worker.json`). The file keeps at
+most 40 lines. Payload JSON is the request shape (`events` wrapper for batch
+uploads) with identity fields stripped (email, account/org UUIDs, device and
+session ids). The panel seed tab exposes this only when **调试上传** is on,
+via `GET /api/panel/vms/:id/seed-settings?debug=1` → `telemetry_log` (string
+array, last 40 lines).
diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs
index e2fb1027f..1a277da9f 100644
--- a/src/lib/admin/panel-routes.mjs
+++ b/src/lib/admin/panel-routes.mjs
@@ -3542,19 +3542,18 @@ export function createPanelHandler(ctx) {
} 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,
- ...seedTelemetryContract(seed_policy),
- settings_json,
- claude_setting,
- kin_seed,
- cli_home: homeDir,
- }),
- )
+ 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)) {
@@ -5778,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 []
+ }
+}
diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs
index 4d8de1743..81b73c80c 100644
--- a/src/lib/protocol/seed-policy.mjs
+++ b/src/lib/protocol/seed-policy.mjs
@@ -114,6 +114,7 @@ export function buildSlotSettingsEnv(pol = {}, { timezone, locale, extra } = {})
const out = applyRequiredSeedEnv(env, pol)
// 2.1.283+ sends x-claude-code-prompt-id only when this is set. Default on.
out.CLAUDE_CODE_GATEWAY_HINT_HEADERS = '1'
+ if (out.CLAUDE_CODE_ATTRIBUTION_HEADER == null) out.CLAUDE_CODE_ATTRIBUTION_HEADER = '0'
return out
}
diff --git a/test/unit/panel-vm-seed-settings.test.mjs b/test/unit/panel-vm-seed-settings.test.mjs
index 5926df0bb..1c53c907d 100644
--- a/test/unit/panel-vm-seed-settings.test.mjs
+++ b/test/unit/panel-vm-seed-settings.test.mjs
@@ -73,3 +73,35 @@ test('GET seed-settings returns an empty claude.setting file as empty', async ()
fs.rmSync(root, { recursive: true, force: true })
}
})
+
+test('GET seed-settings returns the upload log only when debug is on', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-log-'))
+ try {
+ seed(root, 'vm-01')
+ const run = path.join(root, 'vms', 'vm-01', 'run')
+ fs.mkdirSync(run, { recursive: true })
+ fs.writeFileSync(
+ path.join(run, 'telemetry-upload.log'),
+ 'old\n2026-10-19T00:00:00Z batch tengu_init ok {"event_type":"x"}\n',
+ )
+ const hidden = handler(root)
+ await hidden.handlePanel(
+ { method: 'GET', url: '/api/panel/vms/vm-01/seed-settings' },
+ {},
+ new URL('http://localhost/api/panel/vms/vm-01/seed-settings'),
+ )
+ assert.equal(hidden.response.body.data.telemetry_log, undefined)
+ const shown = handler(root)
+ await shown.handlePanel(
+ { method: 'GET', url: '/api/panel/vms/vm-01/seed-settings?debug=1' },
+ {},
+ new URL('http://localhost/api/panel/vms/vm-01/seed-settings?debug=1'),
+ )
+ assert.deepEqual(shown.response.body.data.telemetry_log, [
+ 'old',
+ '2026-10-19T00:00:00Z batch tengu_init ok {"event_type":"x"}',
+ ])
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
diff --git a/test/unit/seed-policy.test.mjs b/test/unit/seed-policy.test.mjs
index 390110c1d..b7f5ec166 100644
--- a/test/unit/seed-policy.test.mjs
+++ b/test/unit/seed-policy.test.mjs
@@ -69,6 +69,7 @@ test('buildSlotSettingsEnv is TZ/LANG plus contract, leftover extra cannot win',
assert.equal(env.LC_ALL, 'en_US.UTF-8')
assert.equal(env.CUSTOM, 'ok')
assert.equal(env.CLAUDE_CODE_GATEWAY_HINT_HEADERS, '1')
+ assert.equal(env.CLAUDE_CODE_ATTRIBUTION_HEADER, '0')
})
test('buildSeedSettingsEnv delegates to slot env', () => {
diff --git a/web/src/features/vm/detail-page.tsx b/web/src/features/vm/detail-page.tsx
index c8b5fc32b..f33fae213 100644
--- a/web/src/features/vm/detail-page.tsx
+++ b/web/src/features/vm/detail-page.tsx
@@ -69,6 +69,7 @@ import { probeOutcome, type ProbeCheck } from '@/features/vm/probe-status'
import {
vmQueryOptions,
vmSeedQueryOptions,
+ vmSeedUploadLogQueryOptions,
vmsListQueryOptions,
} from '@/features/vm/queries'
import { RuntimeChip } from '@/features/vm/runtime-chip'
@@ -117,6 +118,7 @@ function VmDetailBody({
onTabChange: (tab: string) => void
}) {
const navigate = useNavigate()
+ const [seedDebug, setSeedDebug] = useState(false)
const qc = useQueryClient()
const perms = useVmPerms()
const dash = useQuery({
@@ -138,6 +140,11 @@ function VmDetailBody({
enabled: perms.admin && wantedTab === 'seed',
refetchInterval: wantedTab === 'seed' ? 2000 : false,
})
+ const seedUploadLog = useQuery({
+ ...vmSeedUploadLogQueryOptions(id),
+ enabled: perms.admin && wantedTab === 'seed' && seedDebug,
+ refetchInterval: seedDebug && wantedTab === 'seed' ? 2000 : false,
+ })
const routing = useQuery({ ...routingQueryOptions(), enabled: perms.admin })
usePoolSeatStream()
@@ -235,7 +242,10 @@ function VmDetailBody({
onSuccess: async () => {
toast.success('已播种')
await qc.invalidateQueries({
- queryKey: vmSeedQueryOptions(id).queryKey,
+ queryKey: ['panel', 'vm', id, 'seed'],
+ })
+ await qc.invalidateQueries({
+ queryKey: ['panel', 'vm', id, 'seed-upload-log'],
})
},
onError: (error: Error) => toast.error(error.message),
@@ -576,6 +586,9 @@ function VmDetailBody({
telemetry={kernel?.telemetry}
saving={saveSeed.isPending}
syncTelemetry={syncTelemetry}
+ debug={seedDebug}
+ onDebug={setSeedDebug}
+ uploadLog={seedUploadLog.data}
onSave={(next) => saveSeed.mutate(next)}
/>
) : (
diff --git a/web/src/features/vm/queries.ts b/web/src/features/vm/queries.ts
index c8a917ed9..d3e3a7cad 100644
--- a/web/src/features/vm/queries.ts
+++ b/web/src/features/vm/queries.ts
@@ -54,6 +54,25 @@ export function vmSeedQueryOptions(id: string) {
})
}
+/** Upload audit lines only — does not share the seed form query key. */
+export function vmSeedUploadLogQueryOptions(id: string) {
+ return queryOptions({
+ queryKey: ['panel', 'vm', id, 'seed-upload-log'] as const,
+ queryFn: async () => {
+ const data = await api(
+ `/api/panel/vms/${encodeURIComponent(id)}/seed-settings?debug=1`
+ )
+ const lines = data.telemetry_log
+ return Array.isArray(lines)
+ ? lines.filter(
+ (line): line is string => typeof line === 'string' && line !== ''
+ )
+ : []
+ },
+ refetchInterval: false,
+ })
+}
+
export function vmCredentialQueryOptions(id: string, enabled = true) {
return queryOptions({
queryKey: ['panel', 'vm', id, 'credential'] as const,
diff --git a/web/src/features/vm/seed-policy-card.test.ts b/web/src/features/vm/seed-policy-card.test.ts
index 69a3333c1..4f281b08d 100644
--- a/web/src/features/vm/seed-policy-card.test.ts
+++ b/web/src/features/vm/seed-policy-card.test.ts
@@ -11,6 +11,8 @@ describe('seed save follows the telemetry bit', () => {
reject_client_settings: true,
reject_client_metadata_identity: true,
claude_setting: 'A=1',
+ env: {},
+ theme: 'dark',
})
expect(telemetryEnabled(body)).toBe(true)
expect(body.disable_nonessential_traffic).toBe(true)
diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx
index 561e01544..8780c566d 100644
--- a/web/src/features/vm/seed-policy-card.tsx
+++ b/web/src/features/vm/seed-policy-card.tsx
@@ -2,11 +2,14 @@ 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
REJECT_FLAGS,
+ claudeSettingKeys,
+ partitionSeedEnvKeys,
seedDraftOf,
seedSaveBody,
settingsRows,
@@ -40,6 +43,20 @@ function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) {
)
}
+function TelemetryUploadLog({ lines }: { lines: unknown }) {
+ const rows = Array.isArray(lines)
+ ? lines.filter(
+ (line): line is string => typeof line === 'string' && line !== ''
+ )
+ : []
+ return (
+
+ {rows.length
+ ? rows.join('\n')
+ : '还没有上传记录。遥测开着并完成一次上传后会出现。'}
+
+ )
+}
function ToggleRow({
id,
@@ -90,13 +107,18 @@ export function SeedPolicyCard({
onSave,
saving,
syncTelemetry = false,
+ debug,
+ onDebug,
+ uploadLog,
}: {
seed: VmSeedSettings
telemetry?: ObservedTelemetry | null
- onSave: (next: Record) => void
+ onSave: (next: Record) => void
saving: boolean
- /** External official_cc.sync_telemetry. True means the next init turns telemetry back on. */
syncTelemetry?: boolean
+ debug: boolean
+ onDebug: (next: boolean) => void
+ uploadLog?: string[] | null
}) {
const saved = seedDraftOf(seed)
const [draft, setDraft] = useState(saved)
@@ -110,7 +132,10 @@ export function SeedPolicyCard({
const fileSetting =
typeof seed.claude_setting === 'string' ? seed.claude_setting : ''
const rows = settingsRows(seed.settings_json, fileSetting)
+ const claudeNamed = claudeSettingKeys(fileSetting)
const fileMissing = seed.settings_json == null
+ const { editable: editableEnvKeys, readonly: readonlyEnvKeys } =
+ partitionSeedEnvKeys(draft.env)
const patch = (next: Partial) =>
setDraft((current) => ({ ...current, ...next }))
@@ -133,34 +158,85 @@ export function SeedPolicyCard({
文件在,但没有 env 项。
) : (
-
-
-
- {rows.map((row) => (
-
- |
-
- {row.inClaudeSetting ? (
-
- ) : (
-
- )}
- {row.key}
-
- |
-
- {row.value === '' ? '空' : row.value}
- |
-
- ))}
-
-
+
+ {editableEnvKeys.map((key) => (
+
+ ))}
+
+ {readonlyEnvKeys.map((key) => (
+
+
+ {claudeNamed.has(key) ? (
+
+ ) : (
+
+ )}
+ {key}
+
+
+ {draft.env[key] === '' ? '空' : draft.env[key]} · 播种时固定
+
+
+ ))}
+ {rows
+ .filter(
+ (row) =>
+ row.key === 'autoUpdates' || row.key === 'grove_enabled'
+ )
+ .map((row) => (
+
+
+ {row.key}
+
+
+ {row.value} · 播种时固定
+
+
+ ))}
)}
@@ -259,6 +335,19 @@ export function SeedPolicyCard({
重置
+
+
+ {debug ? : null}
+
)
diff --git a/web/src/features/vm/seed-settings.test.ts b/web/src/features/vm/seed-settings.test.ts
index 535f7674f..74ce52519 100644
--- a/web/src/features/vm/seed-settings.test.ts
+++ b/web/src/features/vm/seed-settings.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
+import { vmSeedQueryOptions, vmSeedUploadLogQueryOptions } from './queries'
import {
claudeSettingKeys,
+ editableSeedEnv,
+ isReadonlySeedEnvKey,
+ partitionSeedEnvKeys,
seedDraftOf,
seedSaveBody,
settingsRows,
@@ -37,6 +41,7 @@ describe('current slot settings', () => {
['DISABLE_TELEMETRY', '1', false],
['DO_NOT_TRACK', '1', false],
['CUSTOM_FLAG', 'kept', false],
+ ['CLAUDE_CODE_ATTRIBUTION_HEADER', '0', false],
['theme', 'dark', false],
['autoUpdates', 'false', false],
])
@@ -94,6 +99,121 @@ describe('seed draft', () => {
.claude_setting
).toBe('')
})
+
+ it('saves edited settings env, including the attribution default', () => {
+ const draft = seedDraftOf({
+ settings_json: { env: { CUSTOM: 'before' } },
+ claude_setting: '',
+ })
+ expect(draft.env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
+ draft.env.CUSTOM = 'after'
+ expect(seedSaveBody(draft).extra_env).toMatchObject({
+ CUSTOM: 'after',
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
+ })
+ })
+
+ it('omits contract-locked env keys from extra_env on save', () => {
+ const draft = seedDraftOf({
+ settings_json: {
+ env: {
+ TZ: 'Asia/Shanghai',
+ CUSTOM_FLAG: 'kept',
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '0',
+ CLAUDE_CODE_GATEWAY_HINT_HEADERS: '1',
+ },
+ },
+ claude_setting: '',
+ })
+ draft.env.CUSTOM_FLAG = 'edited'
+ draft.env.TZ = 'UTC'
+ expect(editableSeedEnv(draft.env)).toEqual({
+ CUSTOM_FLAG: 'edited',
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
+ })
+ expect(seedSaveBody(draft).extra_env).toEqual({
+ CUSTOM_FLAG: 'edited',
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
+ })
+ })
+
+ it('keeps theme editable and leaves fixed settings visible', () => {
+ const draft = seedDraftOf({
+ settings_json: {
+ env: {},
+ theme: 'light',
+ autoUpdates: false,
+ grove_enabled: false,
+ },
+ claude_setting: '',
+ })
+ expect(draft.theme).toBe('light')
+ expect(
+ settingsRows(
+ draft.env && {
+ env: draft.env,
+ theme: 'light',
+ autoUpdates: false,
+ grove_enabled: false,
+ },
+ ''
+ ).map((row) => row.key)
+ ).toContain('autoUpdates')
+ expect(seedSaveBody({ ...draft, theme: 'dark' }).theme).toBe('dark')
+ })
+})
+
+describe('readonly seed env keys', () => {
+ it('locks TZ, kill-switch, cloud off, and gateway hint keys', () => {
+ for (const key of [
+ 'TZ',
+ 'LANG',
+ 'LC_ALL',
+ 'DISABLE_TELEMETRY',
+ 'DO_NOT_TRACK',
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
+ 'CLAUDE_CODE_USE_BEDROCK',
+ 'CLAUDE_CODE_USE_VERTEX',
+ 'CLAUDE_CODE_GATEWAY_HINT_HEADERS',
+ 'MY_GATEWAY_HINT_FLAG',
+ ]) {
+ expect(isReadonlySeedEnvKey(key)).toBe(true)
+ }
+ expect(isReadonlySeedEnvKey('CUSTOM_FLAG')).toBe(false)
+ expect(isReadonlySeedEnvKey('CLAUDE_CODE_ATTRIBUTION_HEADER')).toBe(false)
+ })
+
+ it('partitions env keys for the form', () => {
+ expect(
+ partitionSeedEnvKeys({
+ TZ: 'UTC',
+ CUSTOM: '1',
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
+ })
+ ).toEqual({
+ editable: ['CLAUDE_CODE_ATTRIBUTION_HEADER', 'CUSTOM'],
+ readonly: ['TZ'],
+ })
+ })
+})
+
+describe('seed queries', () => {
+ it('keeps seed-settings query key stable when debug upload toggles', () => {
+ expect(vmSeedQueryOptions('vm-1').queryKey).toEqual([
+ 'panel',
+ 'vm',
+ 'vm-1',
+ 'seed',
+ ])
+ expect(vmSeedUploadLogQueryOptions('vm-1').queryKey).toEqual([
+ 'panel',
+ 'vm',
+ 'vm-1',
+ 'seed-upload-log',
+ ])
+ expect(vmSeedQueryOptions('vm-1').queryKey).not.toContain(true)
+ expect(vmSeedQueryOptions('vm-1').queryKey).not.toContain(false)
+ })
})
describe('telemetry lamp', () => {
diff --git a/web/src/features/vm/seed-settings.ts b/web/src/features/vm/seed-settings.ts
index e16b290ef..54baebbb7 100644
--- a/web/src/features/vm/seed-settings.ts
+++ b/web/src/features/vm/seed-settings.ts
@@ -14,6 +14,48 @@ export const REJECT_FLAGS = [
const TOP_KEYS = ['theme', 'autoUpdates', 'grove_enabled'] as const
+/** Env keys rewritten by buildSlotSettingsEnv — editable UI would mislead. */
+const READONLY_SEED_ENV_KEYS = new Set([
+ 'TZ',
+ 'LANG',
+ 'LC_ALL',
+ 'DISABLE_TELEMETRY',
+ 'DO_NOT_TRACK',
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
+ 'CLAUDE_CODE_USE_BEDROCK',
+ 'CLAUDE_CODE_USE_VERTEX',
+ 'CLAUDE_CODE_GATEWAY_HINT_HEADERS',
+])
+
+export function isReadonlySeedEnvKey(key: string): boolean {
+ if (READONLY_SEED_ENV_KEYS.has(key)) return true
+ return /GATEWAY_HINT/i.test(key)
+}
+
+export function editableSeedEnv(
+ env: Record
+): Record {
+ const out: Record = {}
+ for (const [key, value] of Object.entries(env)) {
+ if (!isReadonlySeedEnvKey(key)) out[key] = value
+ }
+ return out
+}
+
+export function partitionSeedEnvKeys(env: Record): {
+ editable: string[]
+ readonly: string[]
+} {
+ const editable: string[] = []
+ const readonly: string[] = []
+ for (const key of Object.keys(env)) {
+ ;(isReadonlySeedEnvKey(key) ? readonly : editable).push(key)
+ }
+ editable.sort()
+ readonly.sort()
+ return { editable, readonly }
+}
+
export type SettingRow = {
key: string
value: string
@@ -25,6 +67,8 @@ export type SeedDraft = TelemetrySeedFlags & {
claude_setting: string
reject_client_settings: boolean
reject_client_metadata_identity: boolean
+ env: Record
+ theme: string
}
type ObservedTelemetry = NonNullable
@@ -51,6 +95,20 @@ export function claudeSettingKeys(text: string): Set {
return keys
}
+export function settingsEnv(
+ settings: Record | null | undefined
+): Record {
+ const env = asRecord(asRecord(settings)?.env) || {}
+ const out: Record = {}
+ for (const [key, value] of Object.entries(env)) {
+ out[key] = value == null ? '' : String(value)
+ }
+ if (!('CLAUDE_CODE_ATTRIBUTION_HEADER' in out)) {
+ out.CLAUDE_CODE_ATTRIBUTION_HEADER = '0'
+ }
+ return out
+}
+
/**
* Rows from the slot file `settings.json`. Missing file yields no rows —
* the policy default string is not a substitute for the file.
@@ -61,12 +119,11 @@ export function settingsRows(
): SettingRow[] {
const root = asRecord(settings)
if (!root) return []
- const env = asRecord(root.env) || {}
+ const env = settingsEnv(root)
const named = claudeSettingKeys(claudeSetting)
- const keys = Object.keys(env)
- const rows: SettingRow[] = keys.map((key) => ({
+ const rows: SettingRow[] = Object.keys(env).map((key) => ({
key,
- value: env[key] == null ? '' : String(env[key]),
+ value: env[key],
inClaudeSetting: named.has(key),
}))
for (const key of TOP_KEYS) {
@@ -96,11 +153,23 @@ export function seedDraftOf(
'reject_client_metadata_identity' in policy
? policy.reject_client_metadata_identity !== false
: true,
+ env: settingsEnv(seed?.settings_json),
+ theme:
+ typeof asRecord(seed?.settings_json)?.theme === 'string'
+ ? String(asRecord(seed?.settings_json)?.theme)
+ : typeof policy.theme === 'string'
+ ? policy.theme
+ : 'dark',
}
}
export function sameDraft(a: SeedDraft, b: SeedDraft): boolean {
+ const keys = new Set([...Object.keys(a.env), ...Object.keys(b.env)])
+ for (const key of keys) {
+ if ((a.env[key] ?? '') !== (b.env[key] ?? '')) return false
+ }
return (
+ a.theme === b.theme &&
telemetryEnabled(a) === telemetryEnabled(b) &&
a.claude_setting === b.claude_setting &&
a.reject_client_settings === b.reject_client_settings &&
@@ -109,14 +178,14 @@ export function sameDraft(a: SeedDraft, b: SeedDraft): boolean {
}
/** Body for PUT /seed-settings. Mirrors follow the telemetry bit. */
-export function seedSaveBody(
- draft: SeedDraft
-): Record {
+export function seedSaveBody(draft: SeedDraft): Record {
return {
...telemetrySeedFlags(telemetryEnabled(draft)),
claude_setting: draft.claude_setting.replace(/\n+$/g, ''),
reject_client_settings: draft.reject_client_settings,
reject_client_metadata_identity: draft.reject_client_metadata_identity,
+ extra_env: editableSeedEnv(draft.env),
+ theme: draft.theme,
}
}
diff --git a/worker/internal/telemetry/run.go b/worker/internal/telemetry/run.go
index 37368c657..bba90dd04 100644
--- a/worker/internal/telemetry/run.go
+++ b/worker/internal/telemetry/run.go
@@ -2,6 +2,7 @@ package telemetry
import (
"context"
+ "encoding/json"
"log"
"os"
"path/filepath"
@@ -244,10 +245,7 @@ func postBatch(ctx context.Context, deps Deps, cfg config.Config, token string,
return nil
}
err := deps.HTTP.PostBatch(ctx, token, cfg.Telemetry.Headers, []Event{ev})
- if err != nil {
- class, status := ClassifyBatchErr(err)
- log.Printf("telemetry sidecar: batch failed class=%s status=%d", class, status)
- }
+ recordUpload(cfg, "batch", ev.EventData["event_name"], err, BatchRequest{Events: []Event{ev}})
return err
}
@@ -256,11 +254,94 @@ func postEval(ctx context.Context, deps Deps, cfg config.Config, token string, b
return nil
}
err := deps.HTTP.PostEval(ctx, token, cfg.Telemetry.Headers, body)
+ recordUpload(cfg, "eval", "growthbook", err, body)
+ return err
+}
+
+const uploadLogLines = 40
+
+var redactedUploadKeys = map[string]struct{}{
+ "email": {}, "account_uuid": {}, "accountuuid": {},
+ "organization_uuid": {}, "organizationuuid": {},
+ "device_id": {}, "deviceid": {}, "session_id": {}, "sessionid": {},
+ "user_id": {}, "userid": {}, "id": {},
+}
+
+func recordUpload(cfg config.Config, kind string, name any, err error, payload any) {
+ if strings.TrimSpace(cfg.ConfigPath) == "" {
+ return
+ }
+ body, marshalErr := json.Marshal(redactUpload(payload))
+ if marshalErr != nil {
+ body = []byte("{}")
+ }
+ state := "ok"
if err != nil {
class, status := ClassifyBatchErr(err)
- log.Printf("telemetry sidecar: eval failed class=%s status=%d", class, status)
+ state = class
+ log.Printf("telemetry sidecar: %s failed class=%s status=%d", kind, class, status)
+ _ = status
}
- return err
+ line := time.Now().UTC().Format(time.RFC3339) + " " + kind + " " + strings.TrimSpace(fmtName(name)) + " " + state + " " + string(body)
+ path := filepath.Join(filepath.Dir(cfg.ConfigPath), "telemetry-upload.log")
+ _ = rewriteUploadLog(path, line)
+}
+
+func redactUpload(value any) any {
+ switch item := value.(type) {
+ case map[string]any:
+ out := make(map[string]any, len(item))
+ for key, child := range item {
+ if _, drop := redactedUploadKeys[strings.ToLower(key)]; drop {
+ continue
+ }
+ out[key] = redactUpload(child)
+ }
+ return out
+ case []any:
+ out := make([]any, len(item))
+ for i, child := range item {
+ out[i] = redactUpload(child)
+ }
+ return out
+ case []Event:
+ out := make([]any, len(item))
+ for i, child := range item {
+ out[i] = redactUpload(child)
+ }
+ return out
+ case BatchRequest:
+ return map[string]any{"events": redactUpload(item.Events)}
+ case Event:
+ return redactUpload(map[string]any{"event_type": item.EventType, "event_data": item.EventData})
+ default:
+ return value
+ }
+}
+
+func rewriteUploadLog(path, line string) error {
+ previous, _ := os.ReadFile(path)
+ lines := strings.Split(strings.TrimRight(string(previous), "\n"), "\n")
+ if len(previous) == 0 {
+ lines = nil
+ }
+ lines = append(lines, line)
+ if len(lines) > uploadLogLines {
+ lines = lines[len(lines)-uploadLogLines:]
+ }
+ next := path + ".next"
+ if err := os.WriteFile(next, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(next, path)
+}
+
+func fmtName(name any) string {
+ text, _ := name.(string)
+ if text == "" {
+ return "-"
+ }
+ return text
}
func stopOn4xx(err error) bool {
diff --git a/worker/internal/telemetry/run_test.go b/worker/internal/telemetry/run_test.go
index b58386e61..f72c8439b 100644
--- a/worker/internal/telemetry/run_test.go
+++ b/worker/internal/telemetry/run_test.go
@@ -216,3 +216,35 @@ func TestRunReloadsTelemetryWithoutRestart(t *testing.T) {
t.Fatalf("reloaded body missing hardware: %s", got)
}
}
+
+func TestUploadLogKeepsFortyLinesAndDropsIdentity(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Config{ConfigPath: filepath.Join(dir, "worker.json")}
+ payload := Event{EventType: "ClaudeCodeInternalEvent", EventData: map[string]any{
+ "event_name": "tengu_init",
+ "email": "person@example.com",
+ "device_id": "abc",
+ "auth": map[string]any{"account_uuid": "acct", "organization_uuid": "org"},
+ "env": map[string]any{"platform": "linux"},
+ }}
+ for range 45 {
+ recordUpload(cfg, "batch", "tengu_init", nil, BatchRequest{Events: []Event{payload}})
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "telemetry-upload.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(raw)
+ lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
+ if len(lines) != uploadLogLines {
+ t.Fatalf("lines=%d", len(lines))
+ }
+ for _, secret := range []string{"person@example.com", "account_uuid", "organization_uuid", "device_id"} {
+ if strings.Contains(text, secret) {
+ t.Fatalf("log kept %s", secret)
+ }
+ }
+ if !strings.Contains(text, `"events":[`) || !strings.Contains(text, `"platform":"linux"`) || !strings.Contains(text, "batch tengu_init ok") {
+ t.Fatalf("log lost the upload shape: %s", text)
+ }
+}