Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- 槽位种子页默认锁定:需确认解锁后才能改配置,播种前再次确认;时区/语言提供 en、cn、jp 基础选项。时区跟随当前 SOCKS5(空/非手动钉住)时展示出口时区,播种经 `timezone_follow_proxy` 写入;可改固定 IANA 时区或改回跟随。TZ/LANG/LC_ALL 仍不经 `extra_env` 覆盖契约键。
- 槽位种子页:`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/<id>/run/telemetry-upload.log`,面板 `GET seed-settings?debug=1` 返回 `telemetry_log`。
- 协议页可配置 `routing.compatibility.auto_compact_window`(100K–1M 或跟随模型);入站在清理 settings 前提取调用方 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,经内部 `auto_compact_window` 字段进入当前 job,清理后的 body 不再携带该 env。
- 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。
- fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/<pid>` 消失后再绑定新二进制;同一 SOCKS 出口的 stop/start 与 `ensureProxyEgress` 共用 per-proxy 锁,避免并发 telemetry 互相 SIGTERM。(#604)
- 面板:侧栏「GitHub / 文档」两枚链接不再折行,字体偏宽时「文档」不会逐字竖排,空间不够先缩箭头再省略文字;VM 列表搜索框关闭浏览器自动填充,不再被回填成登录邮箱。
Expand Down
9 changes: 9 additions & 0 deletions src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,12 @@ export function validatePersonaRoutingPatch(body = {}) {
if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') {
problems.push('cch_enabled 必须是布尔')
}
if (Object.prototype.hasOwnProperty.call(compat, 'auto_compact_window') && compat.auto_compact_window != null) {
const windowTokens = Number(compat.auto_compact_window)
if (!Number.isInteger(windowTokens) || windowTokens < 100_000 || windowTokens > 1_000_000) {
problems.push('auto_compact_window 必须是 100000–1000000 的整数')
}
}
if (
compat.thinking_display != null &&
compat.thinking_display !== '' &&
Expand Down Expand Up @@ -1406,6 +1412,9 @@ function presentCompatibility(compat) {
.trim()
.toLowerCase()
out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized'
const windowTokens = Number(out.auto_compact_window)
out.auto_compact_window =
Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null
return out
}

Expand Down
5 changes: 4 additions & 1 deletion src/lib/protocol/handle-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
createClaudeMessageAssembler,
applyClaudeSSELineToMessage,
} from './convert.mjs'
import { sanitizeInboundBody, defaultSeedPolicy } from './seed-policy.mjs'
import { sanitizeInboundBody, defaultSeedPolicy, callerAutoCompactWindow } from './seed-policy.mjs'
import { fingerprintRequest } from './client-fingerprint.mjs'
import { validateOfficialModel } from './models.mjs'
import { handleCodexProtocol, handleCodexSearch } from './handle-codex.mjs'
Expand Down Expand Up @@ -619,11 +619,13 @@ export function createHandleProtocol(deps) {
)
}

const autoCompactWindow = callerAutoCompactWindow(inbound)
let ctx = {
path: pathName,
protocol,
body: sanitizeInboundBody(inbound, defaultSeedPolicy()),
headers: { ...req.headers },
autoCompactWindow,
}
ctx = applyIntercept(cfg.intercept.rules, 'before_convert', ctx)
const bodyCheck = validateRequestBody(protocol, ctx.body)
Expand Down Expand Up @@ -1291,6 +1293,7 @@ export function createHandleProtocol(deps) {
preserveSystem: officialPassthrough,
preserveThinkingBlocks: preserveCliHopThinkingBlocks,
preserveCallerEnvironment,
autoCompactWindow: ctx.autoCompactWindow,
})
hopBody = await materializeRemoteImageSources(hopBody)
if (panelRewrite) {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/protocol/outbound-attempt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,16 @@ export function prepareCliHopBody(
preserveSystem = false,
preserveThinkingBlocks = false,
preserveCallerEnvironment = false,
autoCompactWindow = null,
} = {},
) {
if (requestContext?.purpose === 'auto_mode_classifier') return prepareClassifierBody(canonicalBody, { stream })
if (preserveSystem) validateCacheBreakpoints(canonicalBody)
let body = officialMessagesBody(canonicalBody, { stream })
const windowTokens = Number(autoCompactWindow)
if (Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000) {
body.auto_compact_window = windowTokens
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inbound compact window not sanitized

Medium Severity

auto_compact_window is now a privileged job field, but inbound copies of it are never stripped. callerAutoCompactWindow only reads env, and prepareCliHopBody writes a validated value only when that env is in range. Otherwise officialMessagesBody still forwards a client-supplied top-level field, so it can override the panel/kernel default or reach the CLI with an unchecked number.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6ed5560. Configure here.

delete body.metadata
if (!preserveSystem) {
const leftover = stripCliOwnedSystem(body.system, { preserveCallerEnvironment })
Expand Down
10 changes: 10 additions & 0 deletions src/lib/protocol/seed-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ export function buildSlotSettingsEnv(pol = {}, { timezone, locale, extra } = {})
return out
}

export function callerAutoCompactWindow(body = {}) {
const sources = [body?.settings?.env, body?.claude_settings?.env, body?.env]
for (const source of sources) {
if (!source || typeof source !== 'object' || Array.isArray(source)) continue
const value = Number(source.CLAUDE_CODE_AUTO_COMPACT_WINDOW)
if (Number.isInteger(value) && value >= 100_000 && value <= 1_000_000) return value
}
return null
}

export function buildSeedSettingsEnv(pol = {}, extra = {}) {
return buildSlotSettingsEnv(pol, { extra })
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/transport/rust-kernel-supervisor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ import {
} from '../oauth/oauth-credentials.mjs'
import { slotHost } from '../vm/slot-host.mjs'

function normalizeAutoCompactWindow(value) {
const windowTokens = Number(value)
return Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null
}

const starts = new Map()
const KVM_HEALTH_MS = { kvm: 120_000, tcg: 480_000 }

Expand Down Expand Up @@ -693,6 +698,13 @@ export function writeKernelConfig(
const previousMax = Number(previous.max_request_bytes)
const requestBytes = routing == null && previousMax > 0 ? previousMax : maxRequestBytes(routing)
const cchEnabled = routing == null ? previous.cch_enabled !== false : routing?.compatibility?.cch_enabled !== false
const previousWindow = Number(previous.auto_compact_window)
const autoCompactWindow =
routing == null
? Number.isInteger(previousWindow) && previousWindow >= 100_000 && previousWindow <= 1_000_000
? previousWindow
: null
: normalizeAutoCompactWindow(routing?.compatibility?.auto_compact_window)
const sessionIdleTtlSeconds =
routing == null
? normalizeSessionIdleTtlSeconds(previous.session_idle_ttl_seconds)
Expand Down Expand Up @@ -726,6 +738,7 @@ export function writeKernelConfig(
cli_version: OFFICIAL_CLI_VERSION,
default_cache_ttl: defaultCacheTtl,
cch_enabled: cchEnabled,
auto_compact_window: autoCompactWindow,
}

if (tz) config.timezone = tz
Expand Down
25 changes: 25 additions & 0 deletions test/unit/go-worker-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
restoreUncommittedHop,
semanticStatusForStreamError,
} from '../../src/lib/transport/go-worker-client.mjs'
import { prepareCliHopBody } from '../../src/lib/protocol/outbound-attempt.mjs'
import { callerAutoCompactWindow, defaultSeedPolicy, sanitizeInboundBody } from '../../src/lib/protocol/seed-policy.mjs'
import { extractOpenaiUsage } from '../../src/lib/protocol/openai-usage.mjs'
import { callRustKernel, rustKernelHealth } from '../../src/lib/transport/rust-kernel-client.mjs'
import { forwardApi, readApiJson } from '../../src/lib/transport/api-kernel-client.mjs'
Expand Down Expand Up @@ -164,6 +166,29 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare'
assert.deepEqual(out.body.system, body.system)
})

test('cli-hop keeps the caller compact window outside settings', () => {
const inbound = {
model: 'claude-opus-5-5',
messages: [{ role: 'user', content: 'hi' }],
settings: { theme: 'light', env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000', TZ: 'UTC' } },
}
const autoCompactWindow = callerAutoCompactWindow(inbound)
const sanitized = sanitizeInboundBody(inbound, defaultSeedPolicy())
assert.equal(callerAutoCompactWindow(sanitized), null)
const prepared = prepareCliHopBody(sanitized, { autoCompactWindow })
const out = finalizeWorkerPayload({
body: prepared,
reqHeaders: {},
exec: { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } },
identity: null,
cliHop: true,
})
assert.equal(sanitized.settings, undefined)
assert.equal(out.body.settings, undefined)
assert.equal(out.body.env, undefined)
assert.equal(out.body.auto_compact_window, 1000000)
})

test('setup-token cli-hop summarizes hidden thinking by default and keeps updates when passthrough', () => {
const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }
const updates = finalizeWorkerPayload({
Expand Down
2 changes: 2 additions & 0 deletions test/unit/kernel-router.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ test('writeKernelConfig separates container paths from host socket paths', () =>
assert.equal(doc.https_proxy, undefined)
assert.equal(doc.slots_per_worker, WRAP_SLOT_MAX)
assert.equal(doc.default_cache_ttl, '1h')
assert.equal(doc.auto_compact_window, null)
assert.match(written.socketPath.replace(/\\/g, '/'), /vms\/vm-09\/run\/kernel\.sock$/)
assert.equal(fs.readFileSync(written.tokenPath, 'utf8').trim(), 'tok')
const exec = { vmId: 'vm-09', homeDir: path.join(root, 'vms', 'vm-09', 'cli-home'), vm: { id: 'vm-09' } }
Expand Down Expand Up @@ -702,6 +703,7 @@ test('writeKernelConfig cli-hop writes local_cli without secrets', () => {
assert.equal(doc.system_layout, 'identity')
assert.equal(doc.cli_version, OFFICIAL_CLI_VERSION)
assert.equal(doc.default_cache_ttl, '5m')
assert.equal(doc.auto_compact_window, null)
assert.equal(doc.timezone, 'America/New_York')
assert.equal(doc.proxy_url, '')
assert.equal(doc.proxy_required, false)
Expand Down
45 changes: 43 additions & 2 deletions web/src/features/settings/protocol-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import {
SESSION_IDLE_TTL_SECONDS_MAX,
SESSION_IDLE_TTL_SECONDS_MIN,
} from '@/types/panel-routing'
import { Brain, Check, Clock3, Database, Hash, ShieldCheck } from 'lucide-react'
import {
Brain,
Check,
Clock3,
Database,
Scaling,
ShieldCheck,
} from 'lucide-react'
import {
cacheTtlFromCompat,
detectProxiedOfficialCcFromCompat,
Expand Down Expand Up @@ -32,6 +39,13 @@ const CLAUDE_CODE_FINGERPRINT: [string, string][] = [
['计费块', 'cc_version = UA 版本'],
]

function autoCompactWindowValue(value: unknown) {
const tokens = Number(value)
if (tokens === 200_000 || tokens === 500_000 || tokens === 1_000_000)
return String(tokens)
return 'model'
}

export function ProtocolPane({
compat,
inference,
Expand Down Expand Up @@ -188,7 +202,34 @@ export function ProtocolPane({
</SettingRow>
</PaneSection>
<PaneSection
icon={Hash}
icon={Scaling}
title='Auto compact 窗口'
desc='默认跟随请求模型。显式值替换模型窗口,请求里的 CLAUDE_CODE_AUTO_COMPACT_WINDOW 仍优先。'
>
<SettingRow
label='窗口'
desc='跟随模型时,Opus / Sonnet / Haiku 5.x 使用 1M,其余模型使用 200K。'
>
<ChipGroup
label='Auto compact 窗口'
value={autoCompactWindowValue(compat.auto_compact_window)}
options={[
['model', '跟随模型'],
['200000', '200K'],
['500000', '500K'],
['1000000', '1M'],
]}
onChange={(value) =>
onCompatChange({
...compat,
auto_compact_window: value === 'model' ? null : Number(value),
})
}
/>
</SettingRow>
</PaneSection>
<PaneSection
icon={Clock3}
title='CCH 统一附加'
desc='出站 billing 的 cch。开启后缺失也补,不区分试用或订阅。'
>
Expand Down
Loading