feat(agent-core-v2): add custom agent identity (#2573)

* refactor(agent-core-v2): simplify context tags and shared copy

Rename the context-injection tags to `<skill-loaded>` and
`<plugin-instructions>`, drop the product prefix from the CronCreate tool
description and the default agent description, and point the MCP OAuth
callback page back to "your terminal" instead of naming one client.

The callback page is shared by the ACP host, the web UI, and embedding
hosts, so naming a single client was inaccurate there. The tags and the
two descriptions read exactly the same without the prefix. Verified no
runtime consumer matches the old tag names; the updated snapshots cover
the tool descriptions that changed.

* feat(agent-core-v2): add a switch for the product-documentation skills

Five builtin skills document this CLI itself — `update-config`,
`custom-theme`, `mcp-config`, `check-kimi-code-docs`, and
`import-from-cc-codex`. Their names and descriptions sit in the system
prompt on every turn, which is dead weight for runs that will never
reconfigure the CLI.

Add a top-level `builtin_product_skills` field (also settable through
`KIMI_CODE_BUILTIN_PRODUCT_SKILLS`) to drop them. On by default, so
nothing changes unless it is set; the trade when off is that the model
loses the guided flows for those tasks.

Filtering happens where the catalog is assembled — a later filter would
leave the skills advertised to the model. The whole section is one
scalar, so it exercises the section-level env binding branch and needs
its own strip: `stripEnvBoundFields` only walks object fields, so an env
override would otherwise be written back into `config.toml`.

* feat(agent-core-v2): add custom agent identity

Add an `[identity]` config section (`name`, optional `slug`, both also
settable through `KIMI_CODE_IDENTITY_NAME` / `KIMI_CODE_IDENTITY_SLUG`)
that sets the identity the agent presents: the name it calls itself in
the system prompt, the `User-Agent` product token sent to third-party
providers, and the client name announced to MCP servers. Leaving it
unset changes nothing.

Until now every one of these was fixed, which left no way to run the
agent as part of another product — an internal deployment, a fork with
its own branding, an embedding host.

The identity resolves inside the engine rather than being seeded by each
host, so it applies to every launch surface — including headless runs,
which today seed no display name at all and fall through to the built-in
default.

Two deliberate asymmetries:

- The display name is a filling value with a fallback chain (config >
  host-declared > the consumer's own default); the slug is a rewriting
  value with two states only, so with no identity configured the
  rewriting paths are equivalent to not existing.
- The rewrite happens in the outbound header assembly, the one layer
  that knows which vendor it is building for. Vendors declaring
  `hostHeaders: 'full'` keep the host's own product token, which that
  header set is built around and which backends key on; the configured
  identity applies to the third-party path.

Resolution is lazy throughout: config loads asynchronously, and a
constructor snapshot would freeze the pre-load value under some startup
orderings.

Two input edges the resolver has to absorb, since both would otherwise
reach the User-Agent builder and either break it or quietly rewrite the
header: blank and whitespace-only values read as unset in the file just
as they already did in the env, so a stray `name = ""` cannot claim an
identity; and a name that folds away to nothing under slug
normalization (a CJK-only name, say) falls back to a neutral token
rather than producing a blank product, which the builder rejects.

* fix(agent-core-v2): keep the file value when a scalar env binding fails to parse

`config.ts` documents that an env value failing its binding's `parse` is
ignored, and `applyEnvBindings` honors that for object fields by
assigning only when the resolved value is defined. `applySectionEnv`
returned the parse result straight through for whole-section scalar
bindings, so a blank or mistyped variable resolved to `undefined` and
cleared the configured file value instead of being ignored.

Nothing hit this before: every existing section either binds object
fields or is env-only. `builtin_product_skills` is the first
whole-section scalar binding, where exporting an empty or misspelled
`KIMI_CODE_BUILTIN_PRODUCT_SKILLS` would silently undo a configured
`false`.

* feat(agent-core-v2): extend the custom identity to discovery and global MCP

Two outbound paths still announced the built-in product name under a
configured identity:

- `DiscoveryService` read the host User-Agent straight from bootstrap
  args when refreshing provider models, so custom registries — which are
  third-party endpoints — saw the original token while chat requests to
  the same class of endpoint saw the configured one.
- `SDKRpcClientV2` builds its own global `McpOAuthService` plus a
  throwaway `McpConnectionManager` for server testing, neither of which
  goes through the workspace-owned manager that carries the resolver.

Both now resolve the identity from the App scope.

* refactor(agent-core-v2): neutralize remaining copy and align comments

The synthetic MCP authentication tool description is injected into the
model context and still named the product; it and the OAuth callback
pages now use client-neutral wording. "Return to your terminal" was no
improvement over naming a client — both assume what the host is, and
that page serves the ACP host, the web UI and embedding hosts alike.

Comments introduced by the identity work move into their module headers,
per the domain convention. Interface field docs stay: the rule names
functions, methods and statements, and field-level docs are established
across the codebase.

The new tests gain scenario headers and dispose the scoped hosts they
create, and the `[identity]` docs state which engine reads the section.

* fix(agent-core-v2): read the product-skill switch after config is ready

`BuiltinSkillSource` is the lowest-priority skill source, so the workspace
catalog loads it first — before `IConfigService` has finished loading — and
keeps the contribution it returns for the life of the handler, with no
reload path and no change event. Reading `builtin_product_skills` eagerly
therefore stranded the startup configuration: an explicit `false` could be
ignored for the whole process. `UserFileSkillSource` already awaits config
readiness for exactly this ordering; this source now does the same.

Also record the identity collaborator in the two module headers that gained
the dependency without documenting it, and scope the
`builtin_product_skills` docs to the engine that reads it, matching the
note the identity section already carries.

* fix(agent-core-v2): apply the product-skill switch to session-less listings

`builtin_product_skills = false` only reached the scoped skill source. The
SDK's `listWorkspaceSkills` and the server's `GET /workspaces/{id}/skills`
both composed the raw `BUILTIN_SKILLS` constant, and the web app feeds its
pre-session onboarding menu from that route — so the five product skills
stayed listed until a session existed, then vanished from the session's
catalog.

Move the decision into `visibleBuiltinSkills(enabled)` next to the constant
and route every consumer through it, reading the switch via the shared
`builtinProductSkillsEnabled`. Keeping "what counts as a product skill" in
one place is the point: three copies of the predicate would drift the next
time a builtin is added. The SDK listing also awaits config readiness,
which it did not do before.

* fix(node-sdk): await config before materializing the global MCP OAuth provider

`McpOAuthService` caches providers by store key and stamps the client name
when it first builds one, and the preceding `globalMcpConfig.get()` reads
`mcp.json` directly rather than through `IConfigService`. So a
`beginGlobalMcpServerAuth` call made right after the harness is created
could resolve the identity before config finished loading, pinning the
built-in label for the rest of the process — including the OAuth dynamic
registration a third-party MCP server records.

`testGlobalMcpServer` already awaited config readiness for its own reasons;
this path now does too.

* refactor(agent-core-v2): drop the unused builtin-skill registrar

`registerBuiltinSkills` stamped the raw constant into a catalog for "edge
composition without a Session" — exactly the shape that now has to respect
`builtin_product_skills`. It has no callers in v2 and is not exported from
the package index, so it was dead code that also stood as an invitation to
bypass the switch. v1 keeps its own copy.

Every remaining path composes builtins through `visibleBuiltinSkills`.

* fix(agent-core-v2): send the configured identity on custom-registry imports

`:import_registry` fetched a user-supplied third-party URL with a
hardcoded `kimi-code-kap-server` User-Agent, so the first request to a
registry announced the product while every scheduled refresh of the same
registry announced the configured identity. The hardcoded value was wrong
on its own terms too: that token names the server, and this path also runs
in the CLI.

Both services now project the identity through `identityUserAgent`, which
carries the two guards (no host header, or no identity) once instead of
per caller. The model catalog keeps an inline copy on purpose — kosong is
a foundational layer and must not import an app domain.

Sweeping the remaining outbound User-Agent sources found no further gaps:
WebFetch deliberately sends a Chrome-like UA, the models.dev catalog fetch
sends none from the CLI, and kap-server's `user-agent` reads are inbound.

* docs: scope the identity env vars and condense the changeset

The environment-variable reference advertised all three new variables
without noting that only the agent-core-v2 engine reads them; the
configuration page already carried that note. Added in both locales.

The changeset had grown into two paragraphs of implementation detail,
which is what would land in the CLI release changelog. `gen-changesets`
asks for one short sentence plus at most a one-line usage hint.

* docs(agent-core-v2): describe the identity as what the agent calls itself

The module headers had drifted into describing the feature by what it
keeps off the wire rather than what it configures. Reworded so they state
the capability: the identity is the name the agent uses for itself, and
the unset case is a no-op rather than something "safe". The product-skill
switch excludes skills rather than hiding them.

Wording only; behavior and structure unchanged.

* test(agent-core-v2): cover the identity on custom-registry imports

The import path switched from a hardcoded `kimi-code-kap-server` token to
the host User-Agent projected through the identity, but nothing asserted
it. Two cases pin both halves: a configured identity reaches the request,
and an unconfigured one leaves the host header intact — the second matters
because a single case would also pass if one hardcoded value had simply
replaced another.

Both fail against the previous implementation.

* fix(node-sdk): guard every global MCP OAuth path behind config readiness

`McpOAuthService` caches providers by store key and stamps the client name
when it first builds one, so any path that can materialize a provider has
to run after config has loaded. `beginGlobalMcpServerAuth` awaited
readiness, but `resetGlobalMcpServerAuth` reaches the same cache through
`invalidate()` -> `getProvider()` without waiting: resetting auth right
after the harness is constructed pinned the built-in client name, and the
await added to the begin path could not help because it then reused that
cached provider.

Rather than add the missing await, the accessor is now async and holds the
guard itself, so the service cannot be obtained before config is ready and
a future entry point cannot forget. The remaining `configReady` in
`testGlobalMcpServer` stays — that one is for its own `[mcp]` section read.

* fix(agent-core-v2): send the configured identity on models.dev requests

The directory fetch behind `listModelsDevProviders` / `getModelsDevProvider`
still hardcoded a `kimi-code-kap-server` User-Agent, so browsing or importing
from models.dev announced the built-in product — and claimed to be the server
even when running in the CLI. Only the custom-registry import had been fixed.

`getModelsDevCatalog` now takes the User-Agent from its caller: the module is
plain module-level state with no container access, and the value depends on
the host and the configured identity, which only the calling service can see.
All four third-party fetches in that service share one helper.

Where the host states no User-Agent, a neutral token stands in rather than
dropping the header — these are directories the service chooses to call, so
there is no host intent to preserve, unlike the provider requests the model
catalog assembles.

Both new tests fail against the previous hardcoded value.

* test(agent-core-v2): assert the product-skill set literally

The expected sets were derived from the same `productSpecific` field the
production filter reads, so a builtin silently losing its marker would just
move between sets and leave every assertion green — while staying visible to
the model once the switch is off. The five names are now literal, with a test
asserting the marked set matches them exactly.

Dropping the marker from one skill now fails four tests instead of none.

Also states the App scope in the identity contract header, per the domain's
comment convention for contract files.

* fix(agent-core-v2): normalize the host-declared display name too

Blank and padded values were normalized on the config side but not on the
host fallback, so an embedding host passing `displayName: "   "` rendered
"You are   ," into the system prompt, and a padded name kept its padding.
Same rule now applies to every source of the name.

Also names `agentIdentity` as the collaborator in the request-headers
adapter header, which described the value it obtains without saying which
domain resolves it.

The three new cases fail against the previous implementation.

* fix(agent-core-v2): keep the configured slug when the host sends no User-Agent

The neutral fallback added for hosts that state no `User-Agent` discarded a
configured identity along with it: `identityUserAgent` returns `undefined`
as soon as there is no host header to rewrite, so `?? DEFAULT_IDENTITY_SLUG`
sent the literal `agent` even when `[identity].slug` was set — precisely the
case that fallback exists to serve. The configured slug now stands on its
own, with the neutral token reserved for having neither.

The four combinations of (host header, configured slug) had three tests; the
missing one is the one that was wrong. It now fails without this change.

`outboundUserAgent` also awaits config readiness before reading the identity,
so a browse issued right after bootstrap cannot send the pre-load value — the
guard lives in the accessor rather than at its four call sites, matching how
the same race is handled elsewhere in this branch.

Both headers here and in `discoveryService` now name `agentIdentity` as the
collaborator resolving that token.

* test(acp-server): follow the renamed skill-activation tag

`acp-server` arrived on main after the tag rename, so its two assertions
still expected `kimi-skill-loaded` and failed once the branches met. Also
updates the web app's CSS comment, which named the old tag from the start
of this branch — a comment, so nothing ever failed on it.

Found by CI: the merge verification only ran agent-core-v2's suite, and
this package is neither a dependency nor a dependent of it.

* fix(agent-core-v2): present the configured slug on registry refreshes too

The previous round taught the import path to fall back to the configured
slug when the host states no `User-Agent`, but left the scheduled refresh
of the same registry on the bare projection — so one registry could see
`acme` on import and the runtime default on refresh.

Extracting `identityUserAgent` had made the two paths share a function
without sharing the policy. The choice itself is now the shared piece:
`identityUserAgentOrDefault` always yields a value, for the directories
this process chooses to call, while `identityUserAgent` stays the form
that rewrites only what the host already sends — what a provider request
needs, where the host's silence is its own choice.

* docs(agent-core-v2): move new member docs into the module headers

The domain's comment convention is absolute — comments live solely in the
top-of-file block — and I had read the "functions, methods, or statements"
clause as leaving interface members out. It does not: only 25 of 734 v2
sources carry an indented block, so the members I documented were the
exception, not the pattern.

Seven members across six files move into their headers. `types.ts` had no
header at all, so it gains one.

* fix(agent-core-v2): connect session MCP overlays after config is ready

The shared manager reaches `connectAll` through `initialize()`, which awaits
the config domain first; `sessionOverlay` called it straight away. A session
carrying ephemeral `mcpServers` created right after bootstrap therefore
resolved the client name before config had loaded and initialized under the
built-in one.

The blast radius is wider than that one connection: a remote server sends
the overlay through `hasTokens()`, which materializes an OAuth provider on
the *shared* service and caches it by store key — so the early name outlives
the connection that raced. The overlay now connects behind `mcpConfig.ready`,
leaving the returned readiness promise unchanged.

* fix(agent-core-v2): reload builtin skills when their switch changes

The workspace catalog keeps each source's contribution for the life of the
handler, so a `builtin_product_skills` toggle never reached an existing
handler's sessions. That was harmless while every surface read the same
constant — but routing the session-less listings through the config made the
two views disagree, since those read the switch on every call.

Follows `ExtraFileSkillSource`: subscribe to the owning section and fire
`onDidChange`, which the catalog already turns into a source reload. The
test asserts an unrelated section does not trigger it.

* fix(agent-core-v2): apply the identity to self-configured web services

`[services.moonshot_search]` and `[services.moonshot_fetch]` name their own
`base_url`, so both services can point at an endpoint the user chose — but
each forwarded the host request headers verbatim, sending the built-in
product token there under a configured identity.

Only the services-config path is rewritten; the managed OAuth path keeps the
host headers as they are, being the endpoint the session authenticated
against. The distinction is the same one the model catalog draws per vendor.

`identityHeaders` carries the rewrite across a whole header set, so this is
the fourth caller sharing the projection rather than repeating its guards.
A pair of tests pins both halves.

My earlier sweep classified these two as official by their names instead of
asking who chooses the URL, which is why they were missed. The contract
header is also condensed here, per the convention below.

* docs(agent-core-v2): condense the identity headers to their contracts

The comment convention is one sentence with two halves — comments live only
in the top-of-file block, *and* that block states the module's role without
narrating implementation. Moving the member docs up last round satisfied the
first and broke the second: the headers ended up spelling out the slug
folding algorithm, the strip mechanics, and the load order.

Kept what a caller or the next editor would get wrong without it (why the
value is read rather than snapshotted, what `undefined` obliges a consumer
to do, why this source waits for config). Dropped what the code already
says. 22/12/12/13 lines, against 53 in `catalogService.ts` — length was
never the problem.

* fix(agent-core-v2): rebuild active prompts when the builtin skills change

Reloading the catalog on a `builtin_product_skills` toggle left existing
agents holding the old listing: `AgentProfileService` refreshes the prompt
only for the plugin source, so a disabled switch kept advertising skills
that were gone, and enabling it left them missing until an unrelated
refresh.

The plugin source is special because it also contributes prompt sections
(#2314), and the file-backed sources are left out for cost — their fs
watches would rebuild every agent's prompt on each edit. The builtin source
has no watch: it changes only when its config switch is toggled, so it
belongs with the plugin source rather than with the file ones.

Subscribing to the catalog rather than the config section is load-bearing.
The catalog fires after the contribution is replaced, whereas a config
subscription would race the reload, and `resolveSkillListing` only awaits
the catalog's *initial* readiness — so the rebuilt prompt could read the
listing it was meant to replace.

The source id is a named constant now, so the subscription does not match
on a bare string.

* refactor(agent-core-v2): freeze the agent identity for the process lifetime

The identity is announced outward (MCP initialize, OAuth registration,
provider request logs) and cannot be re-announced, so mid-process changes
could only ever apply partially. Resolve it once when config first loads
and hold it for the life of the process: IAgentIdentity now hands out a
frozen snapshot via resolved()/current(), carrying finished products
(outbound User-Agent variants, rewritten header set) so call sites stop
composing host headers with the slug themselves. The kosong host-headers
port carries two finished layers and the catalog only picks one; consumers
gain no invalidation obligations because the value can never change after
the freeze. [identity] edits take effect on the next start (documented).

* fix(agent-core-v2): locate the User-Agent header case-insensitively

HTTP header names are case-insensitive, but the snapshot builder looked up
'User-Agent' by exact key: an embedding host spelling it 'user-agent' got no
third-party UA and kept its own product token on the services path even with
an identity configured. The builder now locates every case variant and
rewrites each in place, keeping the host's spelling. Also corrects the two
web-service headers that still described both paths as sending the bootstrap
headers, naming agentIdentity as the collaborator behind the config path,
and documents that a resumed session keeps its recorded system prompt.

* fix(agent-core-v2): attribute header provenance from the finished third-party layer

Inspection reconstructed the non-full host layer from the raw headers with an
exact-case 'User-Agent' lookup, so a host spelling the header 'user-agent'
got a resolved User-Agent with no provenance entry even though the runtime
sends the rewritten value. buildModel now captures the port's finished
third-party layer in the trace and attribution reads it, keeping inspect()
on the same resolution pass as get(). Also condenses the identity contract
header to its external role, and documents that an existing MCP OAuth
authorization keeps the client registration it was granted under (reset the
server's auth to register under the new identity).

* fix(agent-core-v2): keep web tool backends from racing the identity freeze

An env-configured [services] endpoint is visible before config finishes
loading, and FetchURLTool / WebSearchTool materialized their backends at
construction — so a fast bootstrap could hit the identity snapshot's
pre-freeze guard during agent creation, and the composed backend pinned
config and login state for the agent's lifetime against the service's
documented per-call resolution. Both tools now resolve their backend per
invocation, the WebSearch activation gate checks presence alone through the
new hasWebSearchProvider() (no provider composition, no identity read), and
bind() awaits the identity freeze before materializing the model, whose
resolution reads the identity through the host-headers port.
This commit is contained in:
Kai 2026-08-04 22:35:15 +08:00 committed by GitHub
parent 119a33f7f1
commit 98ee35afd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 1867 additions and 185 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Add a custom agent identity, plus a switch for the built-in skills that document Kimi Code itself. Set `[identity] name` in `config.toml` (or `KIMI_CODE_IDENTITY_NAME`) to change the name the agent uses for itself and the identifier it presents to third-party providers and MCP servers; set `builtin_product_skills = false` to drop the product-documentation skills.

View file

@ -1124,7 +1124,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
padding: 10px 0;
}
/* Skill activation card (replaces raw <kimi-skill-loaded> XML) */
/* Skill activation card (replaces raw <skill-loaded> XML) */
.skill-act {
display: flex;
flex-direction: column;

View file

@ -103,6 +103,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories |
| `extra_skill_dirs` | `array<string>` | — | Extra skill search directories, layered on top of the default directories |
| `extra_agent_dirs` | `array<string>` | — | Extra custom agent search directories, layered on top of the default directories |
| `builtin_product_skills` | `boolean` | `true` | Whether the built-in skills that document Kimi Code itself are offered to the model: `update-config`, `custom-theme`, `mcp-config`, `check-kimi-code-docs`, and `import-from-cc-codex`. Turning them off trims their names and descriptions from the system prompt, at the cost of the guided flows for those tasks. Read by the `agent-core-v2` engine (`kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths); ignored on the default engine |
| `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` |
| `providers` | `table` | `{}` | API provider table → [`providers`](#providers) |
| `models` | `table` | — | Model alias table → [`models`](#models) |
@ -114,6 +115,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
| `identity` | `table` | — | Custom agent identity → [`identity`](#identity) |
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `tools`, `image`, `services`, and `permission`.
@ -297,6 +299,29 @@ In print mode (`kimi -p "<prompt>"`), Kimi Code stays alive after the main agent
`startup_timeout_ms` and `tool_timeout_ms` can be overridden by the `KIMI_MCP_STARTUP_TIMEOUT_MS` and `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables respectively, which take higher priority than `config.toml`. See [MCP](../customization/mcp.md) for the full MCP server configuration.
## `identity`
Customizes how the agent identifies itself. Leave it unset and nothing changes.
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | — | Display name the agent calls itself in the system prompt (fills the `${product_name}` slot, including in your own `SYSTEM.md` and agent files) |
| `slug` | `string` | derived from `name` | Machine identifier used in protocol fields: the `User-Agent` product token sent to third-party providers, and the client name announced to MCP servers. Derived from `name` when omitted: lowercased, with every run of non-alphanumeric characters folded to `-` |
```toml
[identity]
name = "Acme Dev Agent"
slug = "acme-dev" # optional
```
Both fields can be set through the `KIMI_CODE_IDENTITY_NAME` and `KIMI_CODE_IDENTITY_SLUG` environment variables, which take higher priority than `config.toml` and are never written back to it — convenient for containers and CI, where writing a config file is awkward.
A name that contains no ASCII letters or digits (for example a purely Chinese name) leaves nothing to derive a slug from and falls back to `agent`; write `slug` explicitly if you need a specific protocol token.
The identity is resolved once at startup and holds for the life of the process — it is announced to MCP servers and providers when connections are made, so it cannot change midway. Edits to this section take effect on the next start, for new sessions: a resumed session keeps the system prompt it was recorded with, since its past turns already speak under that identity. Likewise, an MCP OAuth authorization keeps the client registration it was granted under; reset that server's authentication to register under the new identity.
This section is read by the `agent-core-v2` engine, which currently backs `kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths. On the default `kimi` / `kimi -p` engine it is ignored.
## `tools`
`tools` is the global tool switch: it applies to every agent in all sessions and intersects with each agent's own `tools` / `disallowedTools` policy.

View file

@ -128,6 +128,9 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default |
| `KIMI_CODE_IDENTITY_NAME` | Display name the agent calls itself in the system prompt; takes higher priority than `[identity] name` in `config.toml` and is never written back to it | Any non-empty string; blank values read as unset |
| `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` |
| `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-model) in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored |
| `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored |
@ -150,6 +153,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable |
The three `KIMI_CODE_IDENTITY_*` / `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the `agent-core-v2` engine, which currently backs `kimi web` and the `KIMI_CODE_EXPERIMENTAL_FLAG` paths; the default `kimi` / `kimi -p` engine ignores them.
## Diagnostic logs
These variables control log level and file rotation, read once at process startup:

View file

@ -81,7 +81,7 @@ The Kimi-specific user Skill directory moves with `KIMI_CODE_HOME`, so isolated
extra_skill_dirs = ["~/team-skills", ".agents/team-skills"]
```
**Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list.
**Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. Those describing Kimi Code itself can be turned off with the top-level [`builtin_product_skills`](../configuration/config-files.md#top-level-fields) field.
## Invoking a Skill

View file

@ -103,6 +103,7 @@ timeout = 5
| `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills |
| `extra_skill_dirs` | `array<string>` | — | 额外 Skill 搜索目录,叠加到默认目录之上 |
| `extra_agent_dirs` | `array<string>` | — | 额外自定义 Agent 搜索目录,叠加到默认目录之上 |
| `builtin_product_skills` | `boolean` | `true` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills`update-config``custom-theme``mcp-config``check-kimi-code-docs``import-from-cc-codex`。关闭后它们的名称和描述不再进入系统提示词,代价是失去这些任务的引导流程。本字段由 `agent-core-v2` 引擎读取(`kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径),默认引擎会忽略 |
| `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 |
| `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) |
| `models` | `table` | — | 模型别名表 → [`models`](#models) |
@ -114,6 +115,7 @@ timeout = 5
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
| `hooks` | `array<table>` | — | 生命周期 hook详见 [Hooks](../customization/hooks.md) |
| `identity` | `table` | — | 自定义 Agent 身份 → [`identity`](#identity) |
以下各节对 `providers``models``thinking``loop_control``background``image``services``permission` 等嵌套表逐一展开。
@ -297,6 +299,29 @@ max_output_size = 8192
`startup_timeout_ms``tool_timeout_ms` 可分别被环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS``KIMI_MCP_TOOL_TIMEOUT_MS` 覆盖优先级高于配置文件。MCP server 的完整配置方式见 [MCP](../customization/mcp.md)。
## `identity`
自定义 Agent 的身份标识。不设置时行为完全不变。
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `name` | `string` | — | Agent 在系统提示词中的自称(填充 `${product_name}` 变量,你自己的 `SYSTEM.md` 和 agent 文件同样适用) |
| `slug` | `string` | 由 `name` 派生 | 协议字段中使用的机器标识:发给第三方 provider 的 `User-Agent` 产品名,以及连接 MCP 服务器时声明的客户端名。省略时由 `name` 派生:转小写,连续的非字母数字字符折叠为 `-` |
```toml
[identity]
name = "Acme Dev Agent"
slug = "acme-dev" # 可选
```
两个字段都可以通过 `KIMI_CODE_IDENTITY_NAME``KIMI_CODE_IDENTITY_SLUG` 环境变量设置,优先级高于 `config.toml`,且不会被写回配置文件——适合不便写配置文件的容器和 CI 场景。
如果名称中不含任何 ASCII 字母或数字(例如纯中文名称),就无法派生出 slug此时回退为 `agent`;需要特定协议标识请显式填写 `slug`
身份在启动时解析一次,进程生命周期内保持不变——建立连接时它已宣告给 MCP 服务器和 provider中途无法更换。修改本节配置在下次启动时对新会话生效resume 的会话保留录制时的系统提示词,因为其历史轮次本就以原身份自称。同理,已完成的 MCP OAuth 授权保留其授予时的客户端注册;重置该服务器的认证即可在新身份下重新注册。
本节由 `agent-core-v2` 引擎读取,目前 `kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径使用该引擎。默认的 `kimi` / `kimi -p` 引擎会忽略此配置。
## `tools`
`tools` 设置全局工具开关,对所有会话中的每个 Agent 生效,并在 Agent 自身的 `tools` / `disallowedTools` 策略之上再取一次交集。

View file

@ -128,6 +128,9 @@ kimi
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml``[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 |
| `KIMI_CODE_IDENTITY_NAME` | Agent 在系统提示词中的自称,优先级高于 `config.toml``[identity] name`,且不会被写回配置文件 | 任意非空字符串;空值视为未设置 |
| `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` |
| `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills优先级高于 `config.toml``builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 |
| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort优先级高于 `config.toml``[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 |
@ -150,6 +153,8 @@ kimi
| `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 |
`KIMI_CODE_IDENTITY_*``KIMI_CODE_BUILTIN_PRODUCT_SKILLS` 这三个变量由 `agent-core-v2` 引擎读取,目前 `kimi web` 和开启 `KIMI_CODE_EXPERIMENTAL_FLAG` 的路径使用该引擎;默认的 `kimi` / `kimi -p` 引擎会忽略它们。
## 诊断日志
这组变量控制日志级别和文件滚动,进程启动时读取一次:

View file

@ -81,7 +81,7 @@ Kimi 专属用户级 Skill 目录会随 `KIMI_CODE_HOME` 移动,因此隔离
extra_skill_dirs = ["~/team-skills", ".agents/team-skills"]
```
**内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。
**内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。其中介绍 Kimi Code 自身的部分可以通过顶层 [`builtin_product_skills`](../configuration/config-files.md#顶层字段) 字段关闭。
## 调用 Skill

View file

@ -192,7 +192,7 @@ describe('acp-server skills / available commands', () => {
// The model received the rendered skill activation (content + args), not
// the raw slash text.
const history = JSON.stringify(scripted!.callHistory()[0]);
expect(history).toContain('kimi-skill-loaded');
expect(history).toContain('skill-loaded');
expect(history).toContain('Always answer with the word FIXTURE');
expect(history).toContain('ARGUMENTS: some args');
expect(history).not.toContain('/skill:acp-fixture');
@ -211,7 +211,7 @@ describe('acp-server skills / available commands', () => {
expect(scripted!.callCount()).toBe(1);
const history = JSON.stringify(scripted!.callHistory()[0]);
expect(history).toContain('kimi-skill-loaded');
expect(history).toContain('skill-loaded');
expect(history).toContain('write-goal');
}, 30_000);

View file

@ -8,8 +8,9 @@
# commented "# field: type" lines describe the remaining schema fields.
# Values resolve as: default -> config.toml -> env overlay -> memory.
# Index (23 sections · 3 overlay(s))
# Index (25 sections · 3 overlay(s))
# background src/agent/task/configSection.ts
# builtinProductSkills src/app/skillCatalog/configSection.ts
# cron src/app/cron/configSection.ts
# defaultPermissionMode src/agent/permissionMode/configSection.ts
# defaultPlanMode src/agent/plan/configSection.ts
@ -17,6 +18,7 @@
# extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts
# extraSkillDirs src/app/skillCatalog/configSection.ts
# hooks src/agent/externalHooks/configSection.ts
# identity src/app/agentIdentity/configSection.ts
# image src/agent/media/configSection.ts
# loopControl src/agent/loop/configSection.ts
# mcp src/app/mcpConfig/configSection.ts
@ -56,6 +58,17 @@
# print_background_mode: "exit" | "drain" | "steer"
# print_max_turns: integer
# ##########################################################################
# builtinProductSkills (config.toml: builtin_product_skills)
# owner: src/app/skillCatalog/configSection.ts
# scope: core
# hooks: stripEnv
# env:
# <- KIMI_CODE_BUILTIN_PRODUCT_SKILLS (custom parse)
# ##########################################################################
builtin_product_skills = true
# ##########################################################################
# cron
# owner: src/app/cron/configSection.ts
@ -135,6 +148,20 @@ extra_skill_dirs = []
# command: string
# timeout: integer
# ##########################################################################
# identity
# owner: src/app/agentIdentity/configSection.ts
# scope: core
# hooks: stripEnv
# env:
# name <- KIMI_CODE_IDENTITY_NAME (custom parse)
# slug <- KIMI_CODE_IDENTITY_SLUG (custom parse)
# ##########################################################################
[identity]
# name: string
# slug: string
# ##########################################################################
# image
# owner: src/agent/media/configSection.ts

View file

@ -167,6 +167,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
readonly skipped?: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly path: string;
@ -202,6 +203,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}) => void;
register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -227,6 +229,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}, options?: {
readonly replace?: boolean;
}) => void;
@ -260,6 +263,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
} | undefined;
getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -285,6 +289,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
} | undefined;
renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -310,6 +315,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}, rawArgs: string, context?: {
readonly sessionId?: string;
}) => string;
@ -337,6 +343,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -362,6 +369,7 @@ export interface WorkspaceStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
getSkillRoots: () => readonly string[];
getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
@ -485,6 +493,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
readonly skipped?: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly path: string;
@ -520,6 +529,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}) => void;
register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -545,6 +555,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}, options?: {
readonly replace?: boolean;
}) => void;
@ -578,6 +589,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
} | undefined;
getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -603,6 +615,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
} | undefined;
renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -628,6 +641,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}, rawArgs: string, context?: {
readonly sessionId?: string;
}) => string;
@ -655,6 +669,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
@ -680,6 +695,7 @@ export interface SessionStateSnapshot {
};
readonly mermaid?: string;
readonly d2?: string;
readonly productSpecific?: boolean;
}[];
getSkillRoots: () => readonly string[];
getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {

View file

@ -55,10 +55,10 @@ This server requires an OAuth login that has not yet been completed. ` +
1. The tool prints an authorization URL.
2. **You must show that URL to the user verbatim** and ask them to open it
in a browser, sign in, and approve the kimi-code client.
in a browser, sign in, and approve the client.
3. The tool blocks (up to 15 minutes) until the browser redirects back to
the local callback listener.
4. On success, kimi-code reconnects the MCP server and the real tools
4. On success, the client reconnects the MCP server and the real tools
replace this synthetic tool.
Take no arguments. Treat the URL as sensitive do not modify it or strip

View file

@ -41,7 +41,12 @@
* plugin changes reach the prompt when the skill catalog re-pulls its plugin
* source on explicit plugin reload (the Workspace-scope catalog forwards the
* plugin source's change through the session seed) the same point where
* plugin skills take effect. `refreshSystemPrompt` never rejects: a
* plugin skills take effect. The builtin source is refreshed on the same
* signal: it changes only when its config switch is toggled, so it costs what
* a config edit costs, unlike the file-backed sources whose fs watches would
* rebuild every agent's prompt on each edit. Subscribing to the catalog rather
* than to the config section matters the catalog fires after the
* contribution is replaced, so the rebuilt prompt cannot read the old listing. `refreshSystemPrompt` never rejects: a
* failed context build keeps the current prompt and surfaces a warning,
* because the `[tools]` config watcher fires it voided (an unhandled
* rejection would crash kap-server) and the Session tool-policy fan-out
@ -61,8 +66,13 @@
* fields because the container only holds pure data structures. After every
* successful bind / apply / refresh (never before the new prompt commits,
* so a failed build cannot poison the set), the injected AGENTS.md paths are
* seeded into `agentsMdReminder`'s known-set with the effective cwd. Bound at
* Agent scope.
* seeded into `agentsMdReminder`'s known-set with the effective cwd. Fills the
* prompt's product-name slot from the `agentIdentity` snapshot frozen for
* the process, so no `[identity]` subscription belongs here; the template's
* own default applies when nothing is configured. `bind` gates on the freeze
* before materializing the model, whose resolution reads the identity through
* the host-headers port a fast bootstrap must wait, not trip the pre-freeze
* guard. Bound at Agent scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
@ -88,6 +98,7 @@ import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader';
import { ErrorCodes, Error2 } from "#/errors";
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import type { LoopControl } from '#/agent/loop/configSection';
@ -99,7 +110,10 @@ import type { ToolSource } from '#/tool/toolContract';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource';
import {
BUILTIN_SKILL_SOURCE_ID,
PLUGIN_SKILL_SOURCE_ID,
} from '#/app/skillCatalog/skillSource';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
@ -231,6 +245,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
@IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader,
@IAgentStateService private readonly states: IAgentStateService,
@IPluginService private readonly plugins: IPluginService,
@IAgentIdentity private readonly identity: IAgentIdentity,
@IAgentAgentsMdReminderService private readonly agentsMdReminder: IAgentAgentsMdReminderService,
) {
super();
@ -260,7 +275,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
);
this._register(
this.skillCatalog.onDidChange((sourceId) => {
if (sourceId === PLUGIN_SKILL_SOURCE_ID) {
if (sourceId === PLUGIN_SKILL_SOURCE_ID || sourceId === BUILTIN_SKILL_SOURCE_ID) {
void this.refreshSystemPrompt();
}
}),
@ -351,6 +366,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
async bind(input: BindAgentInput): Promise<void> {
await this.catalog.ready;
await this.identity.resolved();
this.assertBindable(input.profile);
const profile = this.catalog.get(input.profile);
if (profile === undefined) {
@ -925,7 +941,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
skills,
pluginSections,
skillActive: this.isToolActiveForProfile(profile, 'Skill'),
productName: this.bootstrap.args.displayName,
productName: (await this.identity.resolved()).displayName,
replyStyleGuide: this.bootstrap.args.replyStyleGuide,
};
}

View file

@ -37,9 +37,9 @@ export function renderModelToolSkillPrompt(input: RenderModelToolSkillPromptInpu
export function renderSkillLoadedBlock(input: RenderSkillLoadedBlockInput): string {
return [
`<kimi-skill-loaded${renderSkillAttributes(input)}>`,
`<skill-loaded${renderSkillAttributes(input)}>`,
input.skillContent,
'</kimi-skill-loaded>',
'</skill-loaded>',
].join('\n');
}

View file

@ -66,7 +66,7 @@ Use `recurring: false` for "remind me at X" style requests, single deadlines, "i
## Session lifetime
Cron tasks live in the current kimi CLI session. When you exit, they
Cron tasks live in the current session. When you exit, they
are persisted under the session homedir; resuming the same session
reloads them and the scheduler resumes from each task's `createdAt`. Fire times that fell during the offline window are
collapsed into a single delivery via `coalescedCount` (and recurring

View file

@ -1,11 +1,14 @@
/**
* `tools` domain `FetchURLTool` implementation.
*
* Receives the App-scope `IWebFetchService` via DI and fetches through its
* host-injected `UrlFetcher`. The default service falls back to the
* built-in `LocalFetchURLProvider`, so `FetchURL` is always available without
* OAuth. Bound at Agent scope; self-registers via `registerAgentToolService(...)` at
* module load.
* Receives the App-scope `IWebFetchService` via DI and resolves its
* host-injected `UrlFetcher` per invocation the service re-reads config and
* login state on each `getUrlFetcher()` call, and composing the fetcher at
* tool construction would both pin that state for the agent's lifetime and
* race the identity freeze during a fast bootstrap. The default service falls
* back to the built-in `LocalFetchURLProvider`, so `FetchURL` is always
* available without OAuth. Bound at Agent scope; self-registers via
* `registerAgentToolService(...)` at module load.
*/
import { toInputJsonSchema } from '#/tool/input-schema';
@ -20,7 +23,7 @@ import { ToolResultBuilder } from '#/tool/result-builder';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
import { IWebFetchService } from '#/app/web/web';
import { HttpFetchError, type UrlFetcher } from '#/app/web/tools/fetch-url-types';
import { HttpFetchError } from '#/app/web/tools/fetch-url-types';
import { FetchURLInputSchema, IFetchURLTool, type FetchURLInput } from './fetch-url';
import DESCRIPTION from './fetch-url.md?raw';
@ -30,11 +33,7 @@ export class FetchURLTool implements IFetchURLTool {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(FetchURLInputSchema);
private readonly fetcher: UrlFetcher;
constructor(@IWebFetchService webFetch: IWebFetchService) {
this.fetcher = webFetch.getUrlFetcher();
}
constructor(@IWebFetchService private readonly webFetch: IWebFetchService) {}
resolveExecution(args: FetchURLInput): ToolExecution {
const preview = args.url.length > 50 ? `${args.url.slice(0, 50)}` : args.url;
@ -53,7 +52,9 @@ export class FetchURLTool implements IFetchURLTool {
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId, signal });
const { content, kind } = await this.webFetch
.getUrlFetcher()
.fetch(args.url, { toolCallId, signal });
if (!content) {
return {

View file

@ -1 +1 @@
Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do not re-invoke a skill to repeat work already done: if a `<kimi-skill-loaded>` block for it with the same `args` is already present in the conversation, follow those instructions directly instead of calling the tool again. Do call the tool again when you need the skill with different arguments — the loaded block was expanded with the earlier `args` and will not reflect new inputs.
Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do not re-invoke a skill to repeat work already done: if a `<skill-loaded>` block for it with the same `args` is already present in the conversation, follow those instructions directly instead of calling the tool again. Do call the tool again when you need the skill with different arguments — the loaded block was expanded with the earlier `args` and will not reflect new inputs.

View file

@ -2,10 +2,14 @@
* `tools` domain `WebSearchTool` implementation (the `WebSearch` tool).
*
* Resolves the host-injected `WebSearchProvider` from the App-scope
* `IWebSearchProviderService` (`auth` domain) at construction the tool only
* activates when a provider is configured, because there is no local search
* backend renders the results through `ToolResultBuilder`, and classifies
* provider errors into model-readable output.
* `IWebSearchProviderService` (`auth` domain) per invocation the activation
* gate checks presence alone, and the provider (which embeds the frozen
* identity headers) only composes once a call needs it, so tool construction
* during a fast bootstrap cannot race the identity freeze and a mid-session
* login or config edit reaches the next call. The tool only activates when a
* provider is configured, because there is no local search backend; results
* render through `ToolResultBuilder`, and provider errors classify into
* model-readable output.
*
* Registered via the module-level `registerAgentToolService(IWebSearchTool,
* WebSearchTool)` at the bottom of this file — the same "import = register"
@ -23,13 +27,11 @@ import {
import { ToolResultBuilder } from '#/tool/result-builder';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch';
import { Error2, ErrorCodes } from '#/errors';
import {
IWebSearchTool,
WebSearchInputSchema,
type WebSearchInput,
type WebSearchProvider,
} from './web-search';
import DESCRIPTION from './web-search.md?raw';
@ -40,17 +42,9 @@ export class WebSearchTool implements IWebSearchTool {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(WebSearchInputSchema);
private readonly provider: WebSearchProvider;
constructor(
@IWebSearchProviderService providerService: IWebSearchProviderService,
) {
const provider = providerService.getWebSearchProvider();
if (provider === undefined) {
throw new Error2(ErrorCodes.INTERNAL, 'WebSearchProviderService returned no provider during tool activation.');
}
this.provider = provider;
}
@IWebSearchProviderService private readonly providerService: IWebSearchProviderService,
) {}
resolveExecution(args: WebSearchInput): ToolExecution {
const preview = args.query.length > 40 ? `${args.query.slice(0, 40)}` : args.query;
@ -68,8 +62,15 @@ export class WebSearchTool implements IWebSearchTool {
args: WebSearchInput,
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
const provider = this.providerService.getWebSearchProvider();
if (provider === undefined) {
return {
isError: true,
output: 'Web search is no longer configured; the provider was removed after this session started.',
};
}
try {
const results = await this.provider.search(args.query, { toolCallId, signal });
const results = await provider.search(args.query, { toolCallId, signal });
const builder = new ToolResultBuilder({ maxLineLength: null });
if (results.length === 0) {
@ -133,5 +134,5 @@ function classifySearchError(error: unknown): string {
registerAgentToolService(IWebSearchTool, WebSearchTool, {
name: 'WebSearch',
domain: 'auth',
when: (accessor) => accessor.get(IWebSearchProviderService).getWebSearchProvider() !== undefined,
when: (accessor) => accessor.get(IWebSearchProviderService).hasWebSearchProvider(),
});

View file

@ -0,0 +1,94 @@
/**
* `agentIdentity` domain resolved identity contract.
*
* The identity the agent uses for itself, resolved from the `[identity]`
* config section over the host's declared display name and frozen for the
* life of the process: the identity is announced outward (MCP initialize,
* OAuth registration, provider request logs) and cannot be re-announced, so
* restart-to-change is the one coherent semantic and consumers may bake the
* snapshot into caches, prompts, and connections with no invalidation
* obligations. `resolved()` awaits the freeze; `current()` throws before it,
* so an early materialization fails loudly instead of caching a pre-config
* value. Bound at App scope.
*
* The snapshot carries finished products, never raw material for call sites
* to compose: the prompt display name, the protocol slug (`undefined` on
* either means no custom identity consumers keep their built-in behavior),
* and the outbound `User-Agent` projections, which rewrite only the product
* token of what the host already sends (the key located case-insensitively,
* the host's spelling kept) except toward directories this process chooses
* to call, where a header is always presented.
*/
import { replaceUserAgentProduct } from '@moonshot-ai/kimi-code-oauth';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export const DEFAULT_IDENTITY_SLUG = 'agent';
export interface AgentIdentitySnapshot {
readonly displayName: string | undefined;
readonly slug: string | undefined;
readonly outboundUserAgent: string;
readonly thirdPartyUserAgent: string | undefined;
readonly requestHeaders: Readonly<Record<string, string>>;
}
export interface IAgentIdentity {
readonly _serviceBrand: undefined;
resolved(): Promise<AgentIdentitySnapshot>;
current(): AgentIdentitySnapshot;
}
export const IAgentIdentity: ServiceIdentifier<IAgentIdentity> =
createDecorator<IAgentIdentity>('agentIdentity');
export function normalizeIdentitySlug(raw: string): string {
const folded = raw
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '-')
.replaceAll(/^-+|-+$/g, '');
return folded.length > 0 ? folded : DEFAULT_IDENTITY_SLUG;
}
export interface AgentIdentityInput {
readonly name?: string;
readonly slug?: string;
readonly hostDisplayName?: string;
readonly hostRequestHeaders: Readonly<Record<string, string>>;
}
export function buildAgentIdentitySnapshot(input: AgentIdentityInput): AgentIdentitySnapshot {
const name = declared(input.name);
const rawSlug = declared(input.slug) ?? name;
const slug = rawSlug === undefined ? undefined : normalizeIdentitySlug(rawSlug);
const userAgentKeys = Object.keys(input.hostRequestHeaders).filter(
(key) => key.toLowerCase() === 'user-agent',
);
const hostUserAgent =
userAgentKeys[0] === undefined ? undefined : input.hostRequestHeaders[userAgentKeys[0]];
const thirdPartyUserAgent =
hostUserAgent === undefined || slug === undefined
? hostUserAgent
: replaceUserAgentProduct(hostUserAgent, slug);
const requestHeaders: Record<string, string> = { ...input.hostRequestHeaders };
if (slug !== undefined) {
for (const key of userAgentKeys) {
const value = requestHeaders[key];
if (value !== undefined) requestHeaders[key] = replaceUserAgentProduct(value, slug);
}
}
return {
displayName: name ?? declared(input.hostDisplayName),
slug,
outboundUserAgent: thirdPartyUserAgent ?? slug ?? DEFAULT_IDENTITY_SLUG,
thirdPartyUserAgent,
requestHeaders,
};
}
function declared(raw: string | undefined): string | undefined {
const trimmed = raw?.trim();
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}

View file

@ -0,0 +1,72 @@
/**
* `agentIdentity` domain `IAgentIdentity` implementation.
*
* Builds the process-lifetime snapshot from the `[identity]` config section
* (which already layers `env > config.toml`) and the host's declared display
* name and request headers in `IBootstrapService.args`, once config has first
* loaded; later `[identity]` edits take effect on the next start. Bound at
* App scope, activated eagerly so the freeze is armed before any consumer can
* observe config readiness a config load failure still freezes, from
* whatever the config service then serves, matching what every other section
* consumer would read.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { CoreErrors } from '#/_base/errors/codes';
import { Error2 } from '#/_base/errors/errors';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import {
buildAgentIdentitySnapshot,
IAgentIdentity,
type AgentIdentitySnapshot,
} from './agentIdentity';
import { IDENTITY_SECTION, type IdentityConfig } from './configSection';
export class AgentIdentityService implements IAgentIdentity {
declare readonly _serviceBrand: undefined;
private snapshot: AgentIdentitySnapshot | undefined;
private readonly frozen: Promise<AgentIdentitySnapshot>;
constructor(
@IConfigService config: IConfigService,
@IBootstrapService bootstrap: IBootstrapService,
) {
this.frozen = config.ready
.catch(() => undefined)
.then(() => {
const section = config.get<IdentityConfig | undefined>(IDENTITY_SECTION) ?? {};
this.snapshot = buildAgentIdentitySnapshot({
name: section.name,
slug: section.slug,
hostDisplayName: bootstrap.args.displayName,
hostRequestHeaders: bootstrap.args.requestHeaders,
});
return this.snapshot;
});
}
resolved(): Promise<AgentIdentitySnapshot> {
return this.frozen;
}
current(): AgentIdentitySnapshot {
if (this.snapshot === undefined) {
throw new Error2(
CoreErrors.codes.INTERNAL,
'agent identity read before config load completed',
);
}
return this.snapshot;
}
}
registerScopedService(
LifecycleScope.App,
IAgentIdentity,
AgentIdentityService,
ScopeActivation.OnScopeCreated,
'agentIdentity',
);

View file

@ -0,0 +1,58 @@
/**
* `agentIdentity` domain the `[identity]` config section.
*
* Owns the user-facing custom-identity preference: `name`, the display name in
* the system prompt, and the optional `slug` that goes into protocol fields.
* Both bind to `KIMI_CODE_IDENTITY_NAME` / `KIMI_CODE_IDENTITY_SLUG` so a
* container or CI run can state an identity without writing `config.toml`; an
* env override never persists back into the file. Leaving the section unset
* means no custom identity, and every consumer keeps its current behavior.
*
* Unlike most sections this one is read exactly once: `agentIdentity` freezes
* its snapshot when config first loads, so edits apply on the next start
* see the domain contract for why mid-process changes cannot be honored.
*
* Self-registered at module load via `registerConfigSection`.
*/
import { z } from 'zod';
import {
type EnvBindings,
envBindings,
stripEnvBoundFields,
} from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';
export const IDENTITY_SECTION = 'identity';
export const IdentityConfigSchema = z.object({
name: z.string().optional(),
slug: z.string().optional(),
});
export type IdentityConfig = z.infer<typeof IdentityConfigSchema>;
export const IDENTITY_NAME_ENV = 'KIMI_CODE_IDENTITY_NAME';
export const IDENTITY_SLUG_ENV = 'KIMI_CODE_IDENTITY_SLUG';
function parseIdentityEnv(raw: string): string | undefined {
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export const identityEnvBindings: EnvBindings<IdentityConfig> = envBindings(
IdentityConfigSchema,
{
name: { env: IDENTITY_NAME_ENV, parse: parseIdentityEnv },
slug: { env: IDENTITY_SLUG_ENV, parse: parseIdentityEnv },
},
);
export const stripIdentityEnv = stripEnvBoundFields(identityEnvBindings);
registerConfigSection(IDENTITY_SECTION, IdentityConfigSchema, {
defaultValue: {},
env: identityEnvBindings,
stripEnv: stripIdentityEnv,
});

View file

@ -4,7 +4,9 @@
* Owns the seam for the `WebSearch` backend, which needs an authenticated
* Moonshot search provider. `IWebSearchProviderService` exposes the
* configured `WebSearchProvider` (or `undefined` when search is not
* configured). Tests and hosts that need a custom backend bind
* configured), and `hasWebSearchProvider` answers presence alone for tool
* activation gates, which may run before the identity snapshot the composed
* provider embeds has frozen. Tests and hosts that need a custom backend bind
* `IWebSearchProviderService` directly. Bound at App scope.
*/
@ -18,6 +20,7 @@ export interface IWebSearchProviderService {
readonly _serviceBrand: undefined;
getWebSearchProvider(): WebSearchProvider | undefined;
hasWebSearchProvider(): boolean;
}
export const IWebSearchProviderService: ServiceIdentifier<IWebSearchProviderService> =

View file

@ -9,23 +9,30 @@
* state after a successful Kimi login), whose bearer token comes from
* `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from
* the provider's `baseUrl`. The explicit config wins over the managed
* derivation. Both use the host's Kimi identity headers
* (`IBootstrapService.args.requestHeaders`) as default headers. When neither
* source is configured it yields `undefined`.
* derivation. When neither source is configured it yields `undefined`.
* Tests and hosts that need a custom backend bind `IWebSearchProviderService`
* directly. Bound at App scope.
*
* Default headers split by who chose the endpoint: a `[services]` entry names
* its own, so that path sends `agentIdentity`'s frozen `requestHeaders` the
* host header set with the `User-Agent` product token rewritten to the
* configured identity while the managed OAuth path sends the host's own
* headers (`IBootstrapService.args.requestHeaders`) verbatim, being the
* endpoint the session authenticated against.
*/
import {
KIMI_CODE_PROVIDER_NAME,
kimiCodeBaseUrl,
type BearerTokenProvider,
} from '@moonshot-ai/kimi-code-oauth';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IOAuthService } from '#/app/auth/auth';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IProviderService } from '#/kosong/provider/provider';
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
import { SERVICES_SECTION, type ServicesConfig } from '../configSection';
@ -41,31 +48,26 @@ export class WebSearchProviderService implements IWebSearchProviderService {
@IOAuthService private readonly oauth: IOAuthService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IConfigService private readonly config: IConfigService,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {}
getWebSearchProvider(): WebSearchProvider | undefined {
return this.fromServicesConfig() ?? this.fromManagedOAuth();
}
private fromServicesConfig(): WebSearchProvider | undefined {
const search = this.config.get<ServicesConfig>(SERVICES_SECTION)?.moonshotSearch;
if (search?.baseUrl === undefined) {
return undefined;
}
const tokenProvider =
search.oauth === undefined
? undefined
: this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, search.oauth);
return new MoonshotWebSearchProvider({
baseUrl: search.baseUrl,
tokenProvider,
apiKey: nonEmptyString(search.apiKey),
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
customHeaders: search.customHeaders,
});
hasWebSearchProvider(): boolean {
return this.configuredSearch() !== undefined || this.managedTokenProvider() !== undefined;
}
private fromManagedOAuth(): WebSearchProvider | undefined {
private configuredSearch(): (ServicesConfig['moonshotSearch'] & { baseUrl: string }) | undefined {
const search = this.config.get<ServicesConfig>(SERVICES_SECTION)?.moonshotSearch;
if (search?.baseUrl === undefined) return undefined;
return search as ServicesConfig['moonshotSearch'] & { baseUrl: string };
}
private managedTokenProvider():
| { provider: ProviderConfig; tokenProvider: BearerTokenProvider }
| undefined {
const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME);
if (provider === undefined || !isOAuthCatalogVendor(provider.type) || provider.oauth === undefined) {
return undefined;
@ -74,9 +76,30 @@ export class WebSearchProviderService implements IWebSearchProviderService {
KIMI_CODE_PROVIDER_NAME,
provider.oauth,
);
if (tokenProvider === undefined) {
return undefined;
}
if (tokenProvider === undefined) return undefined;
return { provider, tokenProvider };
}
private fromServicesConfig(): WebSearchProvider | undefined {
const search = this.configuredSearch();
if (search === undefined) return undefined;
const tokenProvider =
search.oauth === undefined
? undefined
: this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, search.oauth);
return new MoonshotWebSearchProvider({
baseUrl: search.baseUrl,
tokenProvider,
apiKey: nonEmptyString(search.apiKey),
defaultHeaders: { ...this.identity.current().requestHeaders },
customHeaders: search.customHeaders,
});
}
private fromManagedOAuth(): WebSearchProvider | undefined {
const managed = this.managedTokenProvider();
if (managed === undefined) return undefined;
const { provider, tokenProvider } = managed;
const baseUrl = `${(provider.baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/search`;
return new MoonshotWebSearchProvider({
baseUrl,

View file

@ -8,6 +8,10 @@
* kosong's in-memory registries), and publishes `event.model_catalog.changed`
* on change. Bound at App scope.
*
* Custom registries are third-party endpoints, so the refresh User-Agent
* carries the configured custom identity's product token, matching what chat
* requests send.
*
* `modelSource: 'static'` short-circuits refresh: a provider whose effective
* model source is `static` (config-declared, or declared by its vendor
* definition) serves its models from the static `[models.*]` section, so
@ -47,7 +51,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
import { Error2 } from '#/_base/errors/errors';
import { IOAuthService } from '#/app/auth/auth';
import { AuthErrors } from '#/app/auth/errors';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IConfigService } from '#/app/config/config';
import { IEventService } from '#/app/event/event';
import { ModelCatalogErrors } from '#/kosong/model/errors';
@ -91,7 +95,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
@IConfigService private readonly config: IConfigService,
@IOAuthService private readonly oauth: IOAuthService,
@IEventService private readonly events: IEventService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {}
refreshProviderModels(
@ -123,7 +127,8 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
}
const exclusion = this.computeStaticExclusion();
const result = await refreshProviderModels(this.buildRefreshHost(exclusion), {
const { outboundUserAgent } = await this.identity.resolved();
const result = await refreshProviderModels(this.buildRefreshHost(exclusion, outboundUserAgent), {
scope: options.scope,
providerId: options.providerId,
});
@ -176,13 +181,13 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
};
}
private buildRefreshHost(exclusion: StaticExclusion): RefreshProviderHost {
private buildRefreshHost(exclusion: StaticExclusion, userAgent: string): RefreshProviderHost {
return {
getConfig: async () => this.readUserConfigShape(exclusion),
removeProvider: (providerId) => this.shapeWithoutProvider(providerId),
setConfig: (patch) => this.applyRefreshPatch(patch, exclusion),
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
userAgent: this.bootstrap.args.requestHeaders['User-Agent'],
userAgent,
};
}

View file

@ -1,23 +1,42 @@
/**
* `kosongConfig` domain `IHostRequestHeaders` implementation.
*
* Bridges kosong's host-headers port to the host invocation args: the headers
* are the ones the host stated in `BootstrapInput.args.requestHeaders`
* (usually built through `createKimiDefaultHeaders`), exposed through
* `IBootstrapService.args`. kosong's model catalog only sees the port. Bound
* at App scope.
* Bridges kosong's host-headers port to the host invocation args: `headers`
* is what the host stated in `BootstrapInput.args.requestHeaders` (usually
* built through `createKimiDefaultHeaders`), verbatim; `thirdPartyHeaders` is
* the `User-Agent`-only layer with the product token taken from the frozen
* identity snapshot. kosong's model catalog only sees the port. Bound at App
* scope.
*
* The third-party layer reads `agentIdentity.current()`, which throws until
* config has first loaded so a model materialized too early fails loudly
* instead of caching headers that misstate the configured identity. Vendors
* on the full-headers path never touch it.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
export class HostRequestHeadersAdapter implements IHostRequestHeaders {
readonly headers: Readonly<Record<string, string>>;
constructor(@IBootstrapService bootstrap: IBootstrapService) {
constructor(
@IBootstrapService bootstrap: IBootstrapService,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {
this.headers = bootstrap.args.requestHeaders;
}
get thirdPartyHeaders(): Readonly<Record<string, string>> {
const userAgent = this.identity.current().thirdPartyUserAgent;
return userAgent === undefined ? {} : { 'User-Agent': userAgent };
}
get identitySlug(): string | undefined {
return this.identity.current().slug;
}
}
registerScopedService(

View file

@ -25,6 +25,11 @@
* passes (drop, then re-add onto clean slots). The kosong persistence
* bridge then pushes the change into the registries, which is also what
* invalidates the runtime model catalog.
*
* Both third-party fetches the models.dev directory and the custom-registry
* import send the identity snapshot's `outboundUserAgent`, matching what
* the scheduled refresh of the same registry sends: these are directories
* this service chooses to call, so a header is always sent.
*/
import {
@ -38,6 +43,7 @@ import {
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { Error2 } from '#/_base/errors/errors';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IConfigService } from '#/app/config/config';
import { IModelCatalog } from '#/kosong/model/catalog';
import { type ModelsSection } from '#/kosong/model/model';
@ -76,15 +82,20 @@ export class ModelsDevImportService implements IModelsDevImportService {
@IConfigService private readonly config: IConfigService,
@IKosongConfigService private readonly kosongConfig: IKosongConfigService,
@IModelCatalog private readonly modelCatalog: IModelCatalog,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {}
private async outboundUserAgent(): Promise<string> {
return (await this.identity.resolved()).outboundUserAgent;
}
async listModelsDevProviders(): Promise<ModelsDevProviderItem[]> {
const catalog = await getModelsDevCatalog();
const catalog = await getModelsDevCatalog(await this.outboundUserAgent());
return Object.entries(catalog).map(([id, entry]) => toModelsDevProviderItem(id, entry));
}
async getModelsDevProvider(catalogId: string): Promise<ModelsDevProviderItem> {
const catalog = await getModelsDevCatalog();
const catalog = await getModelsDevCatalog(await this.outboundUserAgent());
const entry = modelsDevEntry(catalog, catalogId);
if (entry === undefined) {
throw new Error2(
@ -126,7 +137,7 @@ export class ModelsDevImportService implements IModelsDevImportService {
options: ImportModelsDevProviderOptions,
): Promise<ImportModelsDevProviderResult> {
const { catalogId } = options;
const catalog = await getModelsDevCatalog();
const catalog = await getModelsDevCatalog(await this.outboundUserAgent());
const entry = modelsDevEntry(catalog, catalogId);
if (entry === undefined) {
throw new Error2(
@ -216,7 +227,7 @@ export class ModelsDevImportService implements IModelsDevImportService {
try {
entries = await fetchCustomRegistry(source, {
fetchImpl: upstreamFetch(),
userAgent: 'kimi-code-kap-server',
userAgent: await this.outboundUserAgent(),
signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS),
});
} catch (err) {

View file

@ -2,6 +2,12 @@
* `kosongConfig` domain models.dev upstream: fetch the third-party
* directory, in-memory cache, built-in snapshot fallback, and the pruned
* item mapping behind the import service's browse methods.
*
* The caller states the outbound `User-Agent`: this module is plain
* module-level state with no container access, and the value depends on the
* host and the configured identity, which only the calling service can see.
* The cached catalog does not vary by caller, so a later call with a different
* value still reuses it.
*/
import { CoreErrors } from '#/_base/errors/codes';
@ -64,20 +70,20 @@ export function upstreamFetch(): typeof fetch {
return fetchImpl;
}
export async function getModelsDevCatalog(): Promise<ModelsDevCatalog> {
export async function getModelsDevCatalog(userAgent: string): Promise<ModelsDevCatalog> {
const now = nowImpl();
if (cache !== undefined && now - cache.fetchedAt < CACHE_TTL_MS) return cache.catalog;
inFlight ??= fetchAndCache().finally(() => {
inFlight ??= fetchAndCache(userAgent).finally(() => {
inFlight = undefined;
});
return inFlight;
}
async function fetchAndCache(): Promise<ModelsDevCatalog> {
async function fetchAndCache(userAgent: string): Promise<ModelsDevCatalog> {
const now = nowImpl();
try {
const res = await fetchImpl(MODELS_DEV_URL, {
headers: { Accept: 'application/json', 'User-Agent': 'kimi-code-kap-server' },
headers: { Accept: 'application/json', 'User-Agent': userAgent },
signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS),
});
if (!res.ok) {

View file

@ -3,11 +3,15 @@
*
* Code-defined builtin skills are constants (not discovered from storage), so
* they bypass `ISkillDiscovery`: `BUILTIN_SKILLS` feeds the builtin
* `ISkillSource`, and `registerBuiltinSkills` stamps them into an in-memory
* catalog for edge composition without a Session.
* `ISkillSource`.
*
* `visibleBuiltinSkills` is the one place that decides which of them the
* `builtin_product_skills` switch excludes. Every consumer goes through it the
* session-scoped source and the session-less workspace listings alike so a
* skill marked `productSpecific` cannot stay advertised on one surface while
* being filtered on another.
*/
import type { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
import type { SkillDefinition } from '#/app/skillCatalog/types';
import { CHECK_KIMI_CODE_DOCS_SKILL } from './check-kimi-code-docs';
import { CUSTOM_THEME_SKILL } from './custom-theme';
@ -33,10 +37,9 @@ export const BUILTIN_SKILLS: readonly SkillDefinition[] = [
SUB_SKILL_CONSOLIDATE,
];
export function registerBuiltinSkills(registry: InMemorySkillCatalog): void {
for (const skill of BUILTIN_SKILLS) {
registry.registerBuiltinSkill(skill);
}
export function visibleBuiltinSkills(productSkillsEnabled: boolean): readonly SkillDefinition[] {
if (productSkillsEnabled) return BUILTIN_SKILLS;
return BUILTIN_SKILLS.filter((skill) => skill.productSpecific !== true);
}
export {

View file

@ -23,4 +23,5 @@ export const CHECK_KIMI_CODE_DOCS_SKILL: SkillDefinition = {
...parsed.metadata,
type: parsed.metadata.type ?? 'inline',
},
productSpecific: true,
};

View file

@ -24,4 +24,5 @@ export const CUSTOM_THEME_SKILL: SkillDefinition = {
type: parsed.metadata.type ?? 'inline',
disableModelInvocation: true,
},
productSpecific: true,
};

View file

@ -24,4 +24,5 @@ export const IMPORT_FROM_CC_CODEX_SKILL: SkillDefinition = {
type: parsed.metadata.type ?? 'inline',
disableModelInvocation: true,
},
productSpecific: true,
};

View file

@ -24,4 +24,5 @@ export const MCP_CONFIG_SKILL: SkillDefinition = {
type: parsed.metadata.type ?? 'inline',
disableModelInvocation: true,
},
productSpecific: true,
};

View file

@ -23,4 +23,5 @@ export const UPDATE_CONFIG_SKILL: SkillDefinition = {
...parsed.metadata,
type: parsed.metadata.type ?? 'inline',
},
productSpecific: true,
};

View file

@ -2,15 +2,33 @@
* `skillCatalog` domain builtin `ISkillSource` producer.
*
* Yields the code-defined `BUILTIN_SKILLS` as the lowest-priority contribution
* (`builtin`, priority 0) so extra / user / workspace / plugin skills override it on
* name collision. Bound at App scope.
* (`builtin`, priority 0) so extra / user / workspace / plugin skills override
* it on name collision. Bound at App scope.
*
* Product-documentation skills are filtered here rather than downstream: their
* names sit in the system prompt for the whole session, and being the
* lowest-priority source this one loads first and is kept for the life of the
* handler hence the wait for config readiness, and the change event that
* lets the catalog reload it when the switch is toggled.
*/
import { Emitter, type Event } from '#/_base/event';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IConfigService } from '#/app/config/config';
import { BUILTIN_SKILLS } from './builtin/builtin';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource';
import { visibleBuiltinSkills } from './builtin/builtin';
import {
BUILTIN_PRODUCT_SKILLS_SECTION,
builtinProductSkillsEnabled,
} from './configSection';
import {
BUILTIN_SKILL_SOURCE_ID,
SKILL_SOURCE_PRIORITY,
type ISkillSource,
type SkillContribution,
} from './skillSource';
export interface IBuiltinSkillSource extends ISkillSource {
readonly _serviceBrand: undefined;
@ -19,14 +37,26 @@ export interface IBuiltinSkillSource extends ISkillSource {
export const IBuiltinSkillSource: ServiceIdentifier<IBuiltinSkillSource> =
createDecorator<IBuiltinSkillSource>('builtinSkillSource');
export class BuiltinSkillSource implements IBuiltinSkillSource {
export class BuiltinSkillSource extends Disposable implements IBuiltinSkillSource {
declare readonly _serviceBrand: undefined;
readonly id = 'builtin';
readonly id = BUILTIN_SKILL_SOURCE_ID;
readonly priority = SKILL_SOURCE_PRIORITY.builtin;
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
constructor(@IConfigService private readonly config: IConfigService) {
super();
this._register(
this.config.onDidSectionChange((event) => {
if (event.domain === BUILTIN_PRODUCT_SKILLS_SECTION) this.onDidChangeEmitter.fire();
}),
);
}
async load(): Promise<SkillContribution> {
return { skills: BUILTIN_SKILLS };
await this.config.ready;
return { skills: visibleBuiltinSkills(builtinProductSkillsEnabled(this.config)) };
}
}

View file

@ -2,12 +2,37 @@
* `skillCatalog` domain skill config sections.
*
* Registers the v1-compatible top-level config domains `extraSkillDirs` and
* `mergeAllAvailableSkills`. Values stay camelCase in memory; TOML uses the
* snake_case keys `extra_skill_dirs` and `merge_all_available_skills`.
* `mergeAllAvailableSkills`, plus `builtinProductSkills`. Values stay camelCase
* in memory; TOML uses the snake_case keys `extra_skill_dirs`,
* `merge_all_available_skills`, and `builtin_product_skills`.
*
* `builtinProductSkills` decides whether the builtin skills documenting this
* CLI itself its `config.toml` / `tui.toml` settings, custom themes, MCP
* setup, the official docs lookup, and the Claude Code / Codex import are
* offered to the model. On by default; turning it off trims their names and
* descriptions from the system prompt, where they otherwise sit on every turn,
* at the cost of the guided flows for those tasks. Useful for unattended runs,
* or deployments where nobody reconfigures the CLI mid-task.
*
* That section is a whole-section scalar rather than an object of fields, so
* the env binding covers it directly and it needs its own strip:
* `stripEnvBoundFields` only walks object fields, so an env override would
* otherwise be written back into `config.toml`. The strip restores the
* env-free file value while the env var resolves, and drops the field when the
* file held anything but a boolean. `builtinProductSkillsEnabled` reads the
* resolved switch; only an explicit opt-out disables, so a missing or
* not-yet-registered section behaves like the shipped default.
*/
import { z } from 'zod';
import { parseBooleanEnv } from '#/_base/utils/env';
import {
type ConfigStripEnv,
type EnvBindings,
envBindings,
type IConfigService,
} from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';
export const EXTRA_SKILL_DIRS_SECTION = 'extraSkillDirs';
@ -25,3 +50,35 @@ export type MergeAllAvailableSkillsConfig = z.infer<typeof MergeAllAvailableSkil
registerConfigSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION, MergeAllAvailableSkillsConfigSchema, {
defaultValue: true,
});
export const BUILTIN_PRODUCT_SKILLS_SECTION = 'builtinProductSkills';
export const BuiltinProductSkillsConfigSchema = z.boolean().optional();
export type BuiltinProductSkillsConfig = z.infer<typeof BuiltinProductSkillsConfigSchema>;
export const BUILTIN_PRODUCT_SKILLS_ENV = 'KIMI_CODE_BUILTIN_PRODUCT_SKILLS';
export const builtinProductSkillsEnvBindings: EnvBindings<BuiltinProductSkillsConfig> =
envBindings(BuiltinProductSkillsConfigSchema, {
env: BUILTIN_PRODUCT_SKILLS_ENV,
parse: parseBooleanEnv,
});
export const stripBuiltinProductSkillsEnv: ConfigStripEnv<BuiltinProductSkillsConfig> = (
value,
raw,
getEnv,
) => {
if (getEnv === undefined) return value;
if (parseBooleanEnv(getEnv(BUILTIN_PRODUCT_SKILLS_ENV)) === undefined) return value;
return typeof raw === 'boolean' ? raw : undefined;
};
registerConfigSection(BUILTIN_PRODUCT_SKILLS_SECTION, BuiltinProductSkillsConfigSchema, {
defaultValue: true,
env: builtinProductSkillsEnvBindings,
stripEnv: stripBuiltinProductSkillsEnv,
});
export function builtinProductSkillsEnabled(config: IConfigService): boolean {
return config.get<BuiltinProductSkillsConfig>(BUILTIN_PRODUCT_SKILLS_SECTION) !== false;
}

View file

@ -82,9 +82,9 @@ export class InMemorySkillCatalog implements SkillCatalog {
const instructions = plugin.instructions;
if (instructions === undefined || instructions.trim().length === 0) return content;
return (
`<kimi-plugin-instructions plugin="${escapeXmlAttr(plugin.id)}">\n` +
`<plugin-instructions plugin="${escapeXmlAttr(plugin.id)}">\n` +
`${instructions}\n` +
`</kimi-plugin-instructions>\n\n${content}`
`</plugin-instructions>\n\n${content}`
);
}

View file

@ -31,6 +31,7 @@ export const SKILL_SOURCE_PRIORITY = {
} as const;
export const PLUGIN_SKILL_SOURCE_ID = 'plugin';
export const BUILTIN_SKILL_SOURCE_ID = 'builtin';
export interface ISkillSource {
readonly _serviceBrand: undefined;

View file

@ -1,3 +1,13 @@
/**
* `skillCatalog` domain skill data types.
*
* The shapes every skill source produces and the catalog stores. A definition
* marked `productSpecific` documents this CLI itself its configuration,
* themes, MCP setup rather than a capability the agent applies to the user's
* work, which is what the `builtin_product_skills` switch excludes; those
* names and descriptions otherwise sit in the system prompt every turn.
*/
export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';
export interface SkillMetadata {
@ -23,6 +33,7 @@ export interface SkillDefinition {
readonly plugin?: SkillPluginContext;
readonly mermaid?: string | undefined;
readonly d2?: string;
readonly productSpecific?: boolean;
}
export interface SkillSummary {

View file

@ -8,11 +8,17 @@
* Kimi OAuth provider when it carries an `oauth` ref (the state after a
* successful Kimi login), routing fetches through the Moonshot fetch service
* (`${provider.baseUrl}/fetch`); and (3) the built-in `LocalFetchURLProvider`,
* so `FetchURL` keeps working without any configuration. The first two use the
* host's Kimi identity headers (`IBootstrapService.args.requestHeaders`) and
* fall back to the local fetcher on failure. Reads config and the managed
* provider lazily on each `getUrlFetcher()` call so it tracks edits and login
* state. Bound at App scope.
* so `FetchURL` keeps working without any configuration. The first two fall
* back to the local fetcher on failure. Reads config and the managed provider
* lazily on each `getUrlFetcher()` call so it tracks edits and login state.
* Bound at App scope.
*
* Default headers split by who chose the endpoint: a `[services]` entry names
* its own, so that path sends `agentIdentity`'s frozen `requestHeaders` the
* host header set with the `User-Agent` product token rewritten to the
* configured identity while the managed OAuth path sends the host's own
* headers (`IBootstrapService.args.requestHeaders`) verbatim, being the
* endpoint the session authenticated against.
*/
import {
@ -23,6 +29,7 @@ import {
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IOAuthService } from '#/app/auth/auth';
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IProviderService } from '#/kosong/provider/provider';
@ -42,6 +49,7 @@ export class WebFetchService implements IWebFetchService {
@IOAuthService private readonly oauth: IOAuthService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IConfigService private readonly config: IConfigService,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {
this.localFetcher = new LocalFetchURLProvider();
}
@ -63,7 +71,7 @@ export class WebFetchService implements IWebFetchService {
baseUrl: fetchConfig.baseUrl,
tokenProvider,
apiKey: nonEmptyString(fetchConfig.apiKey),
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
defaultHeaders: { ...this.identity.current().requestHeaders },
customHeaders: fetchConfig.customHeaders,
localFallback: this.localFetcher,
});

View file

@ -108,6 +108,10 @@ export * from '#/kosong/provider/providerService';
export * from '#/kosong/provider/providerDefinition';
export * from '#/kosong/provider/protocolAdapterRegistry';
import '#/app/skillCatalog/configSection';
import '#/app/agentIdentity/configSection';
export * from '#/app/agentIdentity/configSection';
export * from '#/app/agentIdentity/agentIdentity';
export * from '#/app/agentIdentity/agentIdentityService';
import '#/kosong/protocol/errors';
export * from '#/kosong/protocol/errors';
export * from '#/kosong/protocol/protocol';

View file

@ -28,7 +28,9 @@
* model/provider config-change events. Tests that mutate config
* behind the services' backs (bypassing those events) must call
* `notifyConfigChanged()` to drop the cache otherwise `get` keeps serving
* the previous generation's Model.
* the previous generation's Model. The host-header layers baked into an
* entry need no invalidation: both are frozen for the process (bootstrap
* args, and the identity snapshot behind the third-party layer).
*
* Inspection: every assembly also captures a `ResolutionTraceCollector`
* (provenance records + intermediate artifacts, reference-only) alongside the
@ -42,6 +44,14 @@
* provider registry plus credential state. `setDefaultModel` writes the
* global default-model pointer (through `IModelService`) after a
* materialization gate the catalog's only write.
*
* Outbound headers: vendors declaring `hostHeaders: 'full'` receive the host
* headers port's complete set and stay consistent with it that set is the
* host's to define, and backends key on the product token it carries (log
* filtering, rollout gating). Everyone else receives the port's third-party
* layer, already finished on the app side (at most a `User-Agent`, product
* token per the configured identity) this catalog picks a layer, it never
* edits one.
*/
import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth';
@ -387,6 +397,8 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase()));
trace.capture(TRACE.hostHeaders, this.hostRequestHeaders.headers);
trace.capture(TRACE.thirdPartyHeaders, this.hostRequestHeaders.thirdPartyHeaders);
trace.capture(TRACE.identitySlug, this.hostRequestHeaders.identitySlug);
return {
id,
name: wireName,
@ -396,7 +408,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
headers: resolveOutboundHeaders(
providerConfig?.type,
providerConfig?.customHeaders,
this.hostRequestHeaders.headers,
this.hostRequestHeaders,
),
capabilities,
maxContextSize: model.maxContextSize,
@ -558,20 +570,15 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
export function resolveOutboundHeaders(
providerType: string | undefined,
customHeaders: Readonly<Record<string, string>> | undefined,
hostHeaders: Readonly<Record<string, string>>,
host: Pick<IHostRequestHeaders, 'headers' | 'thirdPartyHeaders'>,
): Readonly<Record<string, string>> {
const forwardsAll =
providerType !== undefined &&
getProviderDefinition(providerType)?.hostHeaders === 'full';
const hostLayer = forwardsAll ? hostHeaders : userAgentOnly(hostHeaders);
const hostLayer = forwardsAll ? host.headers : host.thirdPartyHeaders;
return { ...parseKimiCodeCustomHeaders(), ...hostLayer, ...customHeaders };
}
function userAgentOnly(headers: Readonly<Record<string, string>>): Record<string, string> {
const userAgent = headers['User-Agent'];
return userAgent === undefined ? {} : { 'User-Agent': userAgent };
}
function resolveModelCapabilities(
declaredCapabilities: readonly string[] | undefined,
detected: ModelCapability,

View file

@ -7,15 +7,24 @@
* `BootstrapInput.args.requestHeaders`; the app-side adapter
* (`app/kosongConfig/hostRequestHeadersAdapter`) bridges
* `IBootstrapService.args` to this port so kosong stays a pure abstraction
* layer. `ModelCatalog` merges them per vendor the full set for vendors
* whose definition declares `hostHeaders: 'full'`, only the `User-Agent` for
* everyone else (so device identity never leaks to third-party endpoints).
* layer. The port carries two finished layers and `ModelCatalog` picks one
* per vendor `headers`, the full verbatim set, for vendors whose definition
* declares `hostHeaders: 'full'`; `thirdPartyHeaders`, at most the
* `User-Agent`, for everyone else (so device identity never leaks to
* third-party endpoints). Any custom-identity rewriting happens on the app
* side before the layers reach this port; kosong applies them as given.
*
* `identitySlug` is provenance metadata only the configured custom
* identity's token, surfaced by `inspect` to label where the third-party
* `User-Agent`'s product token came from. No resolution logic reads it.
*/
import { createDecorator } from '#/_base/di/instantiation';
export interface IHostRequestHeaders {
readonly headers: Readonly<Record<string, string>>;
readonly thirdPartyHeaders: Readonly<Record<string, string>>;
readonly identitySlug?: string;
}
export const IHostRequestHeaders = createDecorator<IHostRequestHeaders>('hostRequestHeaders');

View file

@ -95,6 +95,8 @@ export const TRACE = {
detectedCapability: 'detectedCapability',
capabilitySource: 'capabilitySource',
hostHeaders: 'hostHeaders',
thirdPartyHeaders: 'thirdPartyHeaders',
identitySlug: 'identitySlug',
} as const;
export class ResolutionTraceCollector implements ResolutionTrace {
@ -468,6 +470,17 @@ function attributeCapabilities(
);
}
function hostHeaderDetail(
forwardsAll: boolean,
key: string,
identitySlug: string | undefined,
): string {
if (forwardsAll) return "host request headers (hostHeaders: 'full')";
return identitySlug !== undefined && key === 'User-Agent'
? `host User-Agent, product token from [identity] (${identitySlug})`
: 'host User-Agent';
}
function attributeHeaders(
sources: Map<string, InspectionSource>,
model: ResolvedModelLike,
@ -476,14 +489,13 @@ function attributeHeaders(
): void {
const envLayer = parseKimiCodeCustomHeaders();
const rawHost = trace.captured<Readonly<Record<string, string>>>(TRACE.hostHeaders) ?? {};
const identitySlug = trace.captured<string | undefined>(TRACE.identitySlug);
const forwardsAll =
providerConfig?.type !== undefined &&
getProviderDefinition(providerConfig.type)?.hostHeaders === 'full';
const hostLayer: Readonly<Record<string, string>> = forwardsAll
? rawHost
: rawHost['User-Agent'] === undefined
? {}
: { 'User-Agent': rawHost['User-Agent'] };
: trace.captured<Readonly<Record<string, string>>>(TRACE.thirdPartyHeaders) ?? {};
const customLayer = providerConfig?.customHeaders ?? {};
for (const key of Object.keys(model.headers)) {
const path = `resolved.headers.${key}`;
@ -492,7 +504,7 @@ function attributeHeaders(
} else if (key in hostLayer) {
sources.set(path, {
kind: 'builtin',
detail: forwardsAll ? "host request headers (hostHeaders: 'full')" : 'host User-Agent',
detail: hostHeaderDetail(forwardsAll, key, identitySlug),
});
} else if (key in envLayer) {
sources.set(path, { kind: 'env', detail: 'KIMI_CODE_CUSTOM_HEADERS' });

View file

@ -7,6 +7,11 @@
* provider when tokens are present, flips failing servers into `needs-auth`
* on 401, and reconnects after authentication. Applies per-server settings
* over the configured defaults and emits status changes to subscribers.
*
* `resolveClientName` supplies the name announced to servers during initialize
* (and the OAuth dynamic-registration label), consulted per connection so an
* identity configured after construction still applies; omitted, or resolving
* to `undefined`, keeps the built-in name.
*/
import { ErrorCodes, Error2 } from '#/errors';
@ -97,6 +102,7 @@ export interface McpConnectionManagerOptions {
readonly oauthService?: McpOAuthService;
readonly log?: Logger;
readonly resolveDefaultTimeouts?: () => McpDefaultTimeouts;
readonly resolveClientName?: () => string | undefined;
}
export class McpConnectionManager implements McpConnectionView {
@ -371,11 +377,13 @@ export class McpConnectionManager implements McpConnectionView {
): Promise<RuntimeMcpClient> {
const toolCallTimeoutMs =
config.toolTimeoutMs ?? this.options.resolveDefaultTimeouts?.().toolTimeoutMs;
const clientName = this.options.resolveClientName?.();
if (config.transport === 'stdio') {
return new StdioMcpClient(config, {
startupTimeoutMs,
toolCallTimeoutMs,
defaultCwd: this.options.stdioCwd,
clientName,
});
}
if (config.transport === 'sse') {
@ -384,6 +392,7 @@ export class McpConnectionManager implements McpConnectionView {
toolCallTimeoutMs,
envLookup: this.options.envLookup,
oauthProvider: await this.resolveOAuthProvider(config, name),
clientName,
});
}
return new HttpMcpClient(config, {
@ -391,6 +400,7 @@ export class McpConnectionManager implements McpConnectionView {
toolCallTimeoutMs,
envLookup: this.options.envLookup,
oauthProvider: await this.resolveOAuthProvider(config, name),
clientName,
});
}

View file

@ -27,14 +27,14 @@ const SUCCESS_HTML =
'<!doctype html><html><head><meta charset="utf-8"><title>Authorized</title></head>' +
'<body style="font-family:system-ui,sans-serif;padding:2rem;">' +
'<h1>Sign-in complete</h1>' +
'<p>You can close this tab and return to kimi-code.</p>' +
'<p>You can close this tab and return to the application.</p>' +
'</body></html>';
const ERROR_HTML =
'<!doctype html><html><head><meta charset="utf-8"><title>OAuth error</title></head>' +
'<body style="font-family:system-ui,sans-serif;padding:2rem;">' +
'<h1>Sign-in failed</h1>' +
'<p>The authorization server reported an error. Return to kimi-code for details.</p>' +
'<p>The authorization server reported an error. Return to the application for details.</p>' +
'</body></html>';
export async function startCallbackServer(): Promise<CallbackServer> {

View file

@ -13,6 +13,10 @@
* blocking, while the data methods `await ready` before reading or writing.
* The provider does not open browsers or run servers it is the
* persistence + flow-state shim.
*
* `clientName` is the product token for the default label
* (`<clientName> (<serverName>)`), carrying the configured custom identity; it
* is ignored when `clientLabel` states the whole label explicitly.
*/
import { randomBytes } from 'node:crypto';
@ -30,6 +34,7 @@ import type {
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { KIMI_MCP_CLIENT_NAME } from '../client-shared';
import { canonicalMcpOAuthResource, mcpOAuthStoreKey, type McpOAuthStore } from './store';
const TOKENS_SUFFIX = '-tokens.json';
@ -42,6 +47,7 @@ export interface McpOAuthProviderOptions {
readonly serverUrl: string | URL;
readonly store: McpOAuthStore;
readonly clientLabel?: string;
readonly clientName?: string;
}
export class McpOAuthClientProvider implements OAuthClientProvider {
@ -63,7 +69,9 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
this.serverUrl = canonicalMcpOAuthResource(options.serverUrl);
this.storeKey = mcpOAuthStoreKey(options.serverName, this.serverUrl);
this.store = options.store;
this.clientLabel = options.clientLabel ?? `kimi-code (${options.serverName})`;
this.clientLabel =
options.clientLabel ??
`${options.clientName ?? KIMI_MCP_CLIENT_NAME} (${options.serverName})`;
this.ready = this.load();
}

View file

@ -20,6 +20,10 @@
* 3. After `complete()` resolves successfully the provider has tokens on
* disk; the caller (the synthetic tool) drives a manager-level
* `reconnect` to swap the synthetic tool out for the real MCP tools.
*
* `resolveClientName` supplies the product token for provider default labels,
* consulted per provider so an identity configured after this service is
* constructed still applies.
*/
import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
@ -33,6 +37,7 @@ import { mcpOAuthStoreKey, type McpOAuthStore } from './store';
export interface McpOAuthServiceOptions {
readonly store: McpOAuthStore;
readonly clientLabel?: string;
readonly resolveClientName?: () => string | undefined;
}
export interface BeginAuthorizationOptions {
@ -48,11 +53,13 @@ export interface BeginAuthorizationResult {
export class McpOAuthService {
private readonly store: McpOAuthStore;
private readonly clientLabel: string | undefined;
private readonly resolveClientName: (() => string | undefined) | undefined;
private readonly providers = new Map<string, McpOAuthClientProvider>();
constructor(options: McpOAuthServiceOptions) {
this.store = options.store;
this.clientLabel = options.clientLabel;
this.resolveClientName = options.resolveClientName;
}
getProvider(serverName: string, serverUrl: string | URL): McpOAuthClientProvider {
@ -64,6 +71,7 @@ export class McpOAuthService {
serverUrl,
store: this.store,
clientLabel: this.clientLabel,
clientName: this.resolveClientName?.(),
});
this.providers.set(provider.storeKey, provider);
}
@ -86,6 +94,7 @@ export class McpOAuthService {
serverUrl,
store: this.store,
clientLabel: options.clientLabel,
clientName: this.resolveClientName?.(),
});
if (options.clientLabel !== undefined) {
this.providers.set(provider.storeKey, provider);

View file

@ -99,7 +99,7 @@ const DEFAULT_SUMMARY_POLICY = {
registerAgentProfile({
name: 'agent',
description: 'Default Kimi Code agent',
description: 'Default agent',
tools: AGENT_TOOLS,
renderSystemPrompt: (context) =>
renderSystemPromptResult('', context, { skillActive: skillActiveFor(AGENT_TOOLS) }),

View file

@ -17,6 +17,14 @@
* whose cwd is the handler root) lives as long as the handler i.e. the
* process so a stateful stdio server is shared by concurrent sessions of
* the workspace rather than owned by one session. Bound at Workspace scope.
*
* The client name announced to MCP servers on initialize and on OAuth
* dynamic registration is the identity snapshot's slug. Every manager it
* builds, the shared one and each session overlay, gates its connects on
* `identity.resolved()`, so the callback handed to the managers always reads
* the frozen snapshot: a connection (and the OAuth provider a remote server
* materializes, cached on the shared service) can never carry a pre-config
* name.
*/
import { Disposable } from '#/_base/di/lifecycle';
@ -26,6 +34,7 @@ import { ILogService } from '#/_base/log/log';
import { McpConnectionManager } from '#/mcpCore/connection-manager';
import type { McpServerConfig } from '#/mcpCore/config-schema';
import { McpOAuthService } from '#/mcpCore/oauth/service';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { MergedMcpConnectionView } from '#/session/mcp/mergedConnectionView';
@ -50,6 +59,7 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ
private readonly stdioCwd: string;
readonly ready: Promise<void>;
private mutationTail: Promise<void> = Promise.resolve();
private readonly resolveClientName = (): string | undefined => this.identity.current().slug;
constructor(
@IWorkspaceContext workspace: IWorkspaceContext,
@ -57,15 +67,20 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ
@IMcpOAuthStore oauthStore: IMcpOAuthStore,
@ILogService private readonly log: ILogService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentIdentity private readonly identity: IAgentIdentity,
) {
super();
this.stdioCwd = workspace.cwd;
this.oauthService = new McpOAuthService({ store: oauthStore });
this.oauthService = new McpOAuthService({
store: oauthStore,
resolveClientName: this.resolveClientName,
});
this.manager = new McpConnectionManager({
log: this.log,
oauthService: this.oauthService,
stdioCwd: this.stdioCwd,
resolveDefaultTimeouts: () => this.mcpConfig.tunables(),
resolveClientName: this.resolveClientName,
});
this._register({ dispose: () => void this.manager.shutdown() });
this._register(
@ -99,10 +114,13 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ
oauthService: this.oauthService,
stdioCwd: opts?.stdioCwd ?? this.stdioCwd,
resolveDefaultTimeouts: () => this.mcpConfig.tunables(),
resolveClientName: this.resolveClientName,
});
const connect = sessionManager.connectAll({ ...servers }).catch((error: unknown) => {
this.log.error('session mcp overlay initial load failed', { error });
});
const connect = Promise.all([this.mcpConfig.ready, this.identity.resolved()])
.then(() => sessionManager.connectAll({ ...servers }))
.catch((error: unknown) => {
this.log.error('session mcp overlay initial load failed', { error });
});
const view = new MergedMcpConnectionView(
this.manager,
sessionManager,
@ -126,6 +144,7 @@ export class WorkspaceMcpService extends Disposable implements IWorkspaceMcpServ
private async initialize(): Promise<void> {
await this.mcpConfig.ready;
await this.identity.resolved();
const servers = this.mcpConfig.servers();
if (Object.keys(servers).length === 0) return;
await this.manager.connectAll(servers);

File diff suppressed because one or more lines are too long

View file

@ -11,8 +11,16 @@ import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCat
import { IPluginService } from '#/app/plugin/plugin';
import type { EnabledPluginSystemPrompt } from '#/app/plugin/types';
import { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
import type { SkillCatalog } from '#/app/skillCatalog/types';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource';
import {
BUILTIN_SKILL_SOURCE_ID,
PLUGIN_SKILL_SOURCE_ID,
} from '#/app/skillCatalog/skillSource';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { DEFAULT_PRODUCT_NAME } from '#/app/agentProfileCatalog/profile-shared';
import { stubAgentIdentity } from '../../app/agentIdentity/stubs';
import {
appService,
@ -39,6 +47,12 @@ const pluginProfile: ResolvedAgentProfile = normalizeAgentProfile({
tools: [],
});
const skillsProfile: ResolvedAgentProfile = normalizeAgentProfile({
name: 'skills-profile',
systemPrompt: (context) => `skills:${context.skills ?? ''}`,
tools: ['Skill'],
});
const exactProfile: ResolvedAgentProfile = normalizeAgentProfile({
name: 'exact-profile',
systemPrompt: (context) =>
@ -82,6 +96,35 @@ describe('AgentProfileService.applyProfile', () => {
return { ctx, profile: ctx.get(IAgentProfileService) };
}
describe('custom identity', () => {
// The default builtin profile opens with `You are ${product_name}`.
const selfNaming: ResolvedAgentProfile = normalizeAgentProfile({
name: 'self-naming',
systemPrompt: (context) => `You are ${context.productName ?? DEFAULT_PRODUCT_NAME}`,
tools: [],
});
it('names the agent after the configured identity', async () => {
const { profile: svc } = buildContext(
appService(IAgentIdentity, stubAgentIdentity({ displayName: 'Acme Dev', slug: 'acme' })),
);
await svc.applyProfile(selfNaming);
expect(svc.data().systemPrompt).toBe('You are Acme Dev');
});
it('keeps the built-in product name when no identity is configured', async () => {
const { profile: svc } = buildContext(
appService(IAgentIdentity, stubAgentIdentity()),
);
await svc.applyProfile(selfNaming);
expect(svc.data().systemPrompt).toBe(`You are ${DEFAULT_PRODUCT_NAME}`);
});
});
it('loads AGENTS.md into the rendered system prompt', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8');
const { profile: svc } = buildContext();
@ -181,6 +224,29 @@ describe('AgentProfileService.applyProfile', () => {
change.dispose();
});
// The builtin source changes only when its config switch is toggled, so it
// shares the plugin source's refresh. Subscribing to the catalog rather than
// the config section is what makes the rebuilt prompt see the new listing:
// the catalog fires after the contribution is replaced.
it('refreshes the system prompt when the builtin skill source reloads', async () => {
const change = new Emitter<string>();
const listing = { value: 'before' };
const catalog = {
getModelSkillListing: () => listing.value,
} as unknown as SkillCatalog;
const { profile: svc } = buildContext(skillCatalogWithChange(change, catalog));
await svc.applyProfile(skillsProfile);
expect(svc.data().systemPrompt).toBe('skills:before');
listing.value = 'after';
change.fire(BUILTIN_SKILL_SOURCE_ID);
await vi.waitFor(() => {
expect(svc.data().systemPrompt).toBe('skills:after');
});
change.dispose();
});
it('skips plugin sections beyond the aggregate byte budget and warns once', async () => {
const large = 'x'.repeat(48 * 1024);
const sections = {
@ -219,10 +285,13 @@ describe('AgentProfileService.applyProfile', () => {
});
});
function skillCatalogWithChange(change: Emitter<string>): TestAgentServiceOverride {
function skillCatalogWithChange(
change: Emitter<string>,
catalog: SkillCatalog = new InMemorySkillCatalog(),
): TestAgentServiceOverride {
return sessionService(ISessionSkillCatalog, {
_serviceBrand: undefined,
catalog: new InMemorySkillCatalog(),
catalog,
ready: Promise.resolve(),
onDidChange: change.event,
load: async () => {},

View file

@ -27,6 +27,9 @@ import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionT
import { IWireService } from '#/wire/wire';
import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/tool/toolContract';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { deferredAgentIdentityStub } from '../../app/agentIdentity/stubs';
import {
InMemoryWireRecordPersistence,
agentService,
@ -114,6 +117,25 @@ describe('AgentProfileService.bind', () => {
expect(svc.getSystemPrompt()).toContain('Kimi Code CLI');
});
// A fast bootstrap can bind while config is still loading; the model
// materialization inside bind must wait for the identity freeze instead of
// tripping its pre-freeze guard through the host-headers port.
it('waits for the identity freeze instead of racing it', async () => {
const deferred = deferredAgentIdentityStub();
ctx = createTestAgent(
appService(IAgentIdentity, deferred.identity),
hostEnvironmentServices(homeDir),
);
const svc = ctx.get(IAgentProfileService);
const bound = svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL });
setTimeout(() => deferred.freeze(), 20);
await bound;
expect(svc.data().modelAlias).toBe(MOCK_MODEL);
expect(svc.isRunnable()).toBe(true);
});
it('renders the prompt and disclosure from the injected host clock', async () => {
const hostClock: IHostClock = {
_serviceBrand: undefined,

View file

@ -45,7 +45,7 @@ describe('activateSkill RPC', () => {
// JSON.stringify escapes the block's attribute quotes — assert on the
// quote-free fragments.
const llmInput = JSON.stringify(ctx.llmInputs());
expect(llmInput).toContain('kimi-skill-loaded');
expect(llmInput).toContain('skill-loaded');
expect(llmInput).toContain('# Commit body');
expect(llmInput).toContain('ARGUMENTS: -m fix');
});

View file

@ -221,7 +221,7 @@ describe('SkillTool', () => {
expect(tool.name).toBe('Skill');
expect(tool.description).toContain('Invoke a registered skill');
expect(tool.description).toContain('kimi-skill-loaded');
expect(tool.description).toContain('skill-loaded');
expect(tool.description).toContain('with the same `args`');
expect(tool.parameters).toMatchObject({
type: 'object',
@ -303,7 +303,7 @@ describe('SkillTool', () => {
expect(result.delivery?.message.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining(
'<kimi-skill-loaded name="commit" trigger="model-tool" source="user" dir="/skills/commit" args="src/app.ts">',
'<skill-loaded name="commit" trigger="model-tool" source="user" dir="/skills/commit" args="src/app.ts">',
),
});
expect(result.delivery?.message.content[0]).toMatchObject({

View file

@ -0,0 +1,286 @@
/**
* Scenario: custom agent identity resolution.
*
* Asserts the snapshot the identity service freezes once config first loads
* the filling `displayName` (config > host-declared > unset), the rewriting
* `slug` (claimed only when the user declares one), and the finished
* User-Agent products for the three outbound shapes plus the freeze itself:
* a `[identity]` edit after the freeze changes nothing until the next start,
* and a synchronous read before the freeze fails loudly instead of serving a
* pre-config value. Slug normalization guarantees a non-empty ASCII token for
* any input, including the blank and CJK-only cases that would otherwise
* reach the User-Agent builder.
*
* Runs the real `AgentIdentityService` over a stub config service and a stub
* bootstrap; nothing else is wired. Run with
* `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
* test/app/agentIdentity/agentIdentity.test.ts`.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { createScopedTestHost } from '#/_base/di/test';
import {
buildAgentIdentitySnapshot,
DEFAULT_IDENTITY_SLUG,
IAgentIdentity,
normalizeIdentitySlug,
type AgentIdentitySnapshot,
} from '#/app/agentIdentity/agentIdentity';
import { AgentIdentityService } from '#/app/agentIdentity/agentIdentityService';
import { IDENTITY_SECTION } from '#/app/agentIdentity/configSection';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { stubBootstrap } from '../bootstrap/stubs';
import { StubConfigService } from '../../kosong/stubs';
const hosts: Array<{ dispose(): void }> = [];
afterEach(() => {
while (hosts.length > 0) hosts.pop()?.dispose();
});
function createIdentity(
section: Record<string, unknown> | undefined,
options: {
hostDisplayName?: string;
hostRequestHeaders?: Record<string, string>;
} = {},
): { identity: IAgentIdentity; config: StubConfigService } {
registerScopedService(LifecycleScope.App, IAgentIdentity, AgentIdentityService);
const config = new StubConfigService(
section === undefined ? {} : { [IDENTITY_SECTION]: section },
);
const host = createScopedTestHost([
[IConfigService, config],
[
IBootstrapService,
stubBootstrap('/home', {}, {
displayName: options.hostDisplayName,
requestHeaders: options.hostRequestHeaders ?? {},
}),
],
]);
hosts.push(host);
return { identity: host.app.accessor.get(IAgentIdentity), config };
}
async function resolve(
section: Record<string, unknown> | undefined,
hostDisplayName?: string,
): Promise<AgentIdentitySnapshot> {
return createIdentity(section, { hostDisplayName }).identity.resolved();
}
describe('normalizeIdentitySlug', () => {
it('folds an ordinary name into a hyphenated token', () => {
expect(normalizeIdentitySlug('Acme Dev Agent')).toBe('acme-dev-agent');
});
it.each([
['Acme 开发助手', 'acme'],
['ACME__Dev', 'acme-dev'],
[' spaced out ', 'spaced-out'],
['--leading-and-trailing--', 'leading-and-trailing'],
])('normalizes %j to %j', (input, expected) => {
expect(normalizeIdentitySlug(input)).toBe(expected);
});
// The User-Agent builder throws on a blank or non-ASCII product token, so a
// name that folds away entirely must never reach it as an empty string.
it.each(['开发助手', '!!!', ' ', '', '「」', '🎉'])(
'falls back to the default slug for %j',
(input) => {
expect(normalizeIdentitySlug(input)).toBe(DEFAULT_IDENTITY_SLUG);
},
);
it('always yields a non-empty ASCII token', () => {
for (const input of ['Acme', '开发', '~~~', '', 'a1', 'Ω']) {
const slug = normalizeIdentitySlug(input);
expect(slug.length).toBeGreaterThan(0);
// eslint-disable-next-line no-control-regex
expect(/^[ -~]+$/.test(slug)).toBe(true);
}
});
});
describe('AgentIdentityService', () => {
it('claims nothing when the section is unset', async () => {
const identity = await resolve(undefined);
expect(identity.slug).toBeUndefined();
expect(identity.displayName).toBeUndefined();
});
it('falls back to the host-declared display name and claims no slug', async () => {
const identity = await resolve(undefined, 'Embedding Host');
expect(identity.displayName).toBe('Embedding Host');
// A host default is not a custom identity — protocol fields stay untouched.
expect(identity.slug).toBeUndefined();
});
it('lets the config name override the host-declared display name', async () => {
const identity = await resolve({ name: 'Acme Dev' }, 'Embedding Host');
expect(identity.displayName).toBe('Acme Dev');
expect(identity.slug).toBe('acme-dev');
});
it('derives the slug from the name when only a name is configured', async () => {
const identity = await resolve({ name: 'Acme Dev Agent' });
expect(identity.slug).toBe('acme-dev-agent');
});
it('prefers an explicit slug over the derived one', async () => {
const identity = await resolve({ name: 'Acme Dev Agent', slug: 'acme' });
expect(identity.displayName).toBe('Acme Dev Agent');
expect(identity.slug).toBe('acme');
});
it('normalizes a user-written slug', async () => {
expect((await resolve({ slug: 'Acme Dev!' })).slug).toBe('acme-dev');
});
it('applies a slug-only config partially, leaving the display name to fall through', async () => {
const identity = await resolve({ slug: 'acme' }, 'Embedding Host');
expect(identity.slug).toBe('acme');
expect(identity.displayName).toBe('Embedding Host');
});
// A stray blank in config.toml must read as unset, exactly as a blank env
// var does — otherwise it claims an identity and rewrites the User-Agent.
it.each([{ name: '' }, { name: ' ' }, { slug: '' }, { name: '', slug: ' ' }])(
'treats blank config values as unset: %j',
async (section) => {
const identity = await resolve(section, 'Embedding Host');
expect(identity.slug).toBeUndefined();
expect(identity.displayName).toBe('Embedding Host');
},
);
// The host half of the same rule: a padded or blank `displayName` from an
// embedding host must read as unset too, or the prompt renders "You are ,".
it.each(['', ' '])('treats a blank host display name as unset: %j', async (hostName) => {
expect((await resolve(undefined, hostName)).displayName).toBeUndefined();
});
it('trims a padded host display name', async () => {
expect((await resolve(undefined, ' Embedding Host ')).displayName).toBe('Embedding Host');
});
it('trims a padded name and slug', async () => {
const identity = await resolve({ name: ' Acme Dev ' });
expect(identity.displayName).toBe('Acme Dev');
expect(identity.slug).toBe('acme-dev');
});
it('keeps a CJK-only name usable by falling the slug back to the default', async () => {
const identity = await resolve({ name: '开发助手' });
expect(identity.displayName).toBe('开发助手');
expect(identity.slug).toBe(DEFAULT_IDENTITY_SLUG);
});
});
describe('AgentIdentityService freeze', () => {
// The identity is announced outward (MCP initialize, OAuth registration,
// provider logs) and cannot be re-announced, so the snapshot holds for the
// life of the process: a `[identity]` edit after the freeze changes nothing.
it('ignores a config edit made after the freeze', async () => {
const { identity, config } = createIdentity(
{ name: 'Acme' },
{ hostRequestHeaders: { 'User-Agent': 'kimi-code-cli/1.0' } },
);
const before = await identity.resolved();
expect(before.displayName).toBe('Acme');
expect(before.thirdPartyUserAgent).toBe('acme/1.0');
await config.set(IDENTITY_SECTION, { name: 'Rebrand', slug: 'rebrand' });
const after = await identity.resolved();
expect(after).toBe(before);
expect(identity.current().displayName).toBe('Acme');
expect(identity.current().thirdPartyUserAgent).toBe('acme/1.0');
});
it('throws on a synchronous read before the freeze', () => {
const { identity } = createIdentity({ name: 'Acme' });
// The service arms the freeze on config readiness, which cannot have
// delivered yet within the same synchronous frame.
expect(() => identity.current()).toThrow(/before config load/);
});
it('serves the synchronous read once resolved', async () => {
const { identity } = createIdentity({ name: 'Acme' });
await identity.resolved();
expect(identity.current().displayName).toBe('Acme');
});
});
describe('buildAgentIdentitySnapshot products', () => {
const HOST = { 'User-Agent': 'kimi-code-cli/1.2.3 (darwin)', 'X-Msh-Device-Id': 'device-1' };
it('rewrites only the product token across every product when a slug is claimed', () => {
const snapshot = buildAgentIdentitySnapshot({ slug: 'acme', hostRequestHeaders: HOST });
expect(snapshot.thirdPartyUserAgent).toBe('acme/1.2.3 (darwin)');
expect(snapshot.outboundUserAgent).toBe('acme/1.2.3 (darwin)');
expect(snapshot.requestHeaders).toEqual({
'User-Agent': 'acme/1.2.3 (darwin)',
'X-Msh-Device-Id': 'device-1',
});
});
it('passes the host products through untouched when no identity is claimed', () => {
const snapshot = buildAgentIdentitySnapshot({ hostRequestHeaders: HOST });
expect(snapshot.thirdPartyUserAgent).toBe(HOST['User-Agent']);
expect(snapshot.outboundUserAgent).toBe(HOST['User-Agent']);
expect(snapshot.requestHeaders).toEqual(HOST);
});
// The four (host User-Agent × slug) combinations of the always-defined
// product: directories this process chooses to call always get a header.
it.each([
[HOST, 'acme', 'acme/1.2.3 (darwin)'],
[HOST, undefined, HOST['User-Agent']],
[{}, 'acme', 'acme'],
[{}, undefined, DEFAULT_IDENTITY_SLUG],
])('outboundUserAgent for host %j and slug %j is %j', (headers, slug, expected) => {
expect(
buildAgentIdentitySnapshot({ slug, hostRequestHeaders: headers }).outboundUserAgent,
).toBe(expected);
});
// The rewriting product respects a host that deliberately sends nothing.
it('yields no third-party User-Agent when the host sends none', () => {
const snapshot = buildAgentIdentitySnapshot({ slug: 'acme', hostRequestHeaders: {} });
expect(snapshot.thirdPartyUserAgent).toBeUndefined();
expect(snapshot.requestHeaders).toEqual({});
});
// HTTP header names are case-insensitive; a host that spells the header
// `user-agent` (e.g. a WHATWG Headers object flattened with
// Object.fromEntries) must get the same rewrite, under its own spelling.
it.each(['user-agent', 'USER-AGENT'])(
'locates the %j spelling and rewrites it in place',
(key) => {
const snapshot = buildAgentIdentitySnapshot({
slug: 'acme',
hostRequestHeaders: { [key]: 'kimi-code-cli/1.2.3', 'X-Msh-Device-Id': 'device-1' },
});
expect(snapshot.thirdPartyUserAgent).toBe('acme/1.2.3');
expect(snapshot.outboundUserAgent).toBe('acme/1.2.3');
expect(snapshot.requestHeaders).toEqual({
[key]: 'acme/1.2.3',
'X-Msh-Device-Id': 'device-1',
});
},
);
it('passes a lowercase spelling through untouched when no identity is claimed', () => {
const snapshot = buildAgentIdentitySnapshot({
hostRequestHeaders: { 'user-agent': 'kimi-code-cli/1.2.3' },
});
expect(snapshot.thirdPartyUserAgent).toBe('kimi-code-cli/1.2.3');
expect(snapshot.requestHeaders).toEqual({ 'user-agent': 'kimi-code-cli/1.2.3' });
});
});

View file

@ -0,0 +1,76 @@
/**
* Shared `IAgentIdentity` stub.
*
* The identity cuts across the system prompt, outbound headers, MCP client
* naming, and the builtin skill catalog, so plenty of suites need it present
* without caring what it says. The default states "no custom identity", which
* is the shape every pre-existing test expects: consumers must behave exactly
* as they did before the feature existed. Unlike the real resolution, the
* stub's `displayName` and `slug` are independent naming a display name
* does not derive a slug, so a suite can exercise one face in isolation.
*/
import type { ServiceRegistration } from '#/_base/di/test';
import {
buildAgentIdentitySnapshot,
IAgentIdentity,
type AgentIdentitySnapshot,
} from '#/app/agentIdentity/agentIdentity';
export interface AgentIdentityStubOverrides {
readonly displayName?: string;
readonly slug?: string;
readonly hostRequestHeaders?: Readonly<Record<string, string>>;
}
export function stubAgentIdentity(overrides: AgentIdentityStubOverrides = {}): IAgentIdentity {
const products = buildAgentIdentitySnapshot({
slug: overrides.slug,
hostRequestHeaders: overrides.hostRequestHeaders ?? {},
});
const snapshot: AgentIdentitySnapshot = {
...products,
displayName: overrides.displayName,
};
return {
_serviceBrand: undefined,
resolved: () => Promise.resolve(snapshot),
current: () => snapshot,
};
}
export function registerAgentIdentityStub(
reg: ServiceRegistration,
overrides?: AgentIdentityStubOverrides,
): void {
reg.defineInstance(IAgentIdentity, stubAgentIdentity(overrides));
}
export function deferredAgentIdentityStub(overrides: AgentIdentityStubOverrides = {}): {
identity: IAgentIdentity;
freeze: () => void;
} {
let snapshot: AgentIdentitySnapshot | undefined;
let settle!: (frozen: AgentIdentitySnapshot) => void;
const frozen = new Promise<AgentIdentitySnapshot>((resolve) => {
settle = resolve;
});
return {
identity: {
_serviceBrand: undefined,
resolved: () => frozen,
current: () => {
if (snapshot === undefined) throw new Error('identity read before the test froze it');
return snapshot;
},
},
freeze: () => {
const products = buildAgentIdentitySnapshot({
slug: overrides.slug,
hostRequestHeaders: overrides.hostRequestHeaders ?? {},
});
snapshot = { ...products, displayName: overrides.displayName };
settle(snapshot);
},
};
}

View file

@ -32,6 +32,7 @@ import { IConfigService } from '#/app/config/config';
import { ConfigRegistry } from '#/app/config/configService';
import { type DomainEvent, IEventService } from '#/app/event/event';
import { ILogService } from '#/_base/log/log';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IModelService, type ModelRecord } from '#/kosong/model/model';
import { MODELS_SECTION } from '#/app/kosongConfig/configSection';
@ -43,6 +44,7 @@ import '#/kosong/provider/providers/kimi/kimi.contrib';
import { registerBootstrapServices } from '../bootstrap/stubs';
import { registerTelemetryServices } from '../telemetry/stubs';
import { stubAgentIdentity } from '../../app/agentIdentity/stubs';
const OAUTH_PROVIDER = 'managed:kimi-code';
const NON_OAUTH_PROVIDER = 'openai-main';
@ -835,13 +837,16 @@ describe('WebSearchProviderService', () => {
resolveTokenProvider:
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
});
const hostHeaders = {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
};
reg.defineInstance(
IAgentIdentity,
stubAgentIdentity({ hostRequestHeaders: hostHeaders }),
);
reg.definePartialInstance(IBootstrapService, {
args: {
requestHeaders: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
},
},
args: { requestHeaders: hostHeaders },
});
reg.definePartialInstance(IConfigService, {
get: ((domain: string) =>
@ -1025,6 +1030,51 @@ describe('WebSearchProviderService', () => {
expect(createService().getWebSearchProvider()).toBeUndefined();
expect(resolveTokenProvider).not.toHaveBeenCalled();
});
// Tool activation gates on presence alone. An env-configured endpoint is
// visible before config finishes loading, so a fast bootstrap can evaluate
// the gate before the identity snapshot froze — presence must not read it.
it('answers presence without touching a not-yet-frozen identity', () => {
const notFrozen: IAgentIdentity = {
_serviceBrand: undefined,
resolved: () => new Promise(() => undefined),
current: () => {
throw new Error('identity read before freeze');
},
};
servicesConfig = {
moonshotSearch: { baseUrl: 'https://search.example.com/search', apiKey: 'k' },
};
const svc = new WebSearchProviderService(
{ get: ((name: string) => providers[name]) as IProviderService['get'] } as IProviderService,
{
resolveTokenProvider:
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
} as IOAuthService,
{ args: { requestHeaders: {} } } as unknown as IBootstrapService,
{
get: ((domain: string) =>
domain === SERVICES_SECTION ? servicesConfig : undefined) as IConfigService['get'],
} as IConfigService,
notFrozen,
);
expect(svc.hasWebSearchProvider()).toBe(true);
expect(() => svc.getWebSearchProvider()).toThrow(/before freeze/);
servicesConfig = undefined;
providers = {};
expect(svc.hasWebSearchProvider()).toBe(false);
providers = {
[OAUTH_PROVIDER]: {
type: 'kimi',
baseUrl: 'https://api.example.com/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
};
expect(svc.hasWebSearchProvider()).toBe(true);
});
});
describe('services config section', () => {

View file

@ -31,6 +31,7 @@ import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag';
import '#/app/cron/configSection';
import type { CronConfig } from '#/app/cron/configSection';
import '#/app/skillCatalog/configSection';
import { BUILTIN_PRODUCT_SKILLS_SECTION } from '#/app/skillCatalog/configSection';
import {
EXTRA_SKILL_DIRS_SECTION,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
@ -440,6 +441,64 @@ describe('ConfigService env overlay (live)', () => {
disposables.dispose();
});
// `builtinProductSkills` is a whole-section scalar rather than an object of
// fields, so it exercises the section-level env binding branch and needs its
// own strip — `stripEnvBoundFields` only walks object fields.
it('applies a scalar section env binding and keeps it out of the file', async () => {
const env: Record<string, string> = {};
const disposables = new DisposableStore();
const ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env));
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
ix.set(IConfigService, new SyncDescriptor(ConfigService));
const config = ix.get(IConfigService);
await config.ready;
expect(config.get(BUILTIN_PRODUCT_SKILLS_SECTION)).toBe(true);
env['KIMI_CODE_BUILTIN_PRODUCT_SKILLS'] = '0';
expect(config.get(BUILTIN_PRODUCT_SKILLS_SECTION)).toBe(false);
// A write while the env var is active must persist the file's own value,
// never the env override echoed back.
await config.replace(BUILTIN_PRODUCT_SKILLS_SECTION, true);
delete env['KIMI_CODE_BUILTIN_PRODUCT_SKILLS'];
expect(config.get(BUILTIN_PRODUCT_SKILLS_SECTION)).toBe(true);
disposables.dispose();
});
// Contract: "an env value that fails its binding's parse is ignored". Object
// fields already honored it; a whole-section scalar binding must too, or a
// blank / mistyped variable silently clears the configured value.
it('keeps the file value when a scalar section env value fails to parse', async () => {
const env: Record<string, string> = {};
const disposables = new DisposableStore();
const ix = disposables.add(new TestInstantiationService());
ix.stub(ILogService, stubLog());
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env));
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
ix.set(IConfigService, new SyncDescriptor(ConfigService));
const config = ix.get(IConfigService);
await config.ready;
await config.replace(BUILTIN_PRODUCT_SKILLS_SECTION, false);
for (const invalid of ['', ' ', 'maybe']) {
env['KIMI_CODE_BUILTIN_PRODUCT_SKILLS'] = invalid;
expect(config.get(BUILTIN_PRODUCT_SKILLS_SECTION)).toBe(false);
}
env['KIMI_CODE_BUILTIN_PRODUCT_SKILLS'] = 'on';
expect(config.get(BUILTIN_PRODUCT_SKILLS_SECTION)).toBe(true);
disposables.dispose();
});
it('keeps the Kimi effort force separate from the configured effort', async () => {
const env: Record<string, string> = { KIMI_MODEL_THINKING_EFFORT: 'max' };
const disposables = new DisposableStore();

View file

@ -28,6 +28,7 @@ import { createScopedTestHost } from '#/_base/di/test';
import { isError2 } from '#/_base/errors/errors';
import { ILogService, type LogPayload } from '#/_base/log/log';
import { IOAuthService } from '#/app/auth/auth';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { ConfigRegistry } from '#/app/config/configService';
@ -53,6 +54,7 @@ import '#/kosong/provider/providers/standard.contrib';
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubAgentIdentity } from '../agentIdentity/stubs';
function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } {
const published: Array<{ type: string; payload: unknown }> = [];
@ -105,6 +107,10 @@ async function createHost(
IBootstrapService,
stubBootstrap('/tmp/kimi-home', {}, { requestHeaders: { 'User-Agent': 'kimi-test/1.0' } }),
],
[
IAgentIdentity,
stubAgentIdentity({ hostRequestHeaders: { 'User-Agent': 'kimi-test/1.0' } }),
],
]);
const providers = host.app.accessor.get(IProviderService);
const models = host.app.accessor.get(IModelService);

View file

@ -20,6 +20,8 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createScopedTestHost } from '#/_base/di/test';
import { Error2, isError2 } from '#/_base/errors/errors';
import { DEFAULT_IDENTITY_SLUG, IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import {
resetModelsDevUpstreamForTest,
@ -35,6 +37,10 @@ import type { ModelsSection } from '#/kosong/model/model';
import type { ProvidersSection } from '#/kosong/provider/provider';
import { StubConfigService } from '../../kosong/stubs';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubAgentIdentity } from '../agentIdentity/stubs';
const HOST_HEADERS = { 'User-Agent': 'kimi-test/1.0' };
const codes = ModelsDevImportErrors.codes;
@ -109,6 +115,16 @@ function fetchJson(doc: unknown): typeof fetch {
})) as unknown as typeof fetch;
}
function fetchJsonRecordingUserAgent(doc: unknown, seen: Array<string | null>): typeof fetch {
return (async (_input: unknown, init?: { headers?: Record<string, string> }) => {
seen.push(new Headers(init?.headers).get('User-Agent'));
return new Response(JSON.stringify(doc), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
}
function fetchFail(): typeof fetch {
return (async () => {
throw new Error('network down');
@ -132,7 +148,11 @@ function stubModelCatalog(): IModelCatalog {
} as unknown as IModelCatalog;
}
function createHost(sections: Record<string, unknown> = {}): {
function createHost(
sections: Record<string, unknown> = {},
identitySlug?: string,
hostHeaders: Record<string, string> = HOST_HEADERS,
): {
config: StubConfigService;
imports: IModelsDevImportService;
} {
@ -141,6 +161,8 @@ function createHost(sections: Record<string, unknown> = {}): {
[IConfigService, config],
[IKosongConfigService, stubKosongConfig()],
[IModelCatalog, stubModelCatalog()],
[IBootstrapService, stubBootstrap('/home', {}, { requestHeaders: hostHeaders })],
[IAgentIdentity, stubAgentIdentity({ slug: identitySlug, hostRequestHeaders: hostHeaders })],
]);
return { config, imports: host.app.accessor.get(IModelsDevImportService) };
}
@ -287,6 +309,64 @@ describe('IModelsDevImportService', () => {
expect(err.message).toContain('requires a base_url');
});
// A custom registry is a user-supplied third-party endpoint, so this request
// carries the same identity the scheduled refresh of that registry sends —
// it used to go out with a hardcoded product token instead.
it('sends the configured identity as the custom-registry import User-Agent', async () => {
const seen: Array<string | null> = [];
setModelsDevUpstreamForTest({ fetchImpl: fetchJsonRecordingUserAgent(REGISTRY_DOC, seen) });
const { imports } = createHost({}, 'acme');
await imports.importCustomRegistry({ url: REGISTRY_URL });
expect(seen).toEqual(['acme/1.0']);
});
it('keeps the host User-Agent on the import when no identity is configured', async () => {
const seen: Array<string | null> = [];
setModelsDevUpstreamForTest({ fetchImpl: fetchJsonRecordingUserAgent(REGISTRY_DOC, seen) });
const { imports } = createHost();
await imports.importCustomRegistry({ url: REGISTRY_URL });
expect(seen).toEqual([HOST_HEADERS['User-Agent']]);
});
it('sends the configured identity when browsing the models.dev directory', async () => {
const seen: Array<string | null> = [];
setModelsDevUpstreamForTest({ fetchImpl: fetchJsonRecordingUserAgent(CATALOG, seen) });
const { imports } = createHost({}, 'acme');
await imports.listModelsDevProviders();
expect(seen).toEqual(['acme/1.0']);
});
// The fourth combination of (host header, configured slug): a host that
// states no User-Agent must still present the configured identity, not the
// neutral stand-in — this is exactly the case the fallback exists to serve.
it('presents the configured slug when the host states no User-Agent', async () => {
const seen: Array<string | null> = [];
setModelsDevUpstreamForTest({ fetchImpl: fetchJsonRecordingUserAgent(REGISTRY_DOC, seen) });
const { imports } = createHost({}, 'acme', {});
await imports.importCustomRegistry({ url: REGISTRY_URL });
expect(seen).toEqual(['acme']);
});
// These directories are ones this service chooses to call, so a host that
// states no User-Agent gets a neutral token rather than no header at all.
it('falls back to a neutral token when the host states no User-Agent', async () => {
const seen: Array<string | null> = [];
setModelsDevUpstreamForTest({ fetchImpl: fetchJsonRecordingUserAgent(REGISTRY_DOC, seen) });
const { imports } = createHost({}, undefined, {});
await imports.importCustomRegistry({ url: REGISTRY_URL });
expect(seen).toEqual([DEFAULT_IDENTITY_SLUG]);
});
it('imports a custom registry with a source blob and drops providers vanished upstream', async () => {
setModelsDevUpstreamForTest({ fetchImpl: fetchJson(REGISTRY_DOC) });
const { config, imports } = createHost({

View file

@ -0,0 +1,134 @@
/**
* Scenario: the builtin skill source's product-documentation switch.
*
* Asserts that `builtinProductSkills` gates only the skills marked
* `productSpecific` and that an unset section behaves like the shipped
* default, so the filtering happens while the catalog is assembled rather than
* after the names already reached the system prompt.
*
* Runs the real `BuiltinSkillSource` over a stub config service. Run with
* `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
* test/app/skillCatalog/builtinSkillSource.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { TestInstantiationService } from '#/_base/di/test';
import { IConfigService } from '#/app/config/config';
import { BUILTIN_SKILLS, visibleBuiltinSkills } from '#/app/skillCatalog/builtin/builtin';
import { BuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource';
import { BUILTIN_PRODUCT_SKILLS_SECTION } from '#/app/skillCatalog/configSection';
import { StubConfigService } from '../../kosong/stubs';
// Literal on purpose: deriving these from `productSpecific` would read the same
// field the production filter does, so a skill silently losing its marker would
// move sets and keep every assertion green while staying visible to the model.
const PRODUCT_SKILLS = [
'mcp-config',
'import-from-cc-codex',
'update-config',
'custom-theme',
'check-kimi-code-docs',
];
const NEUTRAL_SKILLS = BUILTIN_SKILLS.map((s) => s.name).filter(
(name) => !PRODUCT_SKILLS.includes(name),
);
async function loadNames(configured?: boolean): Promise<readonly string[]> {
const ix = new TestInstantiationService();
ix.set(
IConfigService,
new StubConfigService(
configured === undefined ? {} : { [BUILTIN_PRODUCT_SKILLS_SECTION]: configured },
),
);
const source = ix.createInstance(BuiltinSkillSource);
return (await source.load()).skills.map((s) => s.name);
}
describe('BuiltinSkillSource product-skill switch', () => {
it('marks exactly the product-documentation skills', () => {
expect(BUILTIN_SKILLS.filter((s) => s.productSpecific === true).map((s) => s.name).toSorted())
.toEqual([...PRODUCT_SKILLS].toSorted());
expect(NEUTRAL_SKILLS.length).toBeGreaterThan(0);
});
it('offers every builtin skill when the section is unset', async () => {
const names = await loadNames();
expect(names).toEqual(BUILTIN_SKILLS.map((s) => s.name));
});
it('offers every builtin skill when explicitly enabled', async () => {
const names = await loadNames(true);
expect(names).toEqual(BUILTIN_SKILLS.map((s) => s.name));
});
it('drops product-documentation skills when explicitly disabled', async () => {
const names = await loadNames(false);
expect(names).toEqual(NEUTRAL_SKILLS);
for (const name of PRODUCT_SKILLS) expect(names).not.toContain(name);
});
// The session-less workspace listings (the SDK's `listWorkspaceSkills`, the
// server's `GET /workspaces/{id}/skills`) compose builtins through the same
// predicate rather than the raw constant, so a surface cannot advertise a
// skill the session catalog will drop.
it('exposes the same filter the session-less listings compose with', () => {
expect(visibleBuiltinSkills(true).map((s) => s.name)).toEqual(
BUILTIN_SKILLS.map((s) => s.name),
);
expect(visibleBuiltinSkills(false).map((s) => s.name)).toEqual(NEUTRAL_SKILLS);
});
// The workspace catalog keeps this contribution for the life of the handler,
// while the session-less listings read the switch on every call — without a
// change event the two views would disagree after a toggle.
it('signals a change when the switch is toggled', async () => {
const config = new StubConfigService({ [BUILTIN_PRODUCT_SKILLS_SECTION]: true });
const ix = new TestInstantiationService();
ix.set(IConfigService, config);
const source = ix.createInstance(BuiltinSkillSource);
let fired = 0;
source.onDidChange?.(() => {
fired += 1;
});
await config.replace(BUILTIN_PRODUCT_SKILLS_SECTION, false);
expect(fired).toBe(1);
expect((await source.load()).skills.map((s) => s.name)).toEqual(NEUTRAL_SKILLS);
await config.replace('unrelatedSection', 'x');
expect(fired).toBe(1);
});
// This is the lowest-priority source, so the workspace catalog loads it
// first — before config has finished loading — and keeps the contribution
// for the life of the handler with no reload path. Reading the switch
// eagerly would strand the startup configuration.
it('waits for config readiness before reading the switch', async () => {
let release = (): void => {};
const ready = new Promise<void>((resolve) => {
release = resolve;
});
let loaded = false;
const config = {
_serviceBrand: undefined,
ready,
get: () => (loaded ? false : undefined),
onDidSectionChange: () => ({ dispose: () => {} }),
} as unknown as IConfigService;
const ix = new TestInstantiationService();
ix.set(IConfigService, config);
const source = ix.createInstance(BuiltinSkillSource);
const loading = source.load();
loaded = true;
release();
const names = (await loading).skills.map((s) => s.name);
expect(names).toEqual(NEUTRAL_SKILLS);
});
});

View file

@ -120,7 +120,7 @@ describe('plugin session-start dynamic injection', () => {
const text = lastReminder(ctx);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).toContain('<plugin-instructions plugin="superpowers">');
expect(text).toContain('AskUserQuestion');
expect(text).toContain('TodoList');
expect(text).toContain('body of skill');
@ -142,7 +142,7 @@ describe('plugin session-start dynamic injection', () => {
const text = lastReminder(ctx);
expect(text).toContain('<plugin_session_start plugin="superpowers" skill="using-superpowers">');
expect(text).toContain('body');
expect(text).not.toContain('<kimi-plugin-instructions plugin="superpowers">');
expect(text).not.toContain('<plugin-instructions plugin="superpowers">');
expect(text).not.toContain('AskUserQuestion');
});

View file

@ -299,9 +299,9 @@ describe('InMemorySkillCatalog prompt rendering', () => {
);
expect(rendered).toBe(
'<kimi-plugin-instructions plugin="superpowers">\n' +
'<plugin-instructions plugin="superpowers">\n' +
'Use AskUserQuestion for clarifying questions.\n' +
'</kimi-plugin-instructions>\n\nBrainstorm body.',
'</plugin-instructions>\n\nBrainstorm body.',
);
});

View file

@ -33,7 +33,7 @@ function recordContainsSkillLoaded(record: unknown, skillName: string): boolean
return (
part.type === 'text' &&
typeof part.text === 'string' &&
part.text.includes(`<kimi-skill-loaded name="${skillName}"`)
part.text.includes(`<skill-loaded name="${skillName}"`)
);
}) ?? false
);
@ -246,9 +246,9 @@ describe('ToolManager SkillTool wire behavior', () => {
text: [
'Skill tool loaded instructions for this request. Follow them.',
'',
'<kimi-skill-loaded name="review" trigger="model-tool" source="user" dir="/skills/review" args="">',
'<skill-loaded name="review" trigger="model-tool" source="user" dir="/skills/review" args="">',
'body of review',
'</kimi-skill-loaded>',
'</skill-loaded>',
].join('\n'),
},
],

View file

@ -130,6 +130,26 @@ describe('FetchURLTool output note', () => {
});
});
describe('FetchURLTool backend resolution', () => {
// Agent creation constructs the tool; the backend must not materialize
// until a call needs it. The service documents that each getUrlFetcher()
// call re-reads config and login state, and a construction-time read would
// race the identity freeze during a fast bootstrap.
it('resolves the fetcher per invocation, never at construction', async () => {
const fetch = vi
.fn<UrlFetcher['fetch']>()
.mockResolvedValue({ content: 'hello', kind: 'passthrough' } satisfies UrlFetchResult);
const getUrlFetcher = vi.fn(() => ({ fetch }));
const tool = new FetchURLTool({ _serviceBrand: undefined, getUrlFetcher });
expect(getUrlFetcher).not.toHaveBeenCalled();
await execute(tool, 'https://example.com', new AbortController().signal);
await execute(tool, 'https://example.com', new AbortController().signal);
expect(getUrlFetcher).toHaveBeenCalledTimes(2);
});
});
describe('LocalFetchURLProvider abort signal', () => {
it('passes the signal through to fetchImpl', async () => {
const controller = new AbortController();

View file

@ -12,6 +12,11 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IOAuthService } from '#/app/auth/auth';
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
import {
buildAgentIdentitySnapshot,
IAgentIdentity,
type AgentIdentitySnapshot,
} from '#/app/agentIdentity/agentIdentity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
@ -21,20 +26,28 @@ import { IWebFetchService } from '#/app/web/web';
import { WebFetchService } from '#/app/web/webService';
import '#/kosong/provider/providers/kimi/kimi.contrib';
import { stubAgentIdentity } from '../agentIdentity/stubs';
const OAUTH_PROVIDER = 'managed:kimi-code';
const NON_OAUTH_PROVIDER = 'openai-main';
const HOST_HEADERS = {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
};
describe('WebFetchService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let providers: Record<string, ProviderConfig>;
let servicesConfig: ServicesConfig | undefined;
let identitySlug: string | undefined;
let resolveTokenProvider: ReturnType<typeof vi.fn>;
beforeEach(() => {
disposables = new DisposableStore();
providers = {};
servicesConfig = undefined;
identitySlug = undefined;
resolveTokenProvider = vi
.fn()
.mockReturnValue({ getAccessToken: async () => 'access-token' });
@ -47,13 +60,17 @@ describe('WebFetchService', () => {
resolveTokenProvider:
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
});
// Built per call so each test's `identitySlug` assignment lands in the
// snapshot the service reads.
const snapshot = (): AgentIdentitySnapshot =>
buildAgentIdentitySnapshot({ slug: identitySlug, hostRequestHeaders: HOST_HEADERS });
reg.defineInstance(IAgentIdentity, {
_serviceBrand: undefined,
resolved: () => Promise.resolve(snapshot()),
current: snapshot,
});
reg.definePartialInstance(IBootstrapService, {
args: {
requestHeaders: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
},
},
args: { requestHeaders: HOST_HEADERS },
});
reg.definePartialInstance(IConfigService, {
get: ((domain: string) =>
@ -168,6 +185,41 @@ describe('WebFetchService', () => {
expect(headers['X-Config']).toBe('1');
});
// A `[services]` entry names its own endpoint, so the identity applies there;
// the managed OAuth endpoint is the one the session authenticated against and
// keeps the host's own token.
it('sends the configured identity to a services-config endpoint', async () => {
identitySlug = 'acme';
servicesConfig = {
moonshotFetch: { baseUrl: 'https://fetch.example.com/fetch', apiKey: 'fetch-key' },
};
const fetchMock = vi.fn().mockResolvedValue({ status: 200, text: async () => 'page body' });
vi.stubGlobal('fetch', fetchMock);
await fetcher().fetch('https://example.com/page');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers['User-Agent']).toBe('acme/test');
});
it('keeps the host token on the managed oauth endpoint under a custom identity', async () => {
identitySlug = 'acme';
providers[OAUTH_PROVIDER] = {
type: 'kimi',
baseUrl: 'https://api.example.com/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
};
const fetchMock = vi.fn().mockResolvedValue({ status: 200, text: async () => 'page body' });
vi.stubGlobal('fetch', fetchMock);
await fetcher().fetch('https://example.com/page');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers['User-Agent']).toBe('kimi-code-cli/test');
});
it('prefers the services.moonshot_fetch config over the managed oauth provider', () => {
servicesConfig = {
moonshotFetch: { baseUrl: 'https://config.example.com/fetch', apiKey: 'config-key' },

View file

@ -21,6 +21,7 @@ import { CHECKPOINTED_MODELS, type Checkpointed } from '#/agent/contextMemory/co
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ISessionCronService } from '#/session/cron/sessionCronService';
import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceService';
import { IAgentGoalService } from '#/agent/goal/goal';
@ -192,6 +193,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog
import { ISessionSwarmService } from '#/session/swarm/sessionSwarm';
import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext';
import { stubAgentIdentity } from '../app/agentIdentity/stubs';
import { stubClientIdentity } from '../app/bootstrap/stubs';
import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
@ -1067,6 +1069,11 @@ export class AgentTestContext {
IConfigService,
configService(() => this.kimiConfig),
);
// The harness is a config-already-loaded world, so the identity is
// handed out pre-frozen (no custom identity, matching the empty
// bootstrap headers above); the freeze ordering itself is covered
// by the agentIdentity suite. Suites override via `appService`.
reg.defineInstance(IAgentIdentity, stubAgentIdentity());
reg.defineInstance(
IAppendLogStore,
new PersistenceAppendLogStore(

View file

@ -57,13 +57,32 @@ import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/mo
import '#/kosong/model/modelService';
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
import { HostRequestHeadersAdapter } from '#/app/kosongConfig/hostRequestHeadersAdapter';
import { StubConfigService, stubModelOAuthTokens, stubTokenProvider } from '../stubs';
import { stubAgentIdentity } from '../../app/agentIdentity/stubs';
import { stubBootstrap } from '../../app/bootstrap/stubs';
const HOST_HEADERS = { 'User-Agent': 'kimi-test/1.0', 'X-Msh-Device-Id': 'device-1' };
// The real adapter over the real snapshot builder, so these tests cover the
// exact layers the port hands the catalog in production.
function hostHeadersPort(spec: {
headers: Record<string, string>;
identitySlug?: string;
}): IHostRequestHeaders {
return new HostRequestHeadersAdapter(
stubBootstrap('/home', {}, { requestHeaders: spec.headers }),
stubAgentIdentity({ slug: spec.identitySlug, hostRequestHeaders: spec.headers }),
);
}
function createHost(
sections: Record<string, unknown> = {},
oauthTokens: IModelOAuthTokens = stubModelOAuthTokens(),
hostHeaders: { headers: Record<string, string>; identitySlug?: string } = {
headers: HOST_HEADERS,
},
): {
host: ReturnType<typeof createScopedTestHost>;
config: StubConfigService;
@ -75,7 +94,7 @@ function createHost(
const host = createScopedTestHost([
[IConfigService, config],
[IModelOAuthTokens, oauthTokens],
[IHostRequestHeaders, { headers: HOST_HEADERS }],
[IHostRequestHeaders, hostHeadersPort(hostHeaders)],
]);
// Kosong's registries are pure in-memory stores now (persistence lives in
// the app/kosongConfig bridge): seed them from the fixture sections.
@ -184,6 +203,99 @@ describe('Model assembly (pure data)', () => {
}
});
describe('custom identity', () => {
const THIRD_PARTY = {
providers: {
openai: { type: 'openai', apiKey: 'sk-o', baseUrl: 'https://api.openai.com/v1' },
},
models: { gpt: { provider: 'openai', model: 'gpt-5', maxContextSize: 128000 } },
};
const OFFICIAL = {
providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.example.test/v1' } },
models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } },
};
it('rewrites the User-Agent product token for third-party vendors', () => {
const { host, catalog } = createHost(THIRD_PARTY, stubModelOAuthTokens(), {
headers: HOST_HEADERS,
identitySlug: 'acme-dev',
});
try {
// Version preserved, product token swapped, device headers still absent.
expect(catalog.get('gpt').headers).toEqual({ 'User-Agent': 'acme-dev/1.0' });
} finally {
host.dispose();
}
});
it('preserves a parenthesized User-Agent suffix while rewriting', () => {
const { host, catalog } = createHost(THIRD_PARTY, stubModelOAuthTokens(), {
headers: { 'User-Agent': 'kimi-test/1.0 (web)' },
identitySlug: 'acme-dev',
});
try {
expect(catalog.get('gpt').headers).toEqual({ 'User-Agent': 'acme-dev/1.0 (web)' });
} finally {
host.dispose();
}
});
it('leaves full-header vendor requests byte-for-byte unchanged', () => {
// Vendors on the full-header path keep the host's own product token:
// that header set is built around it and backends key on it.
const { host, catalog } = createHost(OFFICIAL, stubModelOAuthTokens(), {
headers: HOST_HEADERS,
identitySlug: 'acme-dev',
});
try {
expect(catalog.get('k2').headers).toEqual(HOST_HEADERS);
} finally {
host.dispose();
}
});
it('changes nothing when no identity is configured', () => {
const { host, catalog } = createHost(THIRD_PARTY);
try {
expect(catalog.get('gpt').headers).toEqual({ 'User-Agent': 'kimi-test/1.0' });
} finally {
host.dispose();
}
});
it('never synthesizes a User-Agent the host did not provide', () => {
const { host, catalog } = createHost(THIRD_PARTY, stubModelOAuthTokens(), {
headers: {},
identitySlug: 'acme-dev',
});
try {
expect(catalog.get('gpt').headers).toEqual({});
} finally {
host.dispose();
}
});
// Inspection must attribute what the runtime actually sent — including a
// host that spells the header `user-agent`, which the finished layer
// canonicalizes.
it('attributes the User-Agent provenance for a lowercase host spelling', () => {
const { host, catalog } = createHost(THIRD_PARTY, stubModelOAuthTokens(), {
headers: { 'user-agent': 'kimi-test/1.0' },
identitySlug: 'acme-dev',
});
try {
expect(catalog.get('gpt').headers).toEqual({ 'User-Agent': 'acme-dev/1.0' });
const view = catalog.inspect('gpt');
expect(view.sources['resolved.headers.User-Agent']).toMatchObject({
kind: 'builtin',
detail: 'host User-Agent, product token from [identity] (acme-dev)',
});
} finally {
host.dispose();
}
});
});
it('keeps an explicit foreign protocol for a kimi model (the dialect path)', () => {
const { host, catalog } = createHost({
providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.example.test/v1' } },
@ -807,7 +919,7 @@ describe('ModelCatalog ping', () => {
models,
stubModelOAuthTokens(),
registry,
{ headers: {} },
{ headers: {}, thirdPartyHeaders: {} },
);
const result = await catalog.ping('k1');
expect(result).toMatchObject({ ok: true, text: 'pong', finishReason: 'completed' });

View file

@ -115,7 +115,12 @@ describe('StdioMcpClient', () => {
try {
await client.connect();
const tools = await client.listTools();
expect(tools.map((t) => t.name).toSorted()).toEqual(['boom', 'echo', 'read_env']);
expect(tools.map((t) => t.name).toSorted()).toEqual([
'boom',
'echo',
'read_env',
'whoami',
]);
const echo = tools.find((t) => t.name === 'echo');
expect(echo?.description).toBe('Echoes input text');
expect(echo?.inputSchema).toMatchObject({ type: 'object' });

View file

@ -30,6 +30,7 @@ import { z } from 'zod';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Error2 } from '#/errors';
import { KIMI_MCP_CLIENT_NAME } from '#/mcpCore/client-shared';
import { McpConnectionManager, type McpServerEntry } from '#/mcpCore/connection-manager';
import { McpOAuthService } from '#/mcpCore/oauth/service';
@ -62,7 +63,7 @@ describe('McpConnectionManager', () => {
expect(entries.map((e) => e.name).toSorted()).toEqual(['alpha', 'beta']);
for (const entry of entries) {
expect(entry.status).toBe('connected');
expect(entry.toolCount).toBe(3);
expect(entry.toolCount).toBe(4);
expect(entry.transport).toBe('stdio');
}
} finally {
@ -168,6 +169,34 @@ describe('McpConnectionManager', () => {
}
}, 15000);
it('announces the resolved custom identity as the MCP client name', async () => {
const cm = new McpConnectionManager({ resolveClientName: () => 'acme-dev' });
try {
await cm.connectAll({ mock: stdioConfig() });
const resolved = cm.resolved('mock');
if (resolved === undefined) throw new Error('Expected mock MCP server to connect');
const result = await resolved.client.callTool('whoami', {});
expect((result.content[0] as { type: 'text'; text: string }).text).toBe('acme-dev');
} finally {
await cm.shutdown();
}
}, 15000);
it('keeps the builtin MCP client name when no identity is configured', async () => {
const cm = new McpConnectionManager();
try {
await cm.connectAll({ mock: stdioConfig() });
const resolved = cm.resolved('mock');
if (resolved === undefined) throw new Error('Expected mock MCP server to connect');
const result = await resolved.client.callTool('whoami', {});
expect((result.content[0] as { type: 'text'; text: string }).text).toBe(
KIMI_MCP_CLIENT_NAME,
);
} finally {
await cm.shutdown();
}
}, 15000);
it('emits status transitions in order per server', async () => {
const cm = new McpConnectionManager();
const seen: Array<{ name: string; status: McpServerEntry['status'] }> = [];
@ -226,7 +255,7 @@ describe('McpConnectionManager', () => {
expect(cm.get('slow')).toMatchObject({
status: 'connected',
toolCount: 3,
toolCount: 4,
});
expect(seen.filter((event) => event.name === 'slow').map((event) => event.status)).toEqual([
'pending',

View file

@ -45,4 +45,15 @@ server.registerTool(
}),
);
server.registerTool(
'whoami',
{
description: 'Returns the client name announced during initialize',
inputSchema: {},
},
() => ({
content: [{ type: 'text', text: server.server.getClientVersion()?.name ?? '' }],
}),
);
await server.connect(new StdioServerTransport());

File diff suppressed because one or more lines are too long

View file

@ -43,6 +43,7 @@ import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp';
import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService';
import { stubLog } from '../../_base/log/stubs';
import { registerAgentIdentityStub } from '../../app/agentIdentity/stubs';
import {
createMemoryMcpOAuthStore,
slowToolStdioFixture,
@ -102,6 +103,7 @@ describe('Workspace MCP initialization', () => {
onDidChange: Event.None as IWorkspaceTrust['onDidChange'],
});
reg.define(IWorkspaceMcpConfigService, WorkspaceMcpConfigService);
registerAgentIdentityStub(reg);
reg.define(IWorkspaceMcpService, WorkspaceMcpService);
},
});

View file

@ -36,6 +36,7 @@ import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpServic
import { stubLog } from '../../_base/log/stubs';
import { createMemoryMcpOAuthStore, stdioFixture } from '../../mcpCore/stubs';
import { registerAgentIdentityStub } from '../../app/agentIdentity/stubs';
function stdioServer(): McpServerConfig {
return { transport: 'stdio', command: process.execPath, args: [stdioFixture] };
@ -86,6 +87,7 @@ describe('WorkspaceMcpService', () => {
reg.definePartialInstance(IMcpOAuthStore, createMemoryMcpOAuthStore());
reg.defineInstance(ILogService, stubLog());
reg.defineInstance(ITelemetryService, noopTelemetryService);
registerAgentIdentityStub(reg);
reg.define(IWorkspaceMcpService, WorkspaceMcpService);
},
});

View file

@ -48,6 +48,8 @@ import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { AgentIdentityService } from '#/app/agentIdentity/agentIdentityService';
import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource';
import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle';
import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService';
@ -252,6 +254,7 @@ describe('workspace resource sharing (handler chain)', () => {
registerScopedService(LifecycleScope.App, IAppStateService, AppStateService, ScopeActivation.OnScopeCreated, 'state');
registerScopedService(LifecycleScope.Workspace, IWorkspaceStateService, WorkspaceStateService, ScopeActivation.OnScopeCreated, 'state');
registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state');
registerScopedService(LifecycleScope.App, IAgentIdentity, AgentIdentityService, ScopeActivation.OnDemand, 'agentIdentity');
registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog');
registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource, ScopeActivation.OnDemand, 'skillCatalog');
registerScopedService(LifecycleScope.App, IAgentProfileRegistry, AgentProfileRegistryService, ScopeActivation.OnDemand, 'agentProfileCatalog');

View file

@ -70,7 +70,8 @@
*/
import {
BUILTIN_SKILLS,
builtinProductSkillsEnabled,
visibleBuiltinSkills,
ErrorCodes,
EXTRA_SKILL_DIRS_SECTION,
IAgentSkillService,
@ -367,7 +368,10 @@ async function listWorkspaceSkillsForRoot(
const catalog = new InMemorySkillCatalog();
const ordered = [
{ skills: BUILTIN_SKILLS, priority: SKILL_SOURCE_PRIORITY.builtin },
{
skills: visibleBuiltinSkills(builtinProductSkillsEnabled(config)),
priority: SKILL_SOURCE_PRIORITY.builtin,
},
{ skills: plugin.skills, priority: SKILL_SOURCE_PRIORITY.plugin },
{ skills: extra.skills, priority: SKILL_SOURCE_PRIORITY.extra },
{ skills: user.skills, priority: SKILL_SOURCE_PRIORITY.user },

View file

@ -141,6 +141,7 @@ import {
} from '@moonshot-ai/agent-core';
import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug';
import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/app/mcpConfig/configSection';
import { IAgentIdentity } from '@moonshot-ai/agent-core-v2/app/agentIdentity/agentIdentity';
import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager';
import {
AlreadyAuthorizedError,
@ -2029,11 +2030,29 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
// either group).
// -----------------------------------------------------------------------
/** v1's per-core `globalMcpOAuth`, built over the app-scope document store. */
private get globalMcpOAuthService(): McpOAuthService {
/**
* Configured custom identity announced to MCP servers, so these global flows
* match what the workspace-owned manager sends. Reads the frozen snapshot;
* every path that reaches it awaited `globalMcpOAuthService()` first.
*/
private resolveMcpClientName(): string | undefined {
return this.engineAccessor.get(IAgentIdentity).current().slug;
}
/**
* v1's per-core `globalMcpOAuth`, built over the app-scope document store.
*
* Async on purpose: the service caches providers by store key and stamps the
* client name when it first builds one, so any path that can materialize a
* provider must not run before the identity snapshot froze. Guarding here
* rather than at each call site means a new entry point cannot forget to.
*/
private async globalMcpOAuthService(): Promise<McpOAuthService> {
await this.engineAccessor.get(IAgentIdentity).resolved();
if (this.globalMcpOAuth === undefined) {
this.globalMcpOAuth = new McpOAuthService({
store: createMcpOAuthStore(this.engineAccessor.get(IAtomicDocumentStore)),
resolveClientName: () => this.resolveMcpClientName(),
});
}
return this.globalMcpOAuth;
@ -2070,7 +2089,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
const server = await this.globalMcpConfig.get(name);
const config = requireOAuthMcpServer(server);
try {
const flow = await this.globalMcpOAuthService.beginAuthorization(server.name, config.url);
const oauth = await this.globalMcpOAuthService();
const flow = await oauth.beginAuthorization(server.name, config.url);
const flowId = randomUUID();
this.globalMcpOAuthFlows.set(flowId, { flow });
return {
@ -2114,7 +2134,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
override async resetGlobalMcpServerAuth(name: string): Promise<void> {
const server = await this.globalMcpConfig.get(name);
const config = requireRemoteMcpServer(server);
await this.globalMcpOAuthService.invalidate(server.name, config.url);
const oauth = await this.globalMcpOAuthService();
await oauth.invalidate(server.name, config.url);
}
/**
@ -2135,7 +2156,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
const section = this.engineAccessor.get(IConfigService).get<McpSection | undefined>(MCP_SECTION);
const manager = new McpConnectionManager({
stdioCwd: options.cwd,
oauthService: this.globalMcpOAuthService,
oauthService: await this.globalMcpOAuthService(),
resolveClientName: () => this.resolveMcpClientName(),
resolveDefaultTimeouts: () => ({
startupTimeoutMs: section?.startupTimeoutMs,
toolTimeoutMs: section?.toolTimeoutMs,

View file

@ -105,6 +105,26 @@ export function createKimiUserAgent(options: {
: `${product}/${version} (${suffix})`;
}
/**
* Swap the product token of a User-Agent produced by
* {@link createKimiUserAgent}, keeping the version and optional suffix intact
* (`kimi-code-cli/1.2.3 (web)` `acme/1.2.3 (web)`).
*
* Lives next to the builder on purpose: the format knowledge product token,
* `/`, version, parenthesized suffix must exist in exactly one place, so a
* change to the builder cannot silently desynchronize the rewriter. Callers
* pass an already-normalized ASCII token; a blank or non-ASCII product still
* throws rather than emitting an invalid header.
*
* A value that does not carry a `/` is treated as a bare product token and
* replaced wholesale.
*/
export function replaceUserAgentProduct(userAgent: string, product: string): string {
const cleaned = requiredAsciiHeader(product, 'Kimi identity product');
const separator = userAgent.indexOf('/');
return separator < 0 ? cleaned : `${cleaned}${userAgent.slice(separator)}`;
}
export function createKimiDefaultHeaders(options: KimiIdentityOptions): Record<string, string> {
return {
'User-Agent': createKimiUserAgent(options),

View file

@ -36,6 +36,7 @@ export {
KIMI_CODE_PLATFORM,
parseKimiCodeCustomHeaders,
readKimiDeviceId,
replaceUserAgentProduct,
} from './identity';
export type { KimiHostIdentity, KimiIdentityOptions } from './identity';