feat: 适配 kimi-code 0.40.1 —— flag 清单/优先级语义/危险命令守卫
- flag 清单 9→10:新增 file_history、search_worker;secondary-model 0.40.1 起默认开启(defaultEnabled) - env 探测补齐为 12 项元组(10 flag + master + legacy),修正此前 tower/subagent_fork/wait_for/auto_session_title 四项漏探 - 优先级语义跟随上游 flagService:单 flag env > [experimental] 显式值 > master env(仅强制开)> 默认。master env 不再锁定 UI;setExperimentalFlag 增加 explicitFalse 路径,关闭默认开/master 强开的 flag 时写字面 false - 权限设置新增 dangerous_command_guard 开关(默认开,写 [permission] 蛇形键;env KIMI_CODE_DANGEROUS_COMMAND_GUARD 运行时覆盖 config) - 新增 11 个测试(注册表/写语义/守卫读写)
This commit is contained in:
1 parent
6a7c8f5393
commit
86a128efea
10 files changed
+339
-47
No files matched your search
@@ -131,4 +131,76 @@ describe("loop_control — v1/v2 key handling", () => {
|
||||
expect(s.loop_control?.max_attempts_per_step).toBe(4);
|
||||
expect(s.loop_control?.max_retries_per_step).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
|
||||
// upstream default ON; an explicit false must survive even with no rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("permission.dangerous_command_guard", () => {
|
||||
it("reads as undefined when the key is absent (default on)", () => {
|
||||
expect(
|
||||
getAgentSettings({}).permission?.dangerous_command_guard
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAgentSettings({ permission: { rules: [] } }).permission
|
||||
?.dangerous_command_guard
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads an explicit true / false", () => {
|
||||
expect(
|
||||
getAgentSettings({ permission: { dangerous_command_guard: false } })
|
||||
.permission?.dangerous_command_guard
|
||||
).toBe(false);
|
||||
expect(
|
||||
getAgentSettings({ permission: { dangerous_command_guard: true } })
|
||||
.permission?.dangerous_command_guard
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes guard=false even with no rules (section kept)", () => {
|
||||
const next = setAgentSettings(
|
||||
{},
|
||||
{ permission: { rules: [], dangerous_command_guard: false } }
|
||||
) as { permission?: Record<string, unknown> };
|
||||
expect(next.permission).toEqual({ dangerous_command_guard: false });
|
||||
});
|
||||
|
||||
it("writes guard together with rules", () => {
|
||||
const rules = [{ decision: "allow" as const, pattern: "Read" }];
|
||||
const next = setAgentSettings(
|
||||
{},
|
||||
{ permission: { rules, dangerous_command_guard: true } }
|
||||
) as { permission?: Record<string, unknown> };
|
||||
expect(next.permission).toEqual({
|
||||
rules,
|
||||
dangerous_command_guard: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a rules-only update carries the existing guard through", () => {
|
||||
const raw = { permission: { dangerous_command_guard: false } };
|
||||
const next = setAgentSettings(raw, {
|
||||
permission: { rules: [{ decision: "deny" as const, pattern: "Bash" }] },
|
||||
}) as { permission?: Record<string, unknown> };
|
||||
expect(next.permission?.dangerous_command_guard).toBe(false);
|
||||
expect(next.permission?.rules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("omits an empty rules array; keeps an explicit guard on plain saves", () => {
|
||||
const raw = { permission: { dangerous_command_guard: true } };
|
||||
const off = setAgentSettings(raw, {
|
||||
permission: { rules: [], dangerous_command_guard: false },
|
||||
}) as { permission?: Record<string, unknown> };
|
||||
expect(off.permission).toEqual({ dangerous_command_guard: false });
|
||||
expect(off.permission).not.toHaveProperty("rules");
|
||||
// An explicit true is materialized on plain saves (harmless, and it
|
||||
// documents the state the UI toggle shows).
|
||||
const on = setAgentSettings(raw, {}) as {
|
||||
permission?: Record<string, unknown>;
|
||||
};
|
||||
expect(on.permission).toEqual({ dangerous_command_guard: true });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
if (thinking.effort === "max") {
|
||||
thinking.effort = "high";
|
||||
}
|
||||
const sectionPermission = getSection<AgentSettings["permission"]>(
|
||||
rawOther,
|
||||
"permission"
|
||||
);
|
||||
return {
|
||||
thinking,
|
||||
loop_control: loop,
|
||||
@@ -73,9 +77,15 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
...getSection<AgentSettings["background"]>(rawOther, "background"),
|
||||
},
|
||||
permission: {
|
||||
rules:
|
||||
getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ??
|
||||
[],
|
||||
rules: sectionPermission?.rules ?? [],
|
||||
// `dangerous_command_guard` stays undefined unless the config carries
|
||||
// an explicit boolean — upstream defaults it to ON (kimi-code 0.40.1),
|
||||
// so an absent key means "on". The env var
|
||||
// KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false" only)
|
||||
// outranks this config value at kimi-code runtime.
|
||||
...(typeof sectionPermission?.dangerous_command_guard === "boolean"
|
||||
? { dangerous_command_guard: sectionPermission.dangerous_command_guard }
|
||||
: {}),
|
||||
},
|
||||
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
|
||||
};
|
||||
@@ -88,13 +98,20 @@ export function setAgentSettings(
|
||||
): unknown {
|
||||
const root = { ...asRecord(rawOther) };
|
||||
const current = getAgentSettings(rawOther);
|
||||
// Explicit guard value wins over the carried-over one; undefined keeps the
|
||||
// key absent (upstream default = on).
|
||||
const guard =
|
||||
patch.permission?.dangerous_command_guard ??
|
||||
current.permission?.dangerous_command_guard;
|
||||
const permission: NonNullable<AgentSettings["permission"]> = {
|
||||
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
|
||||
...(typeof guard === "boolean" ? { dangerous_command_guard: guard } : {}),
|
||||
};
|
||||
const next: AgentSettings = {
|
||||
thinking: { ...current.thinking, ...patch.thinking },
|
||||
loop_control: { ...current.loop_control, ...patch.loop_control },
|
||||
background: { ...current.background, ...patch.background },
|
||||
permission: {
|
||||
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
|
||||
},
|
||||
permission,
|
||||
hooks: patch.hooks ?? current.hooks ?? [],
|
||||
};
|
||||
|
||||
@@ -115,8 +132,19 @@ export function setAgentSettings(
|
||||
if (next.thinking) root.thinking = next.thinking;
|
||||
if (next.loop_control) root.loop_control = next.loop_control;
|
||||
if (next.background) root.background = next.background;
|
||||
if (next.permission?.rules && next.permission.rules.length > 0) {
|
||||
root.permission = next.permission;
|
||||
// Keep `[permission]` when it carries rules *or* an explicit
|
||||
// dangerous_command_guard — dropping the section would silently lose a
|
||||
// guard=false write (absent key = upstream default on). An empty rules
|
||||
// array is omitted rather than written as `rules = []`.
|
||||
const hasRules = !!permission.rules && permission.rules.length > 0;
|
||||
const hasGuard = typeof permission.dangerous_command_guard === "boolean";
|
||||
if (hasRules || hasGuard) {
|
||||
root.permission = {
|
||||
...(hasRules ? { rules: permission.rules } : {}),
|
||||
...(hasGuard
|
||||
? { dangerous_command_guard: permission.dangerous_command_guard }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
delete root.permission;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXPERIMENTAL_FLAGS,
|
||||
getExperimentalFlags,
|
||||
getSecondaryModel,
|
||||
getSubagentModelPool,
|
||||
isExperimentalFlagSet,
|
||||
removeSubagentPoolEntry,
|
||||
setExperimentalFlag,
|
||||
setSecondaryModelOnly,
|
||||
setSubagentDefault,
|
||||
setSubagentForce,
|
||||
@@ -449,3 +453,76 @@ describe("validateSubagentPool", () => {
|
||||
expect(errors).toContainEqual({ key: "forceExcludesModels" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [experimental] — flag registry (kimi-code 0.40.1) + write semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experimental flag registry", () => {
|
||||
it("mirrors the 0.40.1 v2 registry (10 flags incl. file_history)", () => {
|
||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||
"secondary-model",
|
||||
"tool-select",
|
||||
"persistence_minidb_readmodel",
|
||||
"tower",
|
||||
"subagent_fork",
|
||||
"wait_for",
|
||||
"auto_session_title",
|
||||
"remote-control",
|
||||
"search_worker",
|
||||
"file_history",
|
||||
]);
|
||||
const fh = EXPERIMENTAL_FLAGS.find((f) => f.id === "file_history");
|
||||
expect(fh?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_FILE_HISTORY");
|
||||
expect(fh?.defaultEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("secondary-model is on by default since 0.40.1", () => {
|
||||
expect(
|
||||
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setExperimentalFlag", () => {
|
||||
const rawExp = (section: Record<string, unknown>) => ({
|
||||
experimental: section,
|
||||
});
|
||||
|
||||
it("enabling writes true; disabling a default-off flag deletes the key", () => {
|
||||
const on = setExperimentalFlag({}, "tool-select", true) as {
|
||||
experimental: Record<string, unknown>;
|
||||
};
|
||||
expect(on.experimental["tool-select"]).toBe(true);
|
||||
const off = setExperimentalFlag(on, "tool-select", false) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(off).not.toHaveProperty("experimental"); // empty section dropped
|
||||
});
|
||||
|
||||
it("disabling a default-on flag with explicitFalse writes a literal false", () => {
|
||||
const next = setExperimentalFlag(
|
||||
rawExp({ wait_for: true }),
|
||||
"wait_for",
|
||||
false,
|
||||
{ explicitFalse: true }
|
||||
) as { experimental: Record<string, unknown> };
|
||||
expect(next.experimental.wait_for).toBe(false);
|
||||
expect(isExperimentalFlagSet(next, "wait_for")).toBe(true);
|
||||
expect(getExperimentalFlags(next).wait_for).toBe(false);
|
||||
});
|
||||
|
||||
it("an explicit false coexists with other flags and keeps the section", () => {
|
||||
const next = setExperimentalFlag(
|
||||
rawExp({ "secondary-model": false }),
|
||||
"tower",
|
||||
true,
|
||||
{ explicitFalse: true }
|
||||
) as { experimental: Record<string, unknown> };
|
||||
expect(next.experimental).toEqual({
|
||||
"secondary-model": false,
|
||||
tower: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,10 +36,16 @@ export interface ExperimentalFlagDef {
|
||||
/** Known experimental flags — mirrors the kimi-code v2 flag registry
|
||||
* (the per-feature flag.ts files under packages/agent-core-v2/src:
|
||||
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
|
||||
* subagent_fork, wait_for, auto_session_title, remote-control). `acp-v2` was
|
||||
* removed upstream and is dropped here. */
|
||||
* subagent_fork, wait_for, auto_session_title, remote-control,
|
||||
* search_worker, file_history). `acp-v2` was removed upstream and is
|
||||
* dropped here. Keep the env-var list in sync with
|
||||
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
|
||||
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
|
||||
{
|
||||
id: "secondary-model",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
|
||||
defaultEnabled: true, // on by default since kimi-code 0.40.1
|
||||
},
|
||||
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
||||
{
|
||||
id: "persistence_minidb_readmodel",
|
||||
@@ -58,11 +64,21 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||
},
|
||||
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
|
||||
{
|
||||
id: "search_worker",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{ id: "file_history", envVar: "KIMI_CODE_EXPERIMENTAL_FILE_HISTORY" },
|
||||
];
|
||||
|
||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||
|
||||
/** Whether the master env var is set to a truthy value (locks every flag on). */
|
||||
/** Whether the master env var is set to a truthy value. Since kimi-code
|
||||
* 0.40.1 it only force-ONs flags that have *no* explicit `[experimental]`
|
||||
* entry (an explicit config value — true or false — outranks it, and the
|
||||
* master env can never force a flag off). It locks no UI toggles; only a
|
||||
* flag's own single-feature env var does. */
|
||||
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
|
||||
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
|
||||
}
|
||||
@@ -105,16 +121,24 @@ export function isExperimentalFlagSet(rawOther: unknown, id: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Set one flag in `[experimental]`. Removing all flags drops the section. */
|
||||
/** Set one flag in `[experimental]`. Removing all flags drops the section.
|
||||
* Pass `explicitFalse` when deleting the key would NOT express "off" —
|
||||
* i.e. the flag is on by upstream default (`defaultEnabled`) or the master
|
||||
* env force-ons undefined flags — and write a literal `false` instead,
|
||||
* which outranks both under the kimi-code 0.40.1 priority
|
||||
* (single env > explicit config > master env > default). */
|
||||
export function setExperimentalFlag(
|
||||
rawOther: unknown,
|
||||
id: string,
|
||||
enabled: boolean
|
||||
enabled: boolean,
|
||||
opts?: { explicitFalse?: boolean }
|
||||
): unknown {
|
||||
const root = { ...asRecord(rawOther) };
|
||||
const flags = { ...getExperimentalFlags(rawOther) };
|
||||
if (enabled) {
|
||||
flags[id] = true;
|
||||
} else if (opts?.explicitFalse) {
|
||||
flags[id] = false;
|
||||
} else {
|
||||
delete flags[id];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user