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) + } +}