mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 18:27:39 +00:00
feat(cli): add built-in Computer Use and WebBridge capabilities (#2407)
* feat(agent-core-v2): add built-in capabilities (kimi-cu, kimi-webbridge) with REST routes
Add a capability domain holding a closed registry of built-in product
capabilities. Each entry owns layered readiness detection and idempotent
install orchestration: binary runtimes from fixed official CDN URLs
(KimiCU.app + launchd service + TCC permission state; the WebBridge
daemon with start-if-down semantics for Kimi Work coexistence) plus
agent wiring through the plugin service. The WebBridge wiring un-shadows
stale user-source skill copies (user priority beats plugin priority).
kap-server exposes the domain as GET /api/v1/capabilities,
GET /api/v1/capabilities/{id}, and POST /api/v1/capabilities/{id}:install
with client-polled progress and new wire codes 40418 / 40922 / 40923.
The plugin marketplace gains an official kimi-webbridge entry
(browser-control skills) packaged by the existing CDN build.
* fix(agent-core-v2): rename the webbridge wiring plugin to kimi-webbridge-skill
An official kimi-webbridge guide plugin (install/remove setup skills,
v3.0.4) already exists at the marketplace path the capability installer
pointed at — a different artifact owned by another release line. Give
the browser-control usage-skill plugin its own id/path instead of
colliding with (or overwriting) the guide plugin. The capability entry's
detect/install now tracks kimi-webbridge-skill; a machine with only the
guide plugin correctly reports the skill layer as missing.
* feat(agent-core-v2): shelf installs auto-complete capability binary layers
Two changes to make the plugin marketplace a first-class install path:
- Marketplace gains kimi-cu (sourced from the CU team's CDN zip — no
repackaging) and the kimi-webbridge usage-skill plugin now claims the
kimi-webbridge id at v4.0.0, deliberately superseding the WebBridge
guide plugin (v3.0.4, install/remove guide skills): guide users get a
version upgrade onto the real usage skill.
- The capability service subscribes to IPluginService.onDidReload: when
a capability's wiring step flips to ok through ANY install path
(shelf, TUI, CLI), it auto-completes the missing binary layers
(KimiCU.app + service, or the WebBridge daemon). Triggers only on the
false→true edge so completed installs with still-missing manual steps
(TCC permissions) never retrigger heavy downloads on later reloads.
* fix(plugins): keep kimi-webbridge plugin version aligned with the upstream skill
The plugin version tracks the bundled official usage skill (1.11.3) so
version drift against the WebBridge release line stays visible, instead
of minting an independent 4.0.0.
* fix(agent-core-v2): never report the webbridge installer-script version as the product version
The on-disk ~/.kimi-webbridge/bin/kimi-webbridge.version file tracks the
installer's own lineage (3.1.x, bumps on every install/upgrade run),
not the product version (v1.11.3 — daemon, extension, and skills all
share it). A downed daemon would have shown the misleading installer
number; report no version instead (live /status remains the source of
truth).
* chore(plugins): list kimi-cu on the marketplace without a pinned version
Marketplace versions are optional by schema: rows display the version
detected from the installed plugin's manifest, and update prompts only
fire on a valid semver latest > local comparison. A hand-maintained
number would drift just like the guide plugin's did. The locally built
kimi-webbridge entry keeps its manifest-stamped version (1.11.3).
* fix(agent-core-v2): fire onDidReload on plugin mutations, not just explicit reload
installPlugin / setPluginEnabled / removePlugin changed the catalog
silently — consumers listening to onDidReload (session skill-catalog
convergence, the capability shelf-install hook) only converged on an
explicit reloadPlugins(). Fire the same summary-shaped event on every
mutation (added:[id] / [] / removed:[id]) so every install path
converges. This also unbreaks the shelf-install hook on real hosts:
its unit tests passed against a fake emitter that fired on installs,
which the real service never did.
* feat(kap-server): add plugin management and marketplace REST routes
Expose the App-scope plugin service over the wire so non-CLI hosts
(desktop, web) can manage plugins end to end:
- GET /api/v1/plugins/marketplace — catalog (pluginMarketplaceUrl
server option / KIMI_CODE_PLUGIN_MARKETPLACE_URL env / production
default) merged on demand with live install state; updateAvailable
only on strict semver catalog > installed (no semver dependency)
- GET /api/v1/plugins, POST /api/v1/plugins {source}
- POST /api/v1/plugins/{id}:{enable,disable,remove}
- New wire code 40419 plugin.not_found
Mutations flow through IPluginService, so they serialize with other
install paths and fire onDidReload (session skill catalogs and the
capability shelf-install hook converge).
* feat(agent-core-v2): surface a machine-key note from capability installs
CapabilityEntry.install now resolves an optional note exposed through
CapabilityInstallProgress.note (wire-visible). The webbridge entry
returns 'user-skill-migrated' when it replaces a pre-existing
user-source skill (from the official installer) with the plugin-managed
copy — clients can localize the migration instead of the skill silently
disappearing from the user's directory.
* feat(tui): let the real WebBridge marketplace entry win over the pinned promo
The hardcoded Web Bridge row was built when WebBridge had no plugin
package — it pinned above the Official tab and shadowed any catalog
entry with the same id (open-in-browser only). Now that the marketplace
carries the real kimi-webbridge plugin, flip the precedence: the catalog
entry renders and installs normally, and the pinned promo becomes a
loading/error/legacy-catalog fallback only. Footer counts keep their old
semantics (catalog-only; the promo row is never counted).
* fix(tui): dim the installed state so it stops reading as the install action
Both badges shared a near-identical green-ish treatment in the same
column, making a quiet fact look like a clickable action. States now
recede (installed → textDim) while actions stay loud (install →
primary, update → warning).
* feat(agent-core-v2): converge plugin state across processes sharing a home
Multiple hosts share one KIMI_CODE_HOME (CLI, desktop, other agents), but
each PluginService kept a private in-memory snapshot: a plugin installed
or removed in one process stayed invisible to every other live process
until its next restart — new sessions there kept offering stale plugin
skills/MCP, and the capability shelf hook never saw peer installs.
Watch <home>/plugins for installed.json changes and reloadPlugins
(debounced, echo-suppressed around our own mutations) so all consumers
converge in well under a second: session skill catalogs, plugin MCP
mounts, and the capability shelf-install hook alike.
* fix(agent-core-v2): un-shadow webbridge user skills in BOTH user dirs
kimi-code resolves user-scope skills from two roots (~/.kimi-code/skills
and ~/.agents/skills), both at priority 20 — a stale copy in either
shadows the plugin-managed wiring (priority 5), and also keeps the
capability working after the plugin is removed, which reads as
'uninstall did nothing'. Migrate copies in both dirs during install;
other runtimes' dirs (~/.claude, ~/.codex) remain untouched.
* feat(tui): show live runtime-setup progress for capability installs
Installing a capability plugin (kimi-cu, kimi-webbridge) from the
/plugins shelf kicked off a silent background binary install — the row
flipped to installed while megabytes of runtime downloaded invisibly.
Route capability entries through the capability surface instead: the
panel's inline installing line now mirrors live progress (step +
percent) until the install settles, and the transcript reports
ready / failure-with-retry / still-running accordingly. Capability
removal prints an explicit note that runtime binaries are deliberately
left untouched (the capability keeps working), since that read as
'uninstall did nothing'.
Plumbs the capability service through klient's global facade
('capabilityService' decorator resolves in-process) and the node-sdk
v2 client; Session exposes it with a structural feature-detect so v1
engines fail clearly.
* docs(plugins): keep the kimi-cu marketplace blurb accurate for every client
Only the capability-aware clients auto-install the KimiCU.app runtime;
older builds still get wiring-only (the wrapper's error message then
points at the official setup script). Don't overpromise in the catalog
text every version reads.
* feat(agent-core-v2): install capability wiring from client-bundled plugin copies
The kimi-cu / kimi-webbridge wiring plugins ship inside the client release
instead of the marketplace catalog, binding their visibility to the client
version. Capability installs now resolve the bundled copy (env override,
then npm-layout and source-checkout probes from the module) and install it
as a local path, replacing the two CDN zip URLs. A missing bundle fails the
wiring step with a clear reinstall-or-upgrade message.
* build(cli): bundle the capability wiring plugins into client releases
Vendor the official kimi-cu plugin (v0.5.4, from the CU team's plugin zip)
next to kimi-webbridge under plugins/official, copy both into
apps/kimi-code/bundled-plugins at build time, and ship them in the npm
package (files) and the native SEA blob (a new bundled-plugins asset set
extracted into the native cache at startup, published to the engine via
KIMI_CODE_BUNDLED_PLUGINS_DIR). Desktop points the same variable at its
extraResources copy. The .gitignore build-output entries are anchored so
sources under src/native and test/native stop being silently ignored.
* revert(plugins): remove the kimi-cu and kimi-webbridge marketplace entries
Both capabilities now distribute with the client (bundled wiring), so the
catalog drops back to kimi-datasource / superpowers / vercel-plugin. Older
clients never see the entries; current clients install from the Built-in
section. This also reverts the marketplace blurb commit 0635e99c5.
* feat(tui): add a Built-in capabilities section to the plugins panel
The Official tab now opens with a Built-in section fed by the engine's
capability registry (kimi-cu / kimi-webbridge): per-row install state
(install / finish setup / ready), Enter runs the full capability install
with live progress, and unsupported rows hide (kimi-cu off macOS). The
WebBridge promo fallback only remains for v1 engines — on v2 the real
built-in entry wins. Rows double as the reinstall path: a client upgrade
ships newer wiring, and installing again upserts from the new bundle.
* docs(plugins): document the Built-in section and refresh the capability changeset
* build(nix): stage bundled capability plugins into the SEA build
The native SEA blob now embeds the bundled-plugins asset set, so the nix
derivation needs the plugins tree in its src fileset and the staging step
alongside copy-web-assets before build:native:sea.
* revert: drop the client-bundled wiring distribution
Built-in visibility is simpler to get by injecting the two capability
entries into the marketplace catalog at load time; the wiring plugins
themselves keep installing from their fixed official CDN zips. Removes
the vendored kimi-cu plugin, the bundled-plugins npm/SEA packaging and
flake staging, the engine bundle resolver, and the plugins panel's
Built-in section. Keeps the /agents/ and /native/ gitignore anchors so
sources under src/native and test/native are not silently ignored.
* feat(cli): inject the built-in capability entries into the marketplace catalog
The kimi-cu / kimi-webbridge entries are appended by the client at catalog
load time instead of being served by the remote marketplace.json, binding
their visibility to the client version (older clients never see them). No
version is pinned — reinstalling upserts the wiring — and ids the catalog
already carries always win. In a source checkout the webbridge entry
installs the repo's own plugin copy; packaged builds use the official CDN
zip. This reverts the docs paragraph about the Built-in section, which the
simpler approach makes unnecessary.
* test(tui): select the catalog's own first row in marketplace install tests
The client-injected capability entries suppress the WebBridge promo and
append after the catalog rows, so Kimi Datasource now leads the Official
tab — the extra down-key landed on kimi-cu instead.
* feat(cli): surface the built-in capabilities as client-injected marketplace entries
The kimi-cu / kimi-webbridge entries are injected into the marketplace
catalog by the client (v2 engine, default catalog only) instead of being
served remotely, binding their visibility to the client version; injected
rows mask same-id catalog rows, so what these ids mean stays decided by
the client release — a future official listing only reaches older clients,
whose fix is to upgrade.
The /plugins panel shows capability readiness on the rows (setup
incomplete / installing…), platform-gates kimi-cu to macOS, and Enter
finishes the runtime setup with live progress; v1 keeps the plain plugin
install path and the WebBridge promo fallback.
Capability and plugin calls move from the ad-hoc REST routes onto the
typed klient contract (capabilityService next to pluginService), so the
public REST surface returns to its pre-feature shape. Detection is
presence-only — version pins removed: the current version is always read
live (Info.plist, daemon status, install records), installs are
detect-first and idempotent so an interrupted setup can be retried, and
reinstalling pulls the latest managed artifacts (the passive upgrade
path).
* ci: retrigger checks
* fix(cli): recognize Computer Use CDN plugins as official
* fix(cli): keep built-in entries on catalog outage and isolate detector failures
Two review follow-ups: the client-injected entries no longer disappear when
the marketplace catalog is unreachable (they are not served by it), and a
single capability's failing detect probe degrades to a failed step on that
entry instead of rejecting the whole listCapabilities call.
* refactor(cli): simplify built-in capability integration
* refactor(cli): source built-in catalog rows from the engine and tighten detect probes
The injected marketplace entries are now derived from the engine's
capability registry (listCapabilities) instead of hardcoded client-side
copies — the util only owns the mask/append mechanics, and capability ids
are no longer pinned in the CLI (the remove note resolves them through the
registry too). kimi-cu's detect-path probes (service-status, xpc-ping) get
a 3s timeout — they answer in milliseconds when healthy but run on every
status listing, so a wedged binary must degrade quickly instead of
stalling the panel. Document the Official tab's built-in capability rows
in the plugins guide.
* fix(cli): answer capability id membership without running detectors
listCapabilities() runs every entry's detect probes (seconds on a wedged
binary), so using it to decide whether to print the post-remove hint made
every plugin removal pay a full detection round. The id set is part of the
client/engine contract (mirrored in the klient schema), not product data
that drifts — restore the closed-set check. The injected catalog rows keep
flowing from the registry.
* fix(agent-core-v2): make capability setup recover from disabled, partial, and wedged states
Three review follow-ups on the install path: setup now re-enables the
wiring plugin when a previous disable survived installPlugin's upsert
(detection requires enabled, so it would otherwise strand the capability
at partial); the webbridge daemon-binary step verifies the executable bit
on POSIX, so an install interrupted between rename and chmod re-downloads
instead of failing start with EACCES; and kimi-cu's detect degrades
wedged CLI probes (service-status, xpc-ping) to failed steps instead of
throwing, keeping the detect-first install able to repair the remaining
layers — with the probe timeout injectable for tests.
* fix(agent-core-v2): abort capability downloads whose byte stream stalls
downloadToFile had no inactivity deadline: a CDN connection that stops
producing bytes hung the background install forever, wedging the
capability in a permanent installing state (retries rejected as
in-progress) until the process restarted. An idle watchdog now fails the
download after 30s without a chunk; slow but flowing downloads are
unaffected.
* fix(tui): stop offering capability setup on unsupported platforms
An installed wiring plugin whose capability is unsupported on this
OS/arch (kimi-cu off macOS, webbridge on an unknown arch) was treated
like a partial setup: the Installed tab showed setup incomplete and
Enter routed to installCapability, which the service always rejects.
Setup actions are now gated to actionable states (not_installed /
partial); unsupported renders as a dim fact and Enter opens details.
* fix(agent-core-v2): cover the two remaining install wedge modes
Review follow-ups: the KimiCU app step now requires an executable binary,
so a ditto interrupted mid-copy reads as missing and the next setup
re-copies instead of failing EACCES forever; and downloadToFile's idle
budget now also covers the response-header phase via an AbortSignal on
the fetch itself, so a connection that never completes headers fails the
install (clearing the running state) instead of hanging it.
* fix(tui): render capability rows independently of the catalog fetch
While the marketplace catalog was loading or unreachable, the Official
tab showed only the pinned WebBridge promo — built-in runtime setup was
blocked by an unrelated remote fetch, and Enter opened the browser
instead of installing. Locally-known capability rows (from the engine
registry) now render and install in every catalog state; the promo
remains only as the v1 fallback.
* fix(agent-core-v2): keep KimiCU cleanup timeouts best-effort
stopOldProcesses is documented as || true, but runCommand propagates
timeouts: a wedged old binary made kimi-cu uninstall exceed the command
timeout and the reinstall died before ditto could replace the app.
Cleanup commands now swallow failures (the timeout already attempts a
kill) so the replacement always proceeds; the command timeout is
injectable for tests alongside the probe timeout.
* fix(cli): inject built-in entries only for the default marketplace catalog
Injection is part of the default catalog experience: any explicit
replacement (slash-command source or KIMI_CODE_PLUGIN_MARKETPLACE_URL)
now opts out wholesale — its same-id rows are never masked by the
built-ins, and an unreachable custom catalog surfaces its own failure
instead of being silently replaced by a built-in-only tab.
* refactor: align capability row rendering on the source marker and drop conditional spreads
Marketplace-row capability enrichment (status, badges, issue details,
platform filtering) now keys on the capability:<id> source marker — the
same condition Enter uses to route installs — so a custom catalog row
that merely reuses a built-in id renders and installs as a plain plugin.
Also replaces the conditional-spread optional fields with direct
undefined-valued assignments per the repo coding rules.
* refactor(agent-core-v2): move capability comments to the file headers
The domain's comment convention allows only the top-of-file block:
responsibility and scope context for the recent hardening (detect-first
idempotent install, executability gates, probe-failure degradation,
best-effort cleanup, download watchdog, per-entry detection isolation)
now lives in the module headers, and inline narration beside statements
and members is removed.
* fix(tui): follow an in-progress capability install instead of restarting it
Opening /plugins while a capability setup is already running showed the
installing… row, but Enter called installCapability again and the
service's duplicate-start rejection (40922) surfaced as a fake failure.
The panel now checks the live status first and, when an install is
already running, skips the start call and just polls for the existing
progress.
* fix: align two more replacement paths with their contracts
The EXDEV daemon-binary fallback now stages on the target filesystem and
atomically renames over the destination instead of opening a
possibly-running binary for write (ETXTBSY on Linux). And the panel's
fallback capability rows (catalog loading/error) now follow the same
default-catalog condition as the loader injection, so an explicitly
overridden marketplace fully replaces the Official tab.
* fix(tui): make the built-in row marker unforgeable
The capability:<id> source string was the trust signal for routing rows
into capability installs, but any catalog can write that string — a
custom marketplace could smuggle a row past the third-party trust path
into an official runtime install. Injected rows now carry an internal
builtIn flag that the field-by-field catalog parser never produces;
rendering and install routing key on the flag, and the source string is
purely diagnostic.
* fix(agent-core-v2): include MCP server enablement in capability readiness
A user who disabled the kimi-cu stdio MCP server (/plugins mcp disable)
got a ready capability with no Computer Use tools in new sessions: the
plugin step only checked the plugin toggle, and installPlugin's upsert
preserves per-server state. Readiness now requires every declared MCP
server enabled (reporting e.g. mcp 0/1 enabled), and setup re-enables
disabled servers alongside the plugin toggle.
* fix(agent-core-v2): shell-quote ditto paths in the elevated KimiCU copy
The elevated fallback escaped paths only for the AppleScript string
delimiters, not for the /bin/sh command line inside do shell script: a
TMPDIR with spaces broke the install, and shell metacharacters in the
temp path could inject commands into an administrator-privileged script.
Paths are now POSIX single-quoted first, then the assembled command is
AppleScript-escaped.
* fix(agent-core-v2): never break a working KimiCU on a failed update
The reinstall stopped and uninstalled the old service before the
downloaded archive was unpacked: a corrupt or captive-portal zip then
tore down a previously ready setup. The archive is now staged and
unpacked first, and the app step additionally requires the bundle's
Info.plist, so a partially copied bundle reads as missing and gets
re-copied instead of failing registration against a corrupt bundle.
* fix(agent-core-v2): limit the fetch deadline to the header phase
The 30s AbortSignal stayed attached for the whole request, so a
slow-but-healthy download of a large archive was aborted at 30s total
even while chunks kept arriving — exactly what the per-chunk idle
watchdog was meant to allow. The header phase now uses an
AbortController cleared once headers arrive; the body remains governed
by the inactivity watchdog alone.
* test(tui): provide the harness plugin facade in the capability command fakes
The lazy-session refactor routes session-less plugin calls through
host.harness; the fake host now mirrors that shape.
This commit is contained in:
parent
da6646bf57
commit
0abcd00f7f
39 changed files with 3802 additions and 85 deletions
5
.changeset/built-in-capabilities.md
Normal file
5
.changeset/built-in-capabilities.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add Kimi Computer Use and Kimi WebBridge as built-in official marketplace entries in the v2 CLI. Installing from `/plugins` sets up the latest managed runtime and plugin together, reports incomplete manual steps, and supports retrying interrupted setup.
|
||||
|
|
@ -15,6 +15,7 @@ const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn');
|
|||
const SENTINEL = '.kimi-plugin-marketplace-build.json';
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
||||
const SKIP_FILES = new Set(['.DS_Store']);
|
||||
const EXTRA_CDN_PLUGIN_SOURCES = ['./official/kimi-webbridge'];
|
||||
|
||||
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
|
|
@ -58,6 +59,15 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
|
|||
if (result.archive !== undefined) archives.push(result.archive);
|
||||
}
|
||||
|
||||
// WebBridge is injected by v2 clients rather than listed in the remote
|
||||
// catalog, but its managed plugin still needs a CDN artifact.
|
||||
for (const source of EXTRA_CDN_PLUGIN_SOURCES) {
|
||||
const archive = stripRelativePrefix(withZipExtension(source));
|
||||
if (archives.includes(archive)) continue;
|
||||
const result = await materializeEntrySource(source, pluginsRoot, outDir);
|
||||
if (result.archive !== undefined) archives.push(result.archive);
|
||||
}
|
||||
|
||||
const outputMarketplace = {
|
||||
...parsed,
|
||||
plugins,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { homedir as osHomedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
import type { PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { CapabilityStatus, PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
|
||||
import {
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
PluginMcpSelectorComponent,
|
||||
PluginRemoveConfirmComponent,
|
||||
PluginsPanelComponent,
|
||||
describeCapabilityIssues,
|
||||
formatCapabilityVersion,
|
||||
type PluginInstallTrustConfirmResult,
|
||||
type PluginMcpSelection,
|
||||
type PluginRemoveConfirmResult,
|
||||
|
|
@ -26,8 +28,8 @@ import {
|
|||
isOfficialPluginInstall,
|
||||
isOfficialPluginSource,
|
||||
} from '../utils/plugin-source-label';
|
||||
import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app';
|
||||
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
|
||||
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app';
|
||||
import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace';
|
||||
import { openUrl } from '#/utils/open-url';
|
||||
import type { SlashCommandHost } from './dispatch';
|
||||
|
||||
|
|
@ -205,9 +207,25 @@ async function showPluginsPicker(
|
|||
return;
|
||||
}
|
||||
|
||||
let capabilities: readonly CapabilityStatus[] = [];
|
||||
if (host.engineV2) {
|
||||
try {
|
||||
capabilities = await host.requireSession().listCapabilities();
|
||||
} catch (error) {
|
||||
host.showStatus(
|
||||
`Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const panel = new PluginsPanelComponent({
|
||||
installed: plugins,
|
||||
installedIds: new Set(plugins.map((plugin) => plugin.id)),
|
||||
capabilities,
|
||||
catalogIsDefault:
|
||||
options?.marketplaceSource === undefined &&
|
||||
process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined,
|
||||
initialTab: options?.initialTab,
|
||||
selectedId: options?.selectedId,
|
||||
pluginHint: options?.pluginHint,
|
||||
|
|
@ -227,7 +245,7 @@ async function showPluginsPicker(
|
|||
// keep working even when the marketplace is unreachable (badges simply stay
|
||||
// hidden until data arrives).
|
||||
onRequestMarketplace: () => {
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities);
|
||||
},
|
||||
});
|
||||
host.mountEditorReplacement(panel);
|
||||
|
|
@ -238,19 +256,47 @@ async function showPluginsPicker(
|
|||
// over `panel`.
|
||||
if (options?.initialTab !== 'custom') {
|
||||
panel.setMarketplaceLoading();
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource);
|
||||
void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a capability from the engine's registry into a catalog row. The
|
||||
* engine is the single source of truth for what the built-in capabilities
|
||||
* are — the CLI only renders them. The `capability:<id>` source marker
|
||||
* routes installs through the capability flow (never a plain plugin
|
||||
* install), so the row needs no real URL.
|
||||
*/
|
||||
function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry {
|
||||
return {
|
||||
id: capability.id,
|
||||
displayName: capability.displayName,
|
||||
description: capability.description,
|
||||
tier: 'official',
|
||||
source: `capability:${capability.id}`,
|
||||
builtIn: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMarketplaceCatalog(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
source?: string,
|
||||
source: string | undefined,
|
||||
capabilities: readonly CapabilityStatus[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Injection is part of the DEFAULT catalog experience only: any explicit
|
||||
// replacement (the slash-command source or the env override) opts out
|
||||
// wholesale — its same-id rows are never masked and its failures surface.
|
||||
const isDefaultCatalog =
|
||||
source === undefined && process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined;
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: host.state.appState.workDir,
|
||||
source,
|
||||
builtInEntries:
|
||||
host.engineV2 && isDefaultCatalog
|
||||
? capabilities.map(capabilityMarketplaceEntry)
|
||||
: undefined,
|
||||
});
|
||||
panel.setMarketplace(marketplace.plugins, marketplace.source);
|
||||
} catch (error) {
|
||||
|
|
@ -335,6 +381,136 @@ async function confirmInstallTrust(
|
|||
});
|
||||
}
|
||||
|
||||
const CAPABILITY_POLL_INTERVAL_MS = 700;
|
||||
const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget
|
||||
|
||||
/** Client-injected v2 entries install their runtime and plugin together.
|
||||
* Trust keys on the parser-proof `builtIn` flag — the `capability:<id>`
|
||||
* source string stays purely diagnostic. */
|
||||
function isCapabilityEntry(host: SlashCommandHost, entry: PluginMarketplaceEntry): boolean {
|
||||
return host.engineV2 && entry.builtIn === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed-set id check for the post-remove note. The capability ids are part
|
||||
* of the client/engine CONTRACT (mirrored in the klient zod enum), not
|
||||
* product data that drifts — so they may be named here. What must not
|
||||
* happen is the alternative: answering set membership by running
|
||||
* `listCapabilities()`, which fires every entry's detector (seconds of
|
||||
* probes) just to decide whether to print one hint line.
|
||||
*/
|
||||
function isCapabilityId(host: SlashCommandHost, id: string): boolean {
|
||||
return host.engineV2 && (id === 'kimi-cu' || id === 'kimi-webbridge');
|
||||
}
|
||||
|
||||
/** Poll a background capability install, mirroring progress into the
|
||||
* panel's inline installing line until it settles (or we run out of budget). */
|
||||
async function pollCapabilityInstall(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
id: string,
|
||||
label: string,
|
||||
): Promise<CapabilityStatus | undefined> {
|
||||
const session = host.requireSession();
|
||||
for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS);
|
||||
});
|
||||
const status = await session.getCapability(id);
|
||||
if (!status.install.running) return status;
|
||||
const step = status.install.step ?? 'configuring runtime';
|
||||
const percent = status.install.percent;
|
||||
panel.setInstalling(
|
||||
`${truncateForStatus(label)} — ${step}${percent !== undefined ? ` ${percent}%` : ''}`,
|
||||
);
|
||||
host.state.ui.requestRender();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const __pluginsCommandInternals = {
|
||||
isCapabilityEntry,
|
||||
installCapabilityFromPanel,
|
||||
pollCapabilityInstall,
|
||||
removePlugin,
|
||||
};
|
||||
|
||||
async function installCapabilityFromPanel(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
entry: PluginMarketplaceEntry,
|
||||
): Promise<void> {
|
||||
const label = entry.displayName;
|
||||
// Capability entries are official by construction; the trust prompt is
|
||||
// reserved for unreviewed third-party plugins.
|
||||
panel.setInstalling(truncateForStatus(label));
|
||||
host.state.ui.requestRender();
|
||||
const session = host.requireSession();
|
||||
try {
|
||||
// An install already running (started from another panel or client) is
|
||||
// followed, not restarted — the service rejects duplicate starts even
|
||||
// though the original is healthy.
|
||||
const alreadyRunning = await session
|
||||
.getCapability(entry.id)
|
||||
.then((status) => status.install.running, () => false);
|
||||
if (!alreadyRunning) {
|
||||
await session.installCapability(entry.id);
|
||||
}
|
||||
} catch (error) {
|
||||
panel.clearInstalling();
|
||||
host.state.ui.requestRender();
|
||||
host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
|
||||
host.restoreEditor();
|
||||
return;
|
||||
}
|
||||
let result: CapabilityStatus | undefined;
|
||||
try {
|
||||
result = await pollCapabilityInstall(host, panel, entry.id, label);
|
||||
} catch {
|
||||
result = undefined;
|
||||
}
|
||||
panel.clearInstalling();
|
||||
// Close the panel so the result lines land in the transcript, matching the
|
||||
// plain plugin install flow.
|
||||
host.restoreEditor();
|
||||
if (result === undefined) {
|
||||
host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`);
|
||||
return;
|
||||
}
|
||||
if (result.install.error !== undefined) {
|
||||
host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`);
|
||||
return;
|
||||
}
|
||||
if (result.state !== 'ready') {
|
||||
const issues = describeCapabilityIssues(result);
|
||||
host.showStatus(
|
||||
`${label} setup is incomplete${issues.length > 0 ? `: ${issues}` : ''}.`,
|
||||
'warning',
|
||||
);
|
||||
if (result.id === 'kimi-cu' && result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok')) {
|
||||
host.showStatus(
|
||||
'Grant Accessibility and Screen Recording in System Settings → Privacy & Security, then reopen /plugins to recheck.',
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
return;
|
||||
}
|
||||
host.showStatus(
|
||||
`${label} is ready${result.version !== undefined ? ` (${formatCapabilityVersion(result.version)})` : ''}.`,
|
||||
);
|
||||
const skillShadow = result.steps.find(
|
||||
(step) => step.id === 'skill-shadow' && step.state !== 'ok',
|
||||
);
|
||||
if (skillShadow?.detail !== undefined) {
|
||||
host.showStatus(
|
||||
`A user-installed kimi-webbridge skill is shadowing the managed plugin. Remove it manually: ${skillShadow.detail}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
}
|
||||
|
||||
async function installFromPanel(
|
||||
host: SlashCommandHost,
|
||||
panel: PluginsPanelComponent,
|
||||
|
|
@ -436,6 +612,10 @@ async function handlePluginsPanelSelection(
|
|||
await showPluginsPicker(host, { initialTab: 'installed' });
|
||||
return;
|
||||
case 'install':
|
||||
if (isCapabilityEntry(host, selection.entry)) {
|
||||
await installCapabilityFromPanel(host, panel, selection.entry);
|
||||
return;
|
||||
}
|
||||
await installFromPanel(
|
||||
host,
|
||||
panel,
|
||||
|
|
@ -488,6 +668,11 @@ async function handlePluginMcpSelection(
|
|||
async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
|
||||
await (await resolvePluginApi(host)).removePlugin(id);
|
||||
host.showStatus(`Removed ${id}.`);
|
||||
if (isCapabilityId(host, id)) {
|
||||
host.showStatus(
|
||||
'Note: the runtime binaries were left untouched, but Kimi Code plugin wiring is disabled for new sessions. Reinstall any time from the Official tab.',
|
||||
);
|
||||
}
|
||||
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import {
|
|||
visibleWidth,
|
||||
type Focusable,
|
||||
} from '@moonshot-ai/pi-tui';
|
||||
import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type {
|
||||
CapabilityStatus,
|
||||
PluginInfo,
|
||||
PluginMcpServerInfo,
|
||||
PluginSummary,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { SELECT_POINTER } from '#/tui/constant/symbols';
|
||||
|
|
@ -28,10 +33,11 @@ const INSTALL_TRUST_EXIT = 'exit';
|
|||
const INSTALL_TRUST_TRUST = 'trust';
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
// Hardcoded Web Bridge promotion: a built-in entry that always leads the
|
||||
// Official tab, even when the marketplace catalog is unavailable. Selecting it
|
||||
// opens the install page in the browser rather than installing from a source,
|
||||
// because Web Bridge is a browser extension + daemon, not a plugin package.
|
||||
// Hardcoded Web Bridge promotion: a built-in fallback shown only while the
|
||||
// marketplace catalog is loading, unreachable, or predates the real
|
||||
// `kimi-webbridge` entry. Selecting it opens the install page in the browser;
|
||||
// once the catalog carries the real entry, that row wins and installs
|
||||
// normally.
|
||||
const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent';
|
||||
const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = {
|
||||
id: 'kimi-webbridge',
|
||||
|
|
@ -284,10 +290,15 @@ function pluginStatus(plugin: PluginSummary): string | undefined {
|
|||
}
|
||||
|
||||
function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string {
|
||||
// "update …" is a warning (actionable); "installed …" is success;
|
||||
// "install …" is the available action.
|
||||
// States recede, actions pop: "installed …" is a quiet fact (dim), while
|
||||
// "install …" (the available action) stays primary and "update …" stays a
|
||||
// warning — the two used to share near-identical green-ish treatments in
|
||||
// the same column and read as interchangeable.
|
||||
if (status.startsWith('update')) return chalk.hex(colors.warning);
|
||||
if (status.startsWith('installed')) return chalk.hex(colors.success);
|
||||
if (status === 'finish setup' || status === 'installing…' || status === 'unsupported') {
|
||||
return chalk.hex(colors.warning);
|
||||
}
|
||||
if (status.startsWith('installed')) return chalk.hex(colors.textDim);
|
||||
return chalk.hex(colors.primary);
|
||||
}
|
||||
|
||||
|
|
@ -331,6 +342,13 @@ export type PluginsPanelSelection =
|
|||
export interface PluginsPanelOptions {
|
||||
readonly installed: readonly PluginSummary[];
|
||||
readonly installedIds: ReadonlySet<string>;
|
||||
readonly capabilities?: readonly CapabilityStatus[];
|
||||
/**
|
||||
* False when the marketplace was explicitly replaced (slash-command
|
||||
* source or env override): built-in rows then stay out of the Official
|
||||
* tab entirely. Undefined means the default catalog.
|
||||
*/
|
||||
readonly catalogIsDefault?: boolean;
|
||||
readonly initialTab?: PluginsPanelTabId;
|
||||
readonly selectedId?: string;
|
||||
readonly pluginHint?: { readonly id: string; readonly text: string };
|
||||
|
|
@ -423,20 +441,50 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version]));
|
||||
}
|
||||
|
||||
private capabilityFor(id: string): CapabilityStatus | undefined {
|
||||
return this.opts.capabilities?.find((capability) => capability.id === id);
|
||||
}
|
||||
|
||||
/** Capability state for a MARKETPLACE row: only our own injected rows
|
||||
* (flagged `builtIn` — a custom catalog cannot forge the flag) may show
|
||||
* capability status, matching how Enter routes them. */
|
||||
private capabilityForEntry(entry: PluginMarketplaceEntry): CapabilityStatus | undefined {
|
||||
return entry.builtIn === true ? this.capabilityFor(entry.id) : undefined;
|
||||
}
|
||||
|
||||
private get officialEntries(): readonly PluginMarketplaceEntry[] {
|
||||
// The hardcoded Web Bridge entry always leads the Official tab, even when
|
||||
// the catalog is loading or unreachable. Dedupe by id so a catalog that
|
||||
// also lists it does not render a second row.
|
||||
return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries];
|
||||
// While the catalog is loading or unreachable, the locally-known
|
||||
// capability rows still render and install — built-in runtime setup
|
||||
// must never be blocked by an unrelated catalog fetch.
|
||||
if (this.market.status !== 'loaded') {
|
||||
return this.pendingBuiltInEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id)
|
||||
? this.pendingBuiltInEntries
|
||||
: [...this.pendingBuiltInEntries, WEB_BRIDGE_ENTRY];
|
||||
}
|
||||
// The real catalog entry wins when present (it installs the actual
|
||||
// plugin); the hardcoded promo row is only a fallback while the catalog
|
||||
// is loading, unreachable, or predates it — never a duplicate row.
|
||||
return this.officialCatalogEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id)
|
||||
? this.officialCatalogEntries
|
||||
: [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries];
|
||||
}
|
||||
|
||||
/** Capability rows synthesized from the engine's registry, independent of
|
||||
* the marketplace state; unsupported platforms hide them entirely. Only
|
||||
* the default catalog gets built-in rows — an explicitly overridden
|
||||
* marketplace must be able to fully replace the Official tab. */
|
||||
private get pendingBuiltInEntries(): readonly PluginMarketplaceEntry[] {
|
||||
if (this.opts.catalogIsDefault === false) return [];
|
||||
return (this.opts.capabilities ?? [])
|
||||
.filter((capability) => capability.supported)
|
||||
.map(capabilityMarketplaceEntry);
|
||||
}
|
||||
|
||||
private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] {
|
||||
// Dedupe by id (not reference): if the official catalog also lists
|
||||
// kimi-webbridge, the pinned row already represents it, so suppress the
|
||||
// catalog copy to avoid a duplicate row on the Official tab.
|
||||
return this.marketplaceEntries.filter(
|
||||
(entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id,
|
||||
);
|
||||
return this.marketplaceEntries.filter((entry) => {
|
||||
if (entry.tier !== 'official') return false;
|
||||
return this.capabilityForEntry(entry)?.supported !== false;
|
||||
});
|
||||
}
|
||||
|
||||
private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] {
|
||||
|
|
@ -522,6 +570,11 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (plugin === undefined) return;
|
||||
const capability = this.capabilityFor(plugin.id);
|
||||
if (capability !== undefined && capabilityNeedsSetup(capability)) {
|
||||
this.opts.onSelect({ kind: 'install', entry: capabilityMarketplaceEntry(capability) });
|
||||
return;
|
||||
}
|
||||
const update = this.installedUpdateStatus(plugin);
|
||||
if (update !== undefined) {
|
||||
this.opts.onSelect({ kind: 'install', entry: update.entry });
|
||||
|
|
@ -614,8 +667,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
|
||||
private installedHint(): string {
|
||||
const plugin = this.opts.installed[this.selectedIndex];
|
||||
const capability = plugin === undefined ? undefined : this.capabilityFor(plugin.id);
|
||||
const needsSetup = capability !== undefined && capabilityNeedsSetup(capability);
|
||||
const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined;
|
||||
const enter = hasUpdate ? 'Enter update' : 'Enter details';
|
||||
const enter = needsSetup ? 'Enter finish setup' : hasUpdate ? 'Enter update' : 'Enter details';
|
||||
return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`;
|
||||
}
|
||||
|
||||
|
|
@ -637,6 +692,7 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||
const status = pluginStatus(plugin);
|
||||
const update = this.installedUpdateStatus(plugin);
|
||||
const capability = this.capabilityFor(plugin.id);
|
||||
let line = prefix + labelStyle(plugin.displayName);
|
||||
if (status !== undefined) {
|
||||
line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status);
|
||||
|
|
@ -645,12 +701,31 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
const badge = `update ${update.local} → ${update.latest}`;
|
||||
line += ' ' + marketplaceStatusStyle(badge, colors)(badge);
|
||||
}
|
||||
if (capability !== undefined && capability.state !== 'ready') {
|
||||
const badge = capability.install.running
|
||||
? 'installing…'
|
||||
: capabilityNeedsSetup(capability)
|
||||
? 'setup incomplete'
|
||||
: capability.state === 'unsupported'
|
||||
? 'unsupported'
|
||||
: undefined;
|
||||
if (badge !== undefined) {
|
||||
// Unsupported is a fact, not a problem: dim it; actionable setup
|
||||
// states keep the warning tone.
|
||||
line += ' ' + (badge === 'unsupported' ? chalk.hex(colors.textDim)(badge) : chalk.hex(colors.warning)(badge));
|
||||
}
|
||||
}
|
||||
if (this.opts.pluginHint?.id === plugin.id) {
|
||||
line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text);
|
||||
}
|
||||
const descWidth = Math.max(1, width - 4);
|
||||
const out = [line];
|
||||
for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) {
|
||||
const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability);
|
||||
const description =
|
||||
capabilityIssues.length === 0
|
||||
? overviewPluginDescription(plugin)
|
||||
: `${overviewPluginDescription(plugin)} · ${capabilityIssues}`;
|
||||
for (const descLine of wrapOverviewDescription(description, descWidth)) {
|
||||
out.push(mutedHintLine(` ${descLine}`, colors));
|
||||
}
|
||||
return out;
|
||||
|
|
@ -661,6 +736,10 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
width: number,
|
||||
entries: readonly PluginMarketplaceEntry[],
|
||||
indexOffset = 0,
|
||||
// Counts (installed/available footer) are computed over this list:
|
||||
// the Official tab renders the pinned promo as a row but excludes it
|
||||
// from the catalog counts, matching its pre-catalog semantics.
|
||||
entriesForCount: readonly PluginMarketplaceEntry[] = entries,
|
||||
): void {
|
||||
const colors = currentTheme.palette;
|
||||
if (this.market.status === 'loading' || this.market.status === 'idle') {
|
||||
|
|
@ -679,20 +758,31 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width));
|
||||
}
|
||||
}
|
||||
const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length;
|
||||
const installedCount = entriesForCount.filter((e) => this.opts.installedIds.has(e.id)).length;
|
||||
lines.push('');
|
||||
lines.push(
|
||||
mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors),
|
||||
mutedHintLine(
|
||||
` ${installedCount} installed · ${entriesForCount.length - installedCount} available`,
|
||||
colors,
|
||||
),
|
||||
);
|
||||
lines.push(mutedHintLine(` Source: ${this.market.source}`, colors));
|
||||
}
|
||||
|
||||
private renderOfficial(lines: string[], width: number): void {
|
||||
// Web Bridge is pinned above the catalog and stays visible while the
|
||||
// catalog loads or errors, since it's built into the TUI rather than
|
||||
// fetched. Catalog rows shift down by one index to match.
|
||||
lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width));
|
||||
this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1);
|
||||
// Loading / error: `officialEntries` carries the locally-known
|
||||
// capability rows (plus the promo fallback when webbridge is not among
|
||||
// them), so built-in setup works before the catalog arrives. Once
|
||||
// loaded, the promo appears only when the catalog lacks the real entry.
|
||||
if (this.market.status !== 'loaded') {
|
||||
const entries = this.officialEntries;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
lines.push(...this.renderMarketplaceRow(entries[i]!, i, width));
|
||||
}
|
||||
this.renderMarketplaceTab(lines, width, [], entries.length);
|
||||
return;
|
||||
}
|
||||
this.renderMarketplaceTab(lines, width, this.officialEntries, 0, this.officialCatalogEntries);
|
||||
}
|
||||
|
||||
private renderThirdParty(lines: string[], width: number): void {
|
||||
|
|
@ -705,14 +795,22 @@ export class PluginsPanelComponent extends Container implements Focusable {
|
|||
const pointer = selected ? SELECT_POINTER : ' ';
|
||||
const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `);
|
||||
const capability = this.capabilityForEntry(entry);
|
||||
const status = isPinnedWebBridgeEntry(entry)
|
||||
? 'open in browser'
|
||||
: marketplaceEntryStatus(entry, this.installedVersions);
|
||||
: capability === undefined
|
||||
? marketplaceEntryStatus(entry, this.installedVersions)
|
||||
: capabilityRowStatus(capability, entry);
|
||||
const line =
|
||||
prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status);
|
||||
const descWidth = Math.max(1, width - 4);
|
||||
const out = [line];
|
||||
for (const descLine of wrapOverviewDescription(marketplaceEntryDescription(entry), descWidth)) {
|
||||
const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability);
|
||||
const description =
|
||||
capabilityIssues.length === 0
|
||||
? marketplaceEntryDescription(entry)
|
||||
: `${marketplaceEntryDescription(entry)} · ${capabilityIssues}`;
|
||||
for (const descLine of wrapOverviewDescription(description, descWidth)) {
|
||||
out.push(mutedHintLine(` ${descLine}`, colors));
|
||||
}
|
||||
return out;
|
||||
|
|
@ -790,6 +888,86 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
|
|||
return 'Plugin';
|
||||
}
|
||||
|
||||
function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry {
|
||||
return {
|
||||
id: capability.id,
|
||||
displayName: capability.displayName,
|
||||
source: `capability:${capability.id}`,
|
||||
tier: 'official',
|
||||
description: capability.description,
|
||||
builtIn: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup is actionable only for these states. An `unsupported` capability
|
||||
* (wrong OS/arch) can only fail — the service rejects its install — so the
|
||||
* panel must not offer a "finish setup" action that ends in an error; it
|
||||
* renders as unsupported instead.
|
||||
*/
|
||||
function capabilityNeedsSetup(capability: CapabilityStatus): boolean {
|
||||
return (
|
||||
(capability.state === 'not_installed' || capability.state === 'partial') &&
|
||||
!capability.install.running
|
||||
);
|
||||
}
|
||||
|
||||
function capabilityRowStatus(
|
||||
capability: CapabilityStatus,
|
||||
entry: PluginMarketplaceEntry,
|
||||
): string {
|
||||
if (capability.install.running) return 'installing…';
|
||||
switch (capability.state) {
|
||||
case 'ready':
|
||||
return capability.version === undefined
|
||||
? 'ready'
|
||||
: `ready · ${formatCapabilityVersion(capability.version)}`;
|
||||
case 'partial':
|
||||
return 'finish setup';
|
||||
case 'not_installed':
|
||||
return installStatus(entry);
|
||||
case 'unsupported':
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCapabilityVersion(version: string): string {
|
||||
return version.startsWith('v') ? version : `v${version}`;
|
||||
}
|
||||
|
||||
export function describeCapabilityIssues(capability: CapabilityStatus): string {
|
||||
const issues: string[] = [];
|
||||
const required = capability.steps.filter(
|
||||
(step) => step.optional !== true && step.state !== 'ok',
|
||||
);
|
||||
if (required.length > 0) {
|
||||
issues.push(`needs ${required.map(formatCapabilityStep).join(', ')}`);
|
||||
}
|
||||
const extension = capability.steps.find(
|
||||
(step) => step.id === 'extension' && step.state !== 'ok',
|
||||
);
|
||||
if (extension !== undefined) issues.push('browser extension not connected');
|
||||
const skillShadow = capability.steps.find(
|
||||
(step) => step.id === 'skill-shadow' && step.state !== 'ok',
|
||||
);
|
||||
if (skillShadow !== undefined) issues.push('user skill shadows managed plugin');
|
||||
return issues.join(', ');
|
||||
}
|
||||
|
||||
function formatCapabilityStep(step: CapabilityStatus['steps'][number]): string {
|
||||
const label =
|
||||
step.id === 'daemon-binary'
|
||||
? 'daemon binary'
|
||||
: step.id === 'skill'
|
||||
? 'agent skill'
|
||||
: step.id;
|
||||
if (step.detail === undefined || step.detail.length === 0) return label;
|
||||
const detail = step.detail
|
||||
.replaceAll('screenRecording', 'screen recording')
|
||||
.replaceAll(',', ', ');
|
||||
return `${label} (${detail})`;
|
||||
}
|
||||
|
||||
function installStatus(entry: PluginMarketplaceEntry): string {
|
||||
return entry.version === undefined ? 'install' : `install v${entry.version}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel {
|
|||
}
|
||||
try {
|
||||
const url = new URL(plugin.originalSource);
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'code.kimi.com') {
|
||||
return 'third-party';
|
||||
}
|
||||
if (url.pathname.startsWith('/kimi-code/plugins/official/')) {
|
||||
if (isOfficialPluginUrl(url)) {
|
||||
return 'official';
|
||||
}
|
||||
if (url.pathname.startsWith('/kimi-code/plugins/curated/')) {
|
||||
if (
|
||||
url.protocol === 'https:' &&
|
||||
url.hostname === 'code.kimi.com' &&
|
||||
url.pathname.startsWith('/kimi-code/plugins/curated/')
|
||||
) {
|
||||
return 'curated';
|
||||
}
|
||||
return 'third-party';
|
||||
|
|
@ -60,11 +61,7 @@ export function isOfficialPluginSource(source: string): boolean {
|
|||
const trimmed = source.trim();
|
||||
if (!trimmed.startsWith('https://')) return false;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
url.hostname === 'code.kimi.com' &&
|
||||
url.pathname.startsWith('/kimi-code/plugins/official/')
|
||||
);
|
||||
return isOfficialPluginUrl(new URL(trimmed));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -84,6 +81,16 @@ export function isOfficialPluginInstall(plugin: PluginSummary): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginUrl(url: URL): boolean {
|
||||
if (url.protocol !== 'https:') return false;
|
||||
return (
|
||||
(url.hostname === 'code.kimi.com' &&
|
||||
url.pathname.startsWith('/kimi-code/plugins/official/')) ||
|
||||
(url.hostname === 'cdn.kimi.com' &&
|
||||
url.pathname.startsWith('/kimi-computer-use/'))
|
||||
);
|
||||
}
|
||||
|
||||
function hostFromUrl(raw: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export interface PluginMarketplaceEntry {
|
|||
readonly description?: string;
|
||||
readonly homepage?: string;
|
||||
readonly keywords?: readonly string[];
|
||||
/**
|
||||
* Internal provenance flag for client-injected built-in rows. The catalog
|
||||
* parser builds entries field-by-field and never sets it, so a custom
|
||||
* catalog cannot forge it (unlike the `capability:<id>` source string).
|
||||
*/
|
||||
readonly builtIn?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginMarketplace {
|
||||
|
|
@ -71,6 +77,12 @@ export interface LoadPluginMarketplaceOptions {
|
|||
readonly workDir: string;
|
||||
readonly source?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Built-in capability rows to inject, supplied by the caller from the
|
||||
* engine's capability registry (this util owns no product knowledge).
|
||||
* Undefined means no injection.
|
||||
*/
|
||||
readonly builtInEntries?: readonly PluginMarketplaceEntry[];
|
||||
}
|
||||
|
||||
export async function loadPluginMarketplace(
|
||||
|
|
@ -88,11 +100,42 @@ export async function loadPluginMarketplace(
|
|||
} catch (error) {
|
||||
const fallback =
|
||||
configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined;
|
||||
if (fallback === undefined) throw error;
|
||||
if (fallback === undefined) {
|
||||
if (options.builtInEntries !== undefined) {
|
||||
// The built-in entries do not come from the catalog — keep them
|
||||
// visible when the catalog itself is unreachable.
|
||||
return withBuiltInEntries({ source: location.resolved, plugins: [] }, options.builtInEntries);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
raw = await readMarketplaceText(fallback, fetchImpl);
|
||||
return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl);
|
||||
const marketplace = await withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl);
|
||||
return options.builtInEntries !== undefined
|
||||
? withBuiltInEntries(marketplace, options.builtInEntries)
|
||||
: marketplace;
|
||||
}
|
||||
return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl);
|
||||
const marketplace = await withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl);
|
||||
return options.builtInEntries !== undefined
|
||||
? withBuiltInEntries(marketplace, options.builtInEntries)
|
||||
: marketplace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in capability entries (kimi-cu, kimi-webbridge) are injected by the
|
||||
* client instead of being served by the marketplace catalog, so their
|
||||
* visibility is bound to the client version — older clients never see them.
|
||||
* Same-id catalog rows are MASKED, not merged: what these ids mean stays
|
||||
* decided by the client release, and a future official marketplace listing
|
||||
* only reaches older clients (whose fix is to upgrade). No `version` is
|
||||
* pinned: reinstalling uses the latest managed artifacts.
|
||||
*/
|
||||
function withBuiltInEntries(
|
||||
marketplace: PluginMarketplace,
|
||||
builtIns: readonly PluginMarketplaceEntry[],
|
||||
): PluginMarketplace {
|
||||
const builtInIds = new Set(builtIns.map((entry) => entry.id));
|
||||
const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id));
|
||||
return { ...marketplace, plugins: [...catalog, ...builtIns] };
|
||||
}
|
||||
|
||||
async function withLatestVersions(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPluginMarketplaceCdn } from '../../scripts/build-plugin-marketplace-cdn.mjs';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildPluginMarketplaceCdn', () => {
|
||||
it('packages WebBridge without publishing other unlisted official directories', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'kimi-plugin-cdn-build-'));
|
||||
tempRoots.push(root);
|
||||
const pluginsRoot = join(root, 'plugins');
|
||||
const outDir = join(root, 'out');
|
||||
|
||||
await writePlugin(pluginsRoot, 'listed-plugin');
|
||||
await writePlugin(pluginsRoot, 'kimi-webbridge');
|
||||
await writePlugin(pluginsRoot, 'not-listed');
|
||||
await writeFile(
|
||||
join(pluginsRoot, 'marketplace.json'),
|
||||
JSON.stringify({
|
||||
version: '1',
|
||||
plugins: [{ id: 'listed-plugin', source: './official/listed-plugin' }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await buildPluginMarketplaceCdn({ pluginsRoot, outDir });
|
||||
|
||||
await expect(access(join(outDir, 'official/listed-plugin.zip'))).resolves.toBeUndefined();
|
||||
await expect(access(join(outDir, 'official/kimi-webbridge.zip'))).resolves.toBeUndefined();
|
||||
await expect(access(join(outDir, 'official/not-listed.zip'))).rejects.toThrow();
|
||||
const marketplace = JSON.parse(await readFile(join(outDir, 'marketplace.json'), 'utf8'));
|
||||
expect(marketplace.plugins).toHaveLength(1);
|
||||
expect(marketplace.plugins[0].id).toBe('listed-plugin');
|
||||
});
|
||||
});
|
||||
|
||||
async function writePlugin(pluginsRoot: string, id: string): Promise<void> {
|
||||
const pluginDir = join(pluginsRoot, 'official', id);
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginDir, 'kimi.plugin.json'),
|
||||
JSON.stringify({ name: id, version: '1.0.0' }),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
160
apps/kimi-code/test/tui/commands/plugins-capability.test.ts
Normal file
160
apps/kimi-code/test/tui/commands/plugins-capability.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { __pluginsCommandInternals } from '#/tui/commands/plugins';
|
||||
|
||||
const { isCapabilityEntry, installCapabilityFromPanel, pollCapabilityInstall, removePlugin } =
|
||||
__pluginsCommandInternals;
|
||||
|
||||
function fakeHost(overrides: {
|
||||
engineV2?: boolean;
|
||||
capabilityStatus?: () => Promise<{
|
||||
state?: string;
|
||||
install: { running: boolean; step?: string; percent?: number; error?: string };
|
||||
}>;
|
||||
}) {
|
||||
const statuses: string[] = [];
|
||||
const renders: number[] = [];
|
||||
const installCapability = vi.fn(() => Promise.resolve());
|
||||
const session = {
|
||||
getCapability:
|
||||
overrides.capabilityStatus ??
|
||||
(() => Promise.resolve({ state: 'ready', steps: [], install: { running: false } })),
|
||||
installCapability,
|
||||
removePlugin: () => Promise.resolve(),
|
||||
};
|
||||
const host = {
|
||||
engineV2: overrides.engineV2 ?? false,
|
||||
// Session-less (lazy session): plugin calls fall back to the harness facade.
|
||||
session: undefined,
|
||||
harness: {
|
||||
removePlugin: () => Promise.resolve(),
|
||||
},
|
||||
requireSession: () => session,
|
||||
showStatus: (text: string) => {
|
||||
statuses.push(text);
|
||||
},
|
||||
showError: (text: string) => {
|
||||
statuses.push(text);
|
||||
},
|
||||
restoreEditor: () => undefined,
|
||||
state: { ui: { requestRender: () => renders.push(1) } },
|
||||
};
|
||||
return { host: host as never, statuses, renders, installCapability };
|
||||
}
|
||||
|
||||
function fakePanel() {
|
||||
const lines: (string | undefined)[] = [];
|
||||
return {
|
||||
panel: {
|
||||
setInstalling: (label: string) => {
|
||||
lines.push(label);
|
||||
},
|
||||
clearInstalling: () => {
|
||||
lines.push(undefined);
|
||||
},
|
||||
} as never,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
describe('plugins command capability surface', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('routes built-in entries through capabilities only on v2', () => {
|
||||
const v2 = fakeHost({ engineV2: true });
|
||||
expect(
|
||||
isCapabilityEntry(v2.host, { id: 'kimi-cu', source: 'capability:kimi-cu', builtIn: true } as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCapabilityEntry(v2.host, {
|
||||
id: 'kimi-webbridge',
|
||||
source: 'capability:kimi-webbridge',
|
||||
builtIn: true,
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCapabilityEntry(v2.host, { id: 'kimi-cu', source: 'https://example.test/plugin.zip' } as never),
|
||||
).toBe(false);
|
||||
// A forged capability: source without the parser-proof flag is a plain row.
|
||||
expect(
|
||||
isCapabilityEntry(v2.host, { id: 'kimi-cu', source: 'capability:kimi-cu' } as never),
|
||||
).toBe(false);
|
||||
|
||||
const v1 = fakeHost({});
|
||||
expect(
|
||||
isCapabilityEntry(v1.host, { id: 'kimi-cu', source: 'capability:kimi-cu', builtIn: true } as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('polls progress into the panel until the install settles', async () => {
|
||||
let calls = 0;
|
||||
const { host } = fakeHost({
|
||||
capabilityStatus: () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return Promise.resolve({ install: { running: true, step: 'download', percent: 40 } });
|
||||
}
|
||||
return Promise.resolve({ install: { running: false } });
|
||||
},
|
||||
});
|
||||
const { panel, lines } = fakePanel();
|
||||
|
||||
const result = await pollCapabilityInstall(host, panel, 'kimi-cu', 'Kimi Computer Use');
|
||||
|
||||
expect(result?.install.running).toBe(false);
|
||||
expect(lines).toContain('Kimi Computer Use — download 40%');
|
||||
});
|
||||
|
||||
it('removePlugin notes that capability runtimes are left untouched', async () => {
|
||||
const { host, statuses } = fakeHost({ engineV2: true });
|
||||
await removePlugin(host, 'kimi-cu');
|
||||
expect(statuses.some((s) => s.includes('Removed kimi-cu'))).toBe(true);
|
||||
expect(statuses.some((s) => s.includes('runtime binaries were left untouched'))).toBe(true);
|
||||
expect(statuses.some((s) => s.includes('plugin wiring is disabled for new sessions'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removePlugin stays quiet for non-capability plugins', async () => {
|
||||
const { host, statuses } = fakeHost({ engineV2: true });
|
||||
await removePlugin(host, 'superpowers');
|
||||
expect(statuses.some((s) => s.includes('runtime binaries'))).toBe(false);
|
||||
});
|
||||
|
||||
it('starts a capability install only when none is running', async () => {
|
||||
const idle = fakeHost({});
|
||||
await installCapabilityFromPanel(
|
||||
idle.host,
|
||||
fakePanel().panel,
|
||||
{ id: 'kimi-cu', displayName: 'Kimi Computer Use', source: 'capability:kimi-cu' } as never,
|
||||
);
|
||||
expect(idle.installCapability).toHaveBeenCalledWith('kimi-cu');
|
||||
});
|
||||
|
||||
it('follows an in-progress capability install instead of restarting it', async () => {
|
||||
let calls = 0;
|
||||
const { host, installCapability, statuses } = fakeHost({
|
||||
capabilityStatus: () => {
|
||||
calls += 1;
|
||||
// The pre-check sees the running install; the poll then sees it settle.
|
||||
return Promise.resolve(
|
||||
calls === 1
|
||||
? { state: 'partial', steps: [], install: { running: true, step: 'download', percent: 40 } }
|
||||
: { state: 'ready', steps: [], install: { running: false } },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await installCapabilityFromPanel(
|
||||
host,
|
||||
fakePanel().panel,
|
||||
{ id: 'kimi-cu', displayName: 'Kimi Computer Use', source: 'capability:kimi-cu' } as never,
|
||||
);
|
||||
|
||||
// The service rejects duplicate starts (40922) — a healthy in-progress
|
||||
// install must be followed via polling, never reported as a failure.
|
||||
expect(installCapability).not.toHaveBeenCalled();
|
||||
expect(statuses.some((s) => s.includes('Failed to install'))).toBe(false);
|
||||
expect(statuses.some((s) => s.includes('is ready'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import chalk from 'chalk';
|
||||
import type { CapabilityStatus, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import {
|
||||
PluginInstallTrustConfirmComponent,
|
||||
|
|
@ -69,7 +70,9 @@ const thirdPartyEntries = [
|
|||
const marketplaceEntries = [...officialEntries, ...thirdPartyEntries];
|
||||
|
||||
function makePanel(opts: {
|
||||
installed?: readonly (typeof superpowers)[];
|
||||
installed?: readonly PluginSummary[];
|
||||
capabilities?: readonly CapabilityStatus[];
|
||||
catalogIsDefault?: boolean;
|
||||
initialTab?: 'installed' | 'official' | 'third-party' | 'custom';
|
||||
selectedId?: string;
|
||||
pluginHint?: { id: string; text: string };
|
||||
|
|
@ -80,6 +83,8 @@ function makePanel(opts: {
|
|||
const panel = new PluginsPanelComponent({
|
||||
installed,
|
||||
installedIds: new Set(installed.map((p) => p.id)),
|
||||
capabilities: opts.capabilities,
|
||||
catalogIsDefault: opts.catalogIsDefault,
|
||||
initialTab: opts.initialTab,
|
||||
selectedId: opts.selectedId,
|
||||
pluginHint: opts.pluginHint,
|
||||
|
|
@ -90,6 +95,24 @@ function makePanel(opts: {
|
|||
return { panel, onSelect, onRequestMarketplace };
|
||||
}
|
||||
|
||||
function makeCapability(overrides: Partial<CapabilityStatus> = {}): CapabilityStatus {
|
||||
return {
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
description: 'Background GUI automation',
|
||||
supported: true,
|
||||
state: 'partial',
|
||||
steps: [
|
||||
{ id: 'plugin', state: 'ok' },
|
||||
{ id: 'app', state: 'ok' },
|
||||
{ id: 'service', state: 'ok' },
|
||||
{ id: 'permissions', state: 'missing', detail: 'screenRecording' },
|
||||
],
|
||||
install: { running: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('plugins selector dialogs', () => {
|
||||
it('trusts only built-in Kimi CDN plugin paths', () => {
|
||||
expect(pluginTrustLabel({
|
||||
|
|
@ -120,6 +143,20 @@ describe('plugins selector dialogs', () => {
|
|||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip',
|
||||
})).toBe('curated');
|
||||
expect(pluginTrustLabel({
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
enabled: true,
|
||||
state: 'ok',
|
||||
skillCount: 1,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: 1,
|
||||
hookCount: 0,
|
||||
commandCount: 0,
|
||||
hasErrors: false,
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip',
|
||||
})).toBe('official');
|
||||
expect(pluginTrustLabel({
|
||||
id: 'demo',
|
||||
displayName: 'Demo',
|
||||
|
|
@ -169,6 +206,13 @@ describe('plugins selector dialogs', () => {
|
|||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip',
|
||||
})).toBe(true);
|
||||
expect(isOfficialPluginInstall({
|
||||
...base,
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip',
|
||||
})).toBe(true);
|
||||
// Same manifest id from a local path, GitHub, a loopback URL, or a
|
||||
// third-party URL is not the official build.
|
||||
expect(isOfficialPluginInstall({ ...base, source: 'local-path' })).toBe(false);
|
||||
|
|
@ -185,11 +229,40 @@ describe('plugins selector dialogs', () => {
|
|||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('shows installed Kimi Computer Use and WebBridge plugins as official', () => {
|
||||
const installed: PluginSummary[] = [
|
||||
{
|
||||
...superpowers,
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip',
|
||||
},
|
||||
{
|
||||
...superpowers,
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
source: 'zip-url',
|
||||
originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip',
|
||||
},
|
||||
];
|
||||
|
||||
const { panel } = makePanel({ installed });
|
||||
const out = strip(renderRaw(panel));
|
||||
|
||||
expect(out).toContain('id kimi-cu');
|
||||
expect(out).toContain('via cdn.kimi.com · official');
|
||||
expect(out).toContain('id kimi-webbridge');
|
||||
expect(out).toContain('via code.kimi.com · official');
|
||||
});
|
||||
|
||||
it('treats only the official Kimi CDN path as a trusted install source', () => {
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true);
|
||||
expect(isOfficialPluginSource('https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip')).toBe(true);
|
||||
// Curated and other Kimi CDN paths are not "official" for the install gate.
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false);
|
||||
expect(isOfficialPluginSource('https://cdn.kimi.com/unrelated/plugin.zip')).toBe(false);
|
||||
// Non-Kimi hosts (loopback included), non-https schemes, local paths, and
|
||||
// GitHub sources are unofficial.
|
||||
expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false);
|
||||
|
|
@ -343,6 +416,79 @@ describe('plugins selector dialogs', () => {
|
|||
expect(out).toContain('Marketplace unavailable: fetch failed');
|
||||
});
|
||||
|
||||
it('renders a same-id custom catalog row as a normal plugin, without capability state', () => {
|
||||
// A custom marketplace may legitimately list an entry reusing the
|
||||
// kimi-webbridge id: without the capability: marker it must render and
|
||||
// install as a plain plugin, not borrow capability status.
|
||||
const capabilities = [makeCapability({ id: 'kimi-webbridge', displayName: 'Kimi WebBridge' })];
|
||||
const entries = [
|
||||
{
|
||||
id: 'kimi-webbridge',
|
||||
tier: 'official' as const,
|
||||
displayName: 'Kimi WebBridge (fork)',
|
||||
source: 'https://x/fork.zip',
|
||||
},
|
||||
];
|
||||
const { panel, onSelect } = makePanel({ initialTab: 'official', capabilities });
|
||||
panel.setMarketplace(entries, '/tmp/marketplace.json');
|
||||
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).toContain('Kimi WebBridge (fork) install');
|
||||
expect(out).not.toContain('finish setup');
|
||||
|
||||
panel.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/fork.zip' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders capability rows from the engine while the catalog is still loading', () => {
|
||||
const capabilities = [
|
||||
makeCapability(),
|
||||
makeCapability({
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
state: 'not_installed',
|
||||
steps: [],
|
||||
}),
|
||||
];
|
||||
const { panel, onSelect } = makePanel({ initialTab: 'official', capabilities });
|
||||
|
||||
// No setMarketplace yet — built-in runtime setup must not wait on the
|
||||
// remote catalog: the engine-known rows render (and the promo is
|
||||
// suppressed by the real webbridge row).
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).toContain('Kimi Computer Use finish setup');
|
||||
expect(out).toContain('Kimi WebBridge install');
|
||||
expect(out).not.toContain('open in browser');
|
||||
expect(out).toContain('Loading marketplace');
|
||||
|
||||
panel.handleInput('\r'); // index 0 → kimi-cu routes to capability install
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-cu', source: 'capability:kimi-cu' }),
|
||||
});
|
||||
panel.handleInput('[B');
|
||||
panel.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'capability:kimi-webbridge' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps built-in rows out while the overridden marketplace is loading', () => {
|
||||
// /plugins marketplace <url> or the env override must be able to fully
|
||||
// replace the Official tab — fallback capability rows stay out too.
|
||||
const capabilities = [makeCapability()];
|
||||
const { panel } = makePanel({ initialTab: 'official', capabilities, catalogIsDefault: false });
|
||||
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).not.toContain('Kimi Computer Use');
|
||||
expect(out).toContain('Kimi WebBridge open in browser');
|
||||
expect(out).toContain('Loading marketplace');
|
||||
});
|
||||
|
||||
it('opens the Web Bridge webpage on Enter instead of installing', () => {
|
||||
const { panel, onSelect } = makePanel({ initialTab: 'official' });
|
||||
panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json');
|
||||
|
|
@ -366,22 +512,28 @@ describe('plugins selector dialogs', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not duplicate Web Bridge when the catalog also lists it', () => {
|
||||
it('lets the real catalog entry win over the pinned Web Bridge promo', () => {
|
||||
const entries = [
|
||||
{
|
||||
id: 'kimi-webbridge',
|
||||
tier: 'official' as const,
|
||||
displayName: 'Kimi WebBridge',
|
||||
source: 'https://x/w.zip',
|
||||
source: 'capability:kimi-webbridge',
|
||||
},
|
||||
...officialEntries,
|
||||
];
|
||||
const { panel } = makePanel({ initialTab: 'official' });
|
||||
const { panel, onSelect } = makePanel({ initialTab: 'official' });
|
||||
panel.setMarketplace(entries, '/tmp/marketplace.json');
|
||||
const out = strip(renderRaw(panel));
|
||||
// The label should appear exactly once — the hardcoded row wins, the
|
||||
// catalog copy is filtered out.
|
||||
// Exactly one row, and it is the installable catalog copy — the hardcoded
|
||||
// open-in-browser promo is suppressed.
|
||||
expect(out.split('Kimi WebBridge').length - 1).toBe(1);
|
||||
expect(out).not.toContain('open in browser');
|
||||
panel.handleInput('\r'); // index 0 → the real entry installs
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'capability:kimi-webbridge' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('installs a Third-party entry whose id matches the pinned WebBridge', () => {
|
||||
|
|
@ -393,7 +545,7 @@ describe('plugins selector dialogs', () => {
|
|||
id: 'kimi-webbridge',
|
||||
tier: 'curated' as const,
|
||||
displayName: 'Kimi WebBridge',
|
||||
source: 'https://x/w.zip',
|
||||
source: 'capability:kimi-webbridge',
|
||||
},
|
||||
];
|
||||
const { panel, onSelect } = makePanel({ initialTab: 'third-party' });
|
||||
|
|
@ -403,7 +555,7 @@ describe('plugins selector dialogs', () => {
|
|||
panel.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }),
|
||||
entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'capability:kimi-webbridge' }),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -481,6 +633,120 @@ describe('plugins selector dialogs', () => {
|
|||
expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0');
|
||||
});
|
||||
|
||||
it('shows incomplete capability setup on installed and official rows', () => {
|
||||
const installed = [
|
||||
{ ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' },
|
||||
];
|
||||
const capabilities = [makeCapability()];
|
||||
const entries = [
|
||||
{
|
||||
id: 'kimi-cu',
|
||||
tier: 'official' as const,
|
||||
displayName: 'Kimi Computer Use',
|
||||
version: '0.5.4',
|
||||
source: 'capability:kimi-cu',
|
||||
builtIn: true,
|
||||
},
|
||||
];
|
||||
const { panel } = makePanel({ installed, capabilities });
|
||||
panel.setMarketplace(entries, '/tmp/marketplace.json');
|
||||
|
||||
const installedOut = strip(renderRaw(panel));
|
||||
expect(installedOut).toContain('Kimi Computer Use enabled setup incomplete');
|
||||
expect(installedOut).toContain('needs permissions (screen recording)');
|
||||
|
||||
panel.handleInput('\t');
|
||||
const officialOut = strip(renderRaw(panel));
|
||||
expect(officialOut).toContain('Kimi Computer Use finish setup');
|
||||
expect(officialOut).toContain('needs permissions (screen recording)');
|
||||
});
|
||||
|
||||
it('continues incomplete capability setup from the Installed tab on Enter', () => {
|
||||
const installed = [
|
||||
{ ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' },
|
||||
];
|
||||
const { panel, onSelect } = makePanel({ installed, capabilities: [makeCapability()] });
|
||||
|
||||
panel.handleInput('\r');
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'install',
|
||||
entry: expect.objectContaining({ id: 'kimi-cu', source: 'capability:kimi-cu' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an unsupported capability as a fact, not a setup action', () => {
|
||||
// e.g. the kimi-cu plugin installed on Linux (shared home / v1 path):
|
||||
// Enter can only end in the service rejecting the install, so the row
|
||||
// must not offer "finish setup".
|
||||
const installed = [
|
||||
{ ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' },
|
||||
];
|
||||
const capabilities = [
|
||||
makeCapability({ supported: false, state: 'unsupported', steps: [] }),
|
||||
];
|
||||
const { panel, onSelect } = makePanel({ installed, capabilities });
|
||||
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).toContain('Kimi Computer Use enabled unsupported');
|
||||
expect(out).not.toContain('setup incomplete');
|
||||
expect(out).not.toContain('finish setup');
|
||||
|
||||
panel.handleInput('\r');
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'kimi-cu' });
|
||||
});
|
||||
|
||||
it('does not duplicate a daemon version prefix', () => {
|
||||
const capabilities = [
|
||||
makeCapability({
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
state: 'ready',
|
||||
version: 'v1.11.5',
|
||||
steps: [
|
||||
{ id: 'daemon-binary', state: 'ok' },
|
||||
{ id: 'daemon', state: 'ok' },
|
||||
{ id: 'skill', state: 'ok' },
|
||||
{ id: 'extension', state: 'missing', optional: true },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { panel } = makePanel({ capabilities, initialTab: 'official' });
|
||||
panel.setMarketplace(
|
||||
[{ id: 'kimi-webbridge', displayName: 'Kimi WebBridge', source: 'capability:kimi-webbridge', tier: 'official', builtIn: true }],
|
||||
'/tmp/marketplace.json',
|
||||
);
|
||||
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).toContain('ready · v1.11.5');
|
||||
expect(out).not.toContain('vv1.11.5');
|
||||
});
|
||||
|
||||
it('shows manual cleanup when a user skill shadows the managed plugin', () => {
|
||||
const capabilities = [
|
||||
makeCapability({
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
state: 'partial',
|
||||
steps: [
|
||||
{ id: 'daemon-binary', state: 'ok' },
|
||||
{ id: 'daemon', state: 'ok' },
|
||||
{ id: 'skill', state: 'missing' },
|
||||
{ id: 'skill-shadow', state: 'failed', optional: true },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { panel } = makePanel({ capabilities, initialTab: 'official' });
|
||||
panel.setMarketplace(
|
||||
[{ id: 'kimi-webbridge', displayName: 'Kimi WebBridge', source: 'capability:kimi-webbridge', tier: 'official', builtIn: true }],
|
||||
'/tmp/marketplace.json',
|
||||
);
|
||||
|
||||
const out = strip(renderRaw(panel));
|
||||
expect(out).toContain('needs agent skill');
|
||||
expect(out).toContain('user skill shadows managed plugin');
|
||||
});
|
||||
|
||||
it('does not show an update badge on the Installed tab before the marketplace loads', () => {
|
||||
const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }];
|
||||
const { panel } = makePanel({ installed });
|
||||
|
|
|
|||
|
|
@ -94,34 +94,102 @@ describe('loadPluginMarketplace', () => {
|
|||
'utf8',
|
||||
);
|
||||
|
||||
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file });
|
||||
|
||||
expect(marketplace).toEqual({
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: '/tmp/work',
|
||||
source: file,
|
||||
version: '1',
|
||||
plugins: [
|
||||
{
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
tier: 'official',
|
||||
version: '1.0.0',
|
||||
description: 'Datasource tools',
|
||||
source: join(dir, 'kimi-datasource'),
|
||||
keywords: ['data'],
|
||||
homepage: undefined,
|
||||
},
|
||||
{
|
||||
id: 'superpowers',
|
||||
displayName: 'Superpowers',
|
||||
tier: 'curated',
|
||||
version: '5.1.0',
|
||||
description: 'Workflow skills',
|
||||
source: join(dir, 'curated', 'superpowers'),
|
||||
keywords: ['skills', 'workflow'],
|
||||
homepage: 'https://github.com/obra/superpowers',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(marketplace.source).toBe(file);
|
||||
expect(marketplace.version).toBe('1');
|
||||
expect(marketplace.plugins.slice(0, 2)).toEqual([
|
||||
{
|
||||
id: 'kimi-datasource',
|
||||
displayName: 'Kimi Datasource',
|
||||
tier: 'official',
|
||||
version: '1.0.0',
|
||||
description: 'Datasource tools',
|
||||
source: join(dir, 'kimi-datasource'),
|
||||
keywords: ['data'],
|
||||
homepage: undefined,
|
||||
},
|
||||
{
|
||||
id: 'superpowers',
|
||||
displayName: 'Superpowers',
|
||||
tier: 'curated',
|
||||
version: '5.1.0',
|
||||
description: 'Workflow skills',
|
||||
source: join(dir, 'curated', 'superpowers'),
|
||||
keywords: ['skills', 'workflow'],
|
||||
homepage: 'https://github.com/obra/superpowers',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const builtInEntries = [
|
||||
{
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
description: 'fake cu',
|
||||
tier: 'official' as const,
|
||||
source: 'capability:kimi-cu',
|
||||
},
|
||||
{
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
description: 'fake wb',
|
||||
tier: 'official' as const,
|
||||
source: 'capability:kimi-webbridge',
|
||||
},
|
||||
];
|
||||
|
||||
it('appends the caller-supplied built-in entries the catalog does not carry', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
|
||||
const file = join(dir, 'marketplace.json');
|
||||
await writeFile(file, JSON.stringify({ version: '1', plugins: [] }), 'utf8');
|
||||
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: '/tmp/work',
|
||||
source: file,
|
||||
builtInEntries,
|
||||
});
|
||||
|
||||
// The util owns no product knowledge: entries come from the caller (the
|
||||
// engine's capability registry), and no version is pinned.
|
||||
expect(marketplace.plugins).toEqual(builtInEntries);
|
||||
expect(marketplace.plugins.map((entry) => entry.version)).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it('masks same-id catalog rows with the built-in entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
|
||||
const file = join(dir, 'marketplace.json');
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
plugins: [
|
||||
{
|
||||
id: 'kimi-webbridge',
|
||||
tier: 'official',
|
||||
displayName: 'Kimi WebBridge',
|
||||
source: './kimi-webbridge',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: '/tmp/work',
|
||||
source: file,
|
||||
builtInEntries,
|
||||
});
|
||||
|
||||
// What the built-in ids mean stays decided by the client release: the
|
||||
// catalog's own kimi-webbridge row is masked, only the injected one
|
||||
// survives — a future official listing would only reach older clients.
|
||||
const webbridge = marketplace.plugins.filter((entry) => entry.id === 'kimi-webbridge');
|
||||
expect(webbridge).toHaveLength(1);
|
||||
expect(webbridge[0]?.source).toBe('capability:kimi-webbridge');
|
||||
expect(marketplace.plugins.some((entry) => entry.id === 'kimi-cu')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes Superpowers in the repository marketplace fixture', async () => {
|
||||
|
|
@ -235,6 +303,23 @@ describe('loadPluginMarketplace', () => {
|
|||
})).rejects.toThrow(/fetch failed/);
|
||||
});
|
||||
|
||||
it('keeps the built-in entries when the catalog is unreachable', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// Explicit source (no checkout fallback) + unreachable: the built-ins do
|
||||
// not come from the catalog, so they must survive the outage.
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: '/tmp/work',
|
||||
source: 'https://example.test/marketplace.json',
|
||||
fetchImpl,
|
||||
builtInEntries,
|
||||
});
|
||||
|
||||
expect(marketplace.plugins.map((entry) => entry.id)).toEqual(['kimi-cu', 'kimi-webbridge']);
|
||||
});
|
||||
|
||||
describe('version derivation from a GitHub source', () => {
|
||||
async function loadEntry(source: string, version?: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-'));
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ You can also use slash commands directly:
|
|||
| `/plugins mcp enable <id> <server>` | Enable an MCP server declared by a plugin |
|
||||
| `/plugins mcp disable <id> <server>` | Disable an MCP server declared by a plugin |
|
||||
|
||||
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/<plugin>:<command>` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source.
|
||||
The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/<plugin>:<command>` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. On the v2 engine, the Official tab also lists the built-in product capabilities (Kimi Computer Use — macOS only — and Kimi WebBridge): these rows are injected by the client rather than served by the remote catalog, and each shows its setup state (`install` / `finish setup` / `ready`, with live progress while installing). Pressing Enter runs the full runtime setup — binary runtime and wiring plugin together; reinstalling later installs the current version. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source.
|
||||
|
||||
### Installing from GitHub
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以
|
|||
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server |
|
||||
| `/plugins mcp disable <id> <server>` | 禁用 plugin 声明的 MCP server |
|
||||
|
||||
**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/<plugin>:<command>` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。
|
||||
**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/<plugin>:<command>` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。在 v2 引擎下,**Official** tab 还会列出内置产品能力(Kimi Computer Use——仅限 macOS——和 Kimi WebBridge):这些条目由客户端注入(不来自远端目录),每行显示部署状态(`install` / `finish setup` / `ready`,安装中显示实时进度)。回车执行完整的运行时部署(二进制运行时与接线插件一起装好);之后再装一遍即为升级。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。
|
||||
|
||||
### 从 GitHub 安装
|
||||
|
||||
|
|
|
|||
25
packages/agent-core-v2/src/app/capability/capability.ts
Normal file
25
packages/agent-core-v2/src/app/capability/capability.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* `capability` domain (L3) — `ICapabilityService` contract.
|
||||
*
|
||||
* Manages the built-in product capabilities (`kimi-cu`, `kimi-webbridge`):
|
||||
* layered readiness detection and idempotent install orchestration. Entries
|
||||
* are hardcoded in a closed registry — install sources are fixed official
|
||||
* CDN URLs, never client-supplied.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
import type { CapabilityStatus } from './types';
|
||||
|
||||
export interface ICapabilityService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
listCapabilities(): Promise<readonly CapabilityStatus[]>;
|
||||
|
||||
getCapability(id: string): Promise<CapabilityStatus>;
|
||||
|
||||
installCapability(id: string): Promise<CapabilityStatus>;
|
||||
}
|
||||
|
||||
export const ICapabilityService: ServiceIdentifier<ICapabilityService> =
|
||||
createDecorator<ICapabilityService>('capabilityService');
|
||||
181
packages/agent-core-v2/src/app/capability/capabilityService.ts
Normal file
181
packages/agent-core-v2/src/app/capability/capabilityService.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* `capability` domain (L3) — `ICapabilityService` implementation.
|
||||
*
|
||||
* Holds the closed registry of built-in capability entries and serializes
|
||||
* install runs per entry. Install progress lives in memory only and is
|
||||
* polled by clients; a failed attempt leaves its error in the progress state
|
||||
* until the next attempt starts. Listing degrades a single entry's failing
|
||||
* detection to a failed step on that entry instead of rejecting the whole
|
||||
* list. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { Error2 } from '#/errors';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import { IHostProcessService } from '#/os/interface/hostProcess';
|
||||
|
||||
import { ICapabilityService } from './capability';
|
||||
import { CapabilityErrors } from './errors';
|
||||
import { createKimiCuEntry } from './entries/kimiCu';
|
||||
import { createKimiWebbridgeEntry } from './entries/kimiWebbridge';
|
||||
import type {
|
||||
CapabilityEntry,
|
||||
CapabilityId,
|
||||
CapabilityInstallProgress,
|
||||
CapabilityReadiness,
|
||||
CapabilityStatus,
|
||||
} from './types';
|
||||
|
||||
const IDLE_PROGRESS: CapabilityInstallProgress = { running: false };
|
||||
|
||||
export class CapabilityService implements ICapabilityService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly entries: ReadonlyMap<CapabilityId, CapabilityEntry>;
|
||||
private readonly installProgress = new Map<CapabilityId, CapabilityInstallProgress>();
|
||||
private readonly runningInstalls = new Set<CapabilityId>();
|
||||
|
||||
constructor(
|
||||
@IBootstrapService bootstrap: IBootstrapService,
|
||||
@IPluginService plugins: IPluginService,
|
||||
@IHostProcessService hostProcess: IHostProcessService,
|
||||
entriesOverride?: readonly CapabilityEntry[],
|
||||
) {
|
||||
if (entriesOverride !== undefined) {
|
||||
this.entries = new Map(entriesOverride.map((entry) => [entry.id, entry]));
|
||||
} else {
|
||||
const ctx = {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
kimiHomeDir: bootstrap.homeDir,
|
||||
userHomeDir: homedir(),
|
||||
plugins,
|
||||
hostProcess,
|
||||
};
|
||||
this.entries = new Map<CapabilityId, CapabilityEntry>([
|
||||
['kimi-cu', createKimiCuEntry(ctx)],
|
||||
['kimi-webbridge', createKimiWebbridgeEntry(ctx)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
listCapabilities(): Promise<readonly CapabilityStatus[]> {
|
||||
return Promise.all([...this.entries.values()].map((entry) => this.statusOfSafe(entry)));
|
||||
}
|
||||
|
||||
async getCapability(id: string): Promise<CapabilityStatus> {
|
||||
return this.statusOf(this.requireEntry(id));
|
||||
}
|
||||
|
||||
async installCapability(id: string): Promise<CapabilityStatus> {
|
||||
const entry = this.requireEntry(id);
|
||||
if (!entry.supported) {
|
||||
throw new Error2(
|
||||
CapabilityErrors.codes.CAPABILITY_UNSUPPORTED,
|
||||
`Capability "${entry.id}" is not supported on ${process.platform}/${process.arch}`,
|
||||
{ details: { id: entry.id } },
|
||||
);
|
||||
}
|
||||
if (this.runningInstalls.has(entry.id)) {
|
||||
throw new Error2(
|
||||
CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS,
|
||||
`Capability "${entry.id}" is already being installed`,
|
||||
{ details: { id: entry.id } });
|
||||
}
|
||||
|
||||
this.runningInstalls.add(entry.id);
|
||||
this.installProgress.set(entry.id, { running: true });
|
||||
void (async () => {
|
||||
try {
|
||||
await entry.install((step, percent) => {
|
||||
this.installProgress.set(
|
||||
entry.id,
|
||||
percent === undefined ? { running: true, step } : { running: true, step, percent },
|
||||
);
|
||||
});
|
||||
this.installProgress.set(entry.id, { running: false });
|
||||
} catch (error) {
|
||||
this.installProgress.set(entry.id, {
|
||||
running: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
this.runningInstalls.delete(entry.id);
|
||||
}
|
||||
})();
|
||||
|
||||
return this.statusOf(entry);
|
||||
}
|
||||
|
||||
private requireEntry(id: string): CapabilityEntry {
|
||||
const entry = this.entries.get(id as CapabilityId);
|
||||
if (entry === undefined) {
|
||||
throw new Error2(
|
||||
CapabilityErrors.codes.CAPABILITY_NOT_FOUND,
|
||||
`Capability "${id}" is not registered`,
|
||||
{ details: { id } },
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async statusOf(entry: CapabilityEntry): Promise<CapabilityStatus> {
|
||||
const install = this.installProgress.get(entry.id) ?? IDLE_PROGRESS;
|
||||
const base = {
|
||||
id: entry.id,
|
||||
displayName: entry.displayName,
|
||||
description: entry.description,
|
||||
install,
|
||||
};
|
||||
if (!entry.supported) {
|
||||
return { ...base, supported: false, state: 'unsupported', steps: [] };
|
||||
}
|
||||
const detected = await entry.detect();
|
||||
const required = detected.steps.filter((step) => step.optional !== true);
|
||||
const requiredOk = required.length > 0 && required.every((step) => step.state === 'ok');
|
||||
const anyOk = detected.steps.some((step) => step.state === 'ok');
|
||||
const state: CapabilityReadiness = requiredOk ? 'ready' : anyOk ? 'partial' : 'not_installed';
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
state,
|
||||
steps: detected.steps,
|
||||
version: detected.version,
|
||||
};
|
||||
}
|
||||
|
||||
private async statusOfSafe(entry: CapabilityEntry): Promise<CapabilityStatus> {
|
||||
try {
|
||||
return await this.statusOf(entry);
|
||||
} catch (error) {
|
||||
const install = this.installProgress.get(entry.id) ?? IDLE_PROGRESS;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const base = {
|
||||
id: entry.id,
|
||||
displayName: entry.displayName,
|
||||
description: entry.description,
|
||||
install,
|
||||
};
|
||||
if (!entry.supported) {
|
||||
return { ...base, supported: false, state: 'unsupported', steps: [] };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
state: 'partial',
|
||||
steps: [{ id: 'detect', state: 'failed' as const, detail }],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ICapabilityService,
|
||||
CapabilityService,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'capability',
|
||||
);
|
||||
23
packages/agent-core-v2/src/app/capability/entries/context.ts
Normal file
23
packages/agent-core-v2/src/app/capability/entries/context.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Shared context injected into capability entries. Every field is
|
||||
* constructor-wired by `CapabilityService`; tests substitute fakes
|
||||
* (temp dirs, fake fetch, fake plugin service) rather than touching the
|
||||
* host.
|
||||
*/
|
||||
|
||||
import type { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { IHostProcessService } from '#/os/interface/hostProcess';
|
||||
|
||||
export interface CapabilityEntryContext {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly arch: string;
|
||||
readonly kimiHomeDir: string;
|
||||
readonly userHomeDir: string;
|
||||
readonly plugins: IPluginService;
|
||||
readonly hostProcess: IHostProcessService;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly applicationsDir?: string;
|
||||
readonly webbridgeBaseUrl?: string;
|
||||
readonly detectProbeTimeoutMs?: number;
|
||||
readonly commandTimeoutMs?: number;
|
||||
}
|
||||
336
packages/agent-core-v2/src/app/capability/entries/kimiCu.ts
Normal file
336
packages/agent-core-v2/src/app/capability/entries/kimiCu.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* `kimi-cu` capability entry (macOS only).
|
||||
*
|
||||
* Layers: the official `kimi-cu` plugin (stdio MCP wrapper + skill) +
|
||||
* KimiCU.app (`/Applications`, launchd background service) + TCC
|
||||
* permissions (accessibility + screen recording — the user must grant
|
||||
* these; they can never be set programmatically).
|
||||
*
|
||||
* The install replicates the official `setup_macos.sh` step-for-step
|
||||
* (stop old processes → ditto into /Applications → register service →
|
||||
* request permissions) with structured progress and errors instead of a
|
||||
* shell pipe. Elevation when /Applications is not writable goes through
|
||||
* `osascript ... with administrator privileges` (native auth dialog).
|
||||
* Installs are detect-first and idempotent: only unsatisfied layers are
|
||||
* redone, setup re-enables a previously disabled wiring plugin (and its
|
||||
* MCP servers), the app step requires an executable binary with bundle
|
||||
* metadata, the archive is staged and unpacked before the old service is
|
||||
* stopped, and cleanup of old processes is best-effort — a wedged old
|
||||
* binary turns CLI probes into failed steps or is skipped past, never
|
||||
* blocking the replacement.
|
||||
*/
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { mkdtemp, readFile, rm, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { downloadToFile, runCommand } from '../host';
|
||||
import type {
|
||||
CapabilityDetectResult,
|
||||
CapabilityEntry,
|
||||
CapabilityInstallReporter,
|
||||
CapabilityStep,
|
||||
} from '../types';
|
||||
import type { CapabilityEntryContext } from './context';
|
||||
|
||||
const PLUGIN_ID = 'kimi-cu';
|
||||
const PLUGIN_ZIP_URL = 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip';
|
||||
const APP_ZIP_URL = 'https://cdn.kimi.com/kimi-computer-use/latest/KimiCU.app.zip';
|
||||
const APP_BUNDLE = 'KimiCU.app';
|
||||
const LAUNCHD_LABEL = 'ai.kimi.cu.service';
|
||||
const COMMAND_TIMEOUT_MS = 30_000;
|
||||
const PERMISSIONS_TIMEOUT_MS = 15_000;
|
||||
const DETECT_PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
interface PermissionStatus {
|
||||
readonly accessibility: boolean;
|
||||
readonly screenRecording: boolean;
|
||||
}
|
||||
|
||||
export function parsePermissionStatus(output: string): PermissionStatus | undefined {
|
||||
const match =
|
||||
/(?:permissions|permissionStatus):\s*accessibility=(true|false)\s+screenRecording=(true|false)/.exec(
|
||||
output,
|
||||
);
|
||||
if (match === null) return undefined;
|
||||
return { accessibility: match[1] === 'true', screenRecording: match[2] === 'true' };
|
||||
}
|
||||
|
||||
export async function readAppBundleVersion(infoPlistPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const xml = await readFile(infoPlistPath, 'utf-8');
|
||||
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(xml);
|
||||
return match?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function appleScriptQuote(script: string): string {
|
||||
return script.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function elevatedDittoScript(from: string, to: string): string {
|
||||
return `/usr/bin/ditto ${shQuote(from)} ${shQuote(to)}`;
|
||||
}
|
||||
|
||||
export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry {
|
||||
const applicationsDir = ctx.applicationsDir ?? '/Applications';
|
||||
const appPath = path.join(applicationsDir, APP_BUNDLE);
|
||||
const appBin = path.join(appPath, 'Contents', 'MacOS', 'kimi-cu');
|
||||
const infoPlist = path.join(appPath, 'Contents', 'Info.plist');
|
||||
const probeTimeoutMs = ctx.detectProbeTimeoutMs ?? DETECT_PROBE_TIMEOUT_MS;
|
||||
const commandTimeoutMs = ctx.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
|
||||
const supported = ctx.platform === 'darwin';
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
return access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function executable(p: string): Promise<boolean> {
|
||||
return access(p, constants.X_OK).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function serviceRunning(): Promise<boolean> {
|
||||
if (!(await exists(appBin))) return false;
|
||||
const result = await runCommand(ctx.hostProcess, appBin, ['service-status'], {
|
||||
timeout: probeTimeoutMs,
|
||||
});
|
||||
return /status=1\b/.test(result.stdout);
|
||||
}
|
||||
|
||||
async function permissionStatus(): Promise<PermissionStatus | undefined> {
|
||||
if (!(await exists(appBin))) return undefined;
|
||||
const result = await runCommand(ctx.hostProcess, appBin, ['xpc-ping'], {
|
||||
timeout: probeTimeoutMs,
|
||||
});
|
||||
return parsePermissionStatus(result.stdout);
|
||||
}
|
||||
|
||||
async function detect(): Promise<CapabilityDetectResult> {
|
||||
const steps: CapabilityStep[] = [];
|
||||
|
||||
const installed = await ctx.plugins.listPlugins();
|
||||
const plugin = installed.find((p) => p.id === PLUGIN_ID);
|
||||
const mcpGap =
|
||||
plugin !== undefined && plugin.enabledMcpServerCount < plugin.mcpServerCount
|
||||
? `mcp ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} enabled`
|
||||
: undefined;
|
||||
const pluginOk =
|
||||
plugin !== undefined &&
|
||||
plugin.enabled &&
|
||||
plugin.state === 'ok' &&
|
||||
plugin.enabledMcpServerCount === plugin.mcpServerCount;
|
||||
steps.push({
|
||||
id: 'plugin',
|
||||
state: pluginOk ? 'ok' : 'missing',
|
||||
detail: mcpGap ?? plugin?.version,
|
||||
});
|
||||
|
||||
const version = await readAppBundleVersion(infoPlist);
|
||||
const appExists = await exists(appBin);
|
||||
const appUsable = appExists && (await executable(appBin)) && (await exists(infoPlist));
|
||||
steps.push({
|
||||
id: 'app',
|
||||
state: appUsable ? 'ok' : 'missing',
|
||||
detail: appExists && !appUsable ? 'not executable' : version,
|
||||
});
|
||||
|
||||
try {
|
||||
steps.push({ id: 'service', state: (await serviceRunning()) ? 'ok' : 'missing' });
|
||||
} catch (error) {
|
||||
steps.push({ id: 'service', state: 'failed', detail: errorMessage(error) });
|
||||
}
|
||||
|
||||
let permissions: PermissionStatus | undefined;
|
||||
let permissionsProbeError: string | undefined;
|
||||
try {
|
||||
permissions = await permissionStatus();
|
||||
} catch (error) {
|
||||
permissionsProbeError = errorMessage(error);
|
||||
}
|
||||
if (permissionsProbeError !== undefined) {
|
||||
steps.push({ id: 'permissions', state: 'failed', detail: permissionsProbeError });
|
||||
} else {
|
||||
const granted =
|
||||
permissions !== undefined && permissions.accessibility && permissions.screenRecording;
|
||||
const missingPermissions = permissions === undefined
|
||||
? undefined
|
||||
: [
|
||||
...(permissions.accessibility ? [] : ['accessibility']),
|
||||
...(permissions.screenRecording ? [] : ['screenRecording']),
|
||||
].join(',');
|
||||
steps.push({
|
||||
id: 'permissions',
|
||||
state: granted ? 'ok' : 'missing',
|
||||
detail:
|
||||
granted || missingPermissions === undefined || missingPermissions.length === 0
|
||||
? undefined
|
||||
: missingPermissions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
version: version ?? plugin?.version,
|
||||
};
|
||||
}
|
||||
|
||||
async function bestEffort(command: string, args: readonly string[]): Promise<void> {
|
||||
await runCommand(ctx.hostProcess, command, args, { timeout: commandTimeoutMs }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async function stopOldProcesses(): Promise<void> {
|
||||
const uid = typeof process.getuid === 'function' ? String(process.getuid()) : '501';
|
||||
if (await exists(appBin)) {
|
||||
await bestEffort(appBin, ['uninstall']);
|
||||
}
|
||||
await bestEffort('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]);
|
||||
for (const mode of ['mcp', 'service', 'overlay']) {
|
||||
await bestEffort('pkill', ['-f', `${APP_BUNDLE}/Contents/MacOS/kimi-cu[[:space:]]+${mode}`]);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
}
|
||||
|
||||
async function moveAppIntoPlace(unzippedApp: string): Promise<void> {
|
||||
await rm(appPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
const direct = await runCommand(ctx.hostProcess, 'ditto', [unzippedApp, appPath], {
|
||||
timeout: commandTimeoutMs,
|
||||
});
|
||||
if (direct.code === 0) return;
|
||||
const script = appleScriptQuote(elevatedDittoScript(unzippedApp, appPath));
|
||||
const elevated = await runCommand(
|
||||
ctx.hostProcess,
|
||||
'osascript',
|
||||
['-e', `do shell script "${script}" with administrator privileges`],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
if (elevated.code !== 0) {
|
||||
throw new Error(
|
||||
`Failed to install ${APP_BUNDLE} into ${applicationsDir} ` +
|
||||
`(direct: ${direct.stderr.trim() || direct.code}; elevated: ${elevated.stderr.trim() || elevated.code})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function install(report: CapabilityInstallReporter): Promise<void> {
|
||||
if (!supported) {
|
||||
throw new Error(`kimi-cu is only supported on macOS (current: ${ctx.platform})`);
|
||||
}
|
||||
|
||||
const before = await detect();
|
||||
const stepStates = new Map(before.steps.map((step) => [step.id, step.state]));
|
||||
const readyBefore = before.steps
|
||||
.filter((step) => step.optional !== true)
|
||||
.every((step) => step.state === 'ok');
|
||||
|
||||
if (stepStates.get('plugin') !== 'ok' || readyBefore) {
|
||||
report('plugin');
|
||||
const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL });
|
||||
if (!summary.enabled) {
|
||||
await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true });
|
||||
}
|
||||
if (summary.enabledMcpServerCount < summary.mcpServerCount) {
|
||||
const info = await ctx.plugins.getPluginInfo({ id: PLUGIN_ID });
|
||||
for (const server of info.mcpServers) {
|
||||
if (!server.enabled) {
|
||||
await ctx.plugins.setPluginMcpServerEnabled({
|
||||
id: PLUGIN_ID,
|
||||
server: server.name,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installApp = stepStates.get('app') !== 'ok' || readyBefore;
|
||||
if (installApp) {
|
||||
const workDir = await mkdtemp(path.join(tmpdir(), 'kimi-cu-install-'));
|
||||
try {
|
||||
report('download', 0);
|
||||
const zipPath = path.join(workDir, 'KimiCU.app.zip');
|
||||
await downloadToFile(
|
||||
APP_ZIP_URL,
|
||||
zipPath,
|
||||
(percent) => {
|
||||
report('download', percent);
|
||||
},
|
||||
ctx.fetchImpl,
|
||||
);
|
||||
|
||||
report('app');
|
||||
const unzipDir = path.join(workDir, 'unzipped');
|
||||
const unzipped = await runCommand(ctx.hostProcess, 'ditto', ['-x', '-k', zipPath, unzipDir], {
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (unzipped.code !== 0) {
|
||||
throw new Error(`Failed to unzip KimiCU.app: ${unzipped.stderr || unzipped.stdout}`);
|
||||
}
|
||||
await stopOldProcesses();
|
||||
await moveAppIntoPlace(path.join(unzipDir, APP_BUNDLE));
|
||||
await runCommand(ctx.hostProcess, 'xattr', ['-dr', 'com.apple.quarantine', appPath], {
|
||||
timeout: commandTimeoutMs,
|
||||
});
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (installApp || stepStates.get('service') !== 'ok') {
|
||||
report('service');
|
||||
const registered = await runCommand(ctx.hostProcess, appBin, ['install'], {
|
||||
timeout: commandTimeoutMs,
|
||||
});
|
||||
if (registered.code !== 0) {
|
||||
throw new Error(`kimi-cu install failed: ${registered.stderr || registered.stdout}`);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
const running = await serviceRunning().catch(() => false);
|
||||
if (!running) {
|
||||
throw new Error('kimi-cu background service is not running after install');
|
||||
}
|
||||
}
|
||||
|
||||
if (stepStates.get('permissions') !== 'ok') {
|
||||
report('permissions');
|
||||
await runCommand(
|
||||
ctx.hostProcess,
|
||||
appBin,
|
||||
['request-permissions', '--ax', '--screen'],
|
||||
{ timeout: PERMISSIONS_TIMEOUT_MS },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
description:
|
||||
'macOS GUI automation in the background — read app UIs and click, type, scroll, and drag without taking over your mouse or foregrounding apps.',
|
||||
supported,
|
||||
detect,
|
||||
install,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* `kimi-webbridge` capability entry (macOS / Linux / Windows).
|
||||
*
|
||||
* Layers: daemon binary (`~/.kimi-webbridge/bin/`, local HTTP daemon on
|
||||
* 127.0.0.1:10086) + agent wiring (the official `kimi-webbridge` plugin —
|
||||
* skills only, installed through `IPluginService`) + browser extension
|
||||
* (soft gate, user installs from the webstore or the manual zip).
|
||||
*
|
||||
* A running daemon is left untouched (start-if-down only, Kimi Work
|
||||
* coexistence). Reinstall replaces the on-disk binary from the latest
|
||||
* channel, which takes effect the next time the daemon starts. Installs
|
||||
* are detect-first and idempotent: only unsatisfied layers are redone,
|
||||
* setup re-enables a previously disabled wiring plugin, the binary step
|
||||
* requires the executable bit on POSIX (an interrupted install reads as
|
||||
* missing and re-downloads), and user-source skill shadows are reported
|
||||
* as an optional step for manual cleanup instead of being deleted.
|
||||
*/
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, chmod, mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { downloadToFile, runCommand } from '../host';
|
||||
import type {
|
||||
CapabilityDetectResult,
|
||||
CapabilityEntry,
|
||||
CapabilityInstallReporter,
|
||||
CapabilityStep,
|
||||
} from '../types';
|
||||
import type { CapabilityEntryContext } from './context';
|
||||
|
||||
const PLUGIN_ID = 'kimi-webbridge';
|
||||
const PLUGIN_ZIP_URL =
|
||||
'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip';
|
||||
const BINARY_CDN_BASE = 'https://cdn.kimi.com/webbridge/latest/releases';
|
||||
const DEFAULT_DAEMON_BASE_URL = 'http://127.0.0.1:10086';
|
||||
const STATUS_TIMEOUT_MS = 1_500;
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
const START_POLL_INTERVAL_MS = 500;
|
||||
const START_POLL_ATTEMPTS = 20;
|
||||
|
||||
interface DaemonStatus {
|
||||
readonly running?: boolean;
|
||||
readonly version?: string;
|
||||
readonly extension_connected?: boolean;
|
||||
}
|
||||
|
||||
function binaryAssetName(platform: NodeJS.Platform, arch: string): string | undefined {
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'arm64') return 'kimi-webbridge-darwin-arm64';
|
||||
if (arch === 'x64') return 'kimi-webbridge-darwin-amd64';
|
||||
return undefined;
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
if (arch === 'arm64') return 'kimi-webbridge-linux-arm64';
|
||||
if (arch === 'x64') return 'kimi-webbridge-linux-amd64';
|
||||
return undefined;
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') return 'kimi-webbridge-windows-amd64.exe';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): CapabilityEntry {
|
||||
const baseUrl = ctx.webbridgeBaseUrl ?? DEFAULT_DAEMON_BASE_URL;
|
||||
const binDir = path.join(ctx.userHomeDir, '.kimi-webbridge', 'bin');
|
||||
const binName = ctx.platform === 'win32' ? 'kimi-webbridge.exe' : 'kimi-webbridge';
|
||||
const binPath = path.join(binDir, binName);
|
||||
const userSourceSkillDirs = [
|
||||
path.join(ctx.kimiHomeDir, 'skills', 'kimi-webbridge'),
|
||||
path.join(ctx.userHomeDir, '.agents', 'skills', 'kimi-webbridge'),
|
||||
];
|
||||
const supported = binaryAssetName(ctx.platform, ctx.arch) !== undefined;
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
return access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function executable(p: string): Promise<boolean> {
|
||||
return access(p, constants.X_OK).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDaemonStatus(): Promise<DaemonStatus | undefined> {
|
||||
const fetchImpl = ctx.fetchImpl ?? fetch;
|
||||
try {
|
||||
const resp = await fetchImpl(`${baseUrl}/status`, {
|
||||
signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) return undefined;
|
||||
return (await resp.json()) as DaemonStatus;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function detect(): Promise<CapabilityDetectResult> {
|
||||
const steps: CapabilityStep[] = [];
|
||||
|
||||
const binaryPresent = await exists(binPath);
|
||||
const binaryUsable =
|
||||
binaryPresent && (ctx.platform === 'win32' || (await executable(binPath)));
|
||||
steps.push({
|
||||
id: 'daemon-binary',
|
||||
state: binaryUsable ? 'ok' : 'missing',
|
||||
detail: binaryPresent && !binaryUsable ? 'not executable' : undefined,
|
||||
});
|
||||
|
||||
const daemon = await fetchDaemonStatus();
|
||||
const daemonRunning = daemon?.running === true;
|
||||
steps.push({
|
||||
id: 'daemon',
|
||||
state: daemonRunning ? 'ok' : 'missing',
|
||||
detail: daemonRunning ? daemon?.version : undefined,
|
||||
});
|
||||
|
||||
const installed = await ctx.plugins.listPlugins();
|
||||
const plugin = installed.find((p) => p.id === PLUGIN_ID);
|
||||
const mcpGap =
|
||||
plugin !== undefined && plugin.enabledMcpServerCount < plugin.mcpServerCount
|
||||
? `mcp ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} enabled`
|
||||
: undefined;
|
||||
const pluginOk =
|
||||
plugin !== undefined &&
|
||||
plugin.enabled &&
|
||||
plugin.state === 'ok' &&
|
||||
plugin.enabledMcpServerCount === plugin.mcpServerCount;
|
||||
steps.push({
|
||||
id: 'skill',
|
||||
state: pluginOk ? 'ok' : 'missing',
|
||||
detail: mcpGap ?? plugin?.version,
|
||||
});
|
||||
|
||||
const skillShadows = (
|
||||
await Promise.all(
|
||||
userSourceSkillDirs.map(async (dir) => ({ dir, present: await exists(dir) })),
|
||||
)
|
||||
).filter((item) => item.present);
|
||||
if (skillShadows.length > 0) {
|
||||
steps.push({
|
||||
id: 'skill-shadow',
|
||||
state: 'failed',
|
||||
detail: skillShadows.map((item) => item.dir).join(', '),
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 'extension',
|
||||
state: daemon?.extension_connected === true ? 'ok' : 'missing',
|
||||
optional: true,
|
||||
});
|
||||
|
||||
return { steps, version: daemon?.version };
|
||||
}
|
||||
|
||||
async function waitForDaemon(): Promise<void> {
|
||||
for (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt += 1) {
|
||||
const status = await fetchDaemonStatus();
|
||||
if (status?.running === true) return;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, START_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
throw new Error(`WebBridge daemon did not come up on ${baseUrl} — check ~/.kimi-webbridge/logs`);
|
||||
}
|
||||
|
||||
async function install(report: CapabilityInstallReporter): Promise<void> {
|
||||
const asset = binaryAssetName(ctx.platform, ctx.arch);
|
||||
if (asset === undefined) {
|
||||
throw new Error(`kimi-webbridge is not supported on ${ctx.platform}/${ctx.arch}`);
|
||||
}
|
||||
|
||||
const before = await detect();
|
||||
const stepStates = new Map(before.steps.map((step) => [step.id, step.state]));
|
||||
const readyBefore = before.steps
|
||||
.filter((step) => step.optional !== true)
|
||||
.every((step) => step.state === 'ok');
|
||||
if (stepStates.get('daemon-binary') !== 'ok' || readyBefore) {
|
||||
await installBinary(report, asset);
|
||||
}
|
||||
|
||||
const status = await fetchDaemonStatus();
|
||||
if (status?.running !== true) {
|
||||
report('daemon');
|
||||
const started = await runCommand(ctx.hostProcess, binPath, ['start'], {
|
||||
timeout: START_TIMEOUT_MS,
|
||||
});
|
||||
if (started.code !== 0) {
|
||||
throw new Error(`kimi-webbridge start failed: ${started.stderr || started.stdout}`);
|
||||
}
|
||||
await waitForDaemon();
|
||||
}
|
||||
|
||||
if (stepStates.get('skill') !== 'ok' || readyBefore) {
|
||||
report('skill');
|
||||
const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL });
|
||||
if (!summary.enabled) {
|
||||
await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installBinary(
|
||||
report: CapabilityInstallReporter,
|
||||
asset: string,
|
||||
): Promise<void> {
|
||||
report('download', 0);
|
||||
const url = `${BINARY_CDN_BASE}/${asset}`;
|
||||
const staging = path.join(
|
||||
tmpdir(),
|
||||
`kimi-webbridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ctx.platform === 'win32' ? '.exe' : ''}`,
|
||||
);
|
||||
try {
|
||||
await downloadToFile(
|
||||
url,
|
||||
staging,
|
||||
(percent) => {
|
||||
report('download', percent);
|
||||
},
|
||||
ctx.fetchImpl,
|
||||
);
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await rename(staging, binPath).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'EXDEV') throw error;
|
||||
await renameAcrossDevicesFallback(staging, binPath);
|
||||
});
|
||||
if (ctx.platform !== 'win32') await chmod(binPath, 0o755);
|
||||
} finally {
|
||||
await rm(staging, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'kimi-webbridge',
|
||||
displayName: 'Kimi WebBridge',
|
||||
description:
|
||||
'Control your real browser (with your login sessions) — navigate, click, type, read pages, and screenshot any website.',
|
||||
supported,
|
||||
detect,
|
||||
install,
|
||||
};
|
||||
}
|
||||
|
||||
async function renameAcrossDevicesFallback(from: string, to: string): Promise<void> {
|
||||
const { copyFile } = await import('node:fs/promises');
|
||||
const sibling = `${to}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await copyFile(from, sibling);
|
||||
await rename(sibling, to);
|
||||
} finally {
|
||||
await rm(sibling, { force: true }).catch(() => undefined);
|
||||
}
|
||||
await rm(from, { force: true });
|
||||
}
|
||||
|
||||
export const __kimiWebbridgeInternals = { binaryAssetName, renameAcrossDevicesFallback };
|
||||
15
packages/agent-core-v2/src/app/capability/errors.ts
Normal file
15
packages/agent-core-v2/src/app/capability/errors.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* `capability` domain error codes.
|
||||
*/
|
||||
|
||||
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
|
||||
|
||||
export const CapabilityErrors = {
|
||||
codes: {
|
||||
CAPABILITY_NOT_FOUND: 'capability.not_found',
|
||||
CAPABILITY_UNSUPPORTED: 'capability.unsupported',
|
||||
CAPABILITY_INSTALL_IN_PROGRESS: 'capability.install_in_progress',
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
registerErrorDomain(CapabilityErrors);
|
||||
149
packages/agent-core-v2/src/app/capability/host.ts
Normal file
149
packages/agent-core-v2/src/app/capability/host.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Shared host helpers for capability entries: process execution with
|
||||
* captured output, and streaming downloads with progress reporting.
|
||||
*
|
||||
* `runCommand` never throws for an expected failure — a spawn failure or a
|
||||
* non-zero exit resolves into the result (`code: -1` for spawn failures),
|
||||
* while a timeout kills the process and rejects. `downloadToFile` bounds
|
||||
* both the response-header wait (fetch abort signal) and stream inactivity
|
||||
* (a watchdog reset per chunk, 30s by default), so a stalled CDN connection
|
||||
* fails the background install instead of wedging it.
|
||||
*/
|
||||
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import type { IHostProcessService } from '#/os/interface/hostProcess';
|
||||
|
||||
export interface CommandResult {
|
||||
readonly code: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
async function collect(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
hostProcess: IHostProcessService,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: { timeout?: number } = {},
|
||||
): Promise<CommandResult> {
|
||||
const spawned = await hostProcess.spawn(command, args, { windowsHide: true }).then(
|
||||
(proc) => ({ ok: true as const, proc }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
);
|
||||
if (!spawned.ok) {
|
||||
return { code: -1, stdout: '', stderr: spawned.error instanceof Error ? spawned.error.message : String(spawned.error) };
|
||||
}
|
||||
const { proc } = spawned;
|
||||
try {
|
||||
const work = Promise.all([
|
||||
collect(proc.stdout),
|
||||
collect(proc.stderr),
|
||||
proc.wait().catch(() => -1),
|
||||
] as const);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timed = options.timeout === undefined
|
||||
? work
|
||||
: Promise.race([
|
||||
work,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
void proc.kill().catch(() => {});
|
||||
reject(new Error(`command timed out after ${options.timeout}ms: ${command}`));
|
||||
}, options.timeout);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
const [stdout, stderr, code] = await timed;
|
||||
return { code, stdout, stderr };
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
} finally {
|
||||
proc.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init?: { signal?: AbortSignal },
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
headers: { get(name: string): string | null };
|
||||
body: import('node:stream/web').ReadableStream | null;
|
||||
}>;
|
||||
|
||||
export async function downloadToFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
onPercent?: (percent: number) => void,
|
||||
fetchImpl: FetchLike = fetch as unknown as FetchLike,
|
||||
options: { idleTimeoutMs?: number } = {},
|
||||
): Promise<number> {
|
||||
const idleTimeoutMs = options.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS;
|
||||
const headerController = new AbortController();
|
||||
const headerTimer = setTimeout(() => {
|
||||
headerController.abort();
|
||||
}, idleTimeoutMs);
|
||||
headerTimer.unref?.();
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetchImpl(url, { signal: headerController.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
||||
throw new Error(`Failed to download ${url}: no response within ${idleTimeoutMs}ms`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(headerTimer);
|
||||
}
|
||||
if (!resp.ok || resp.body === null) {
|
||||
throw new Error(`Failed to download ${url}: HTTP ${resp.status}`);
|
||||
}
|
||||
const total = Number(resp.headers.get('content-length') ?? 0);
|
||||
await mkdir(path.dirname(destPath), { recursive: true });
|
||||
let received = 0;
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
const meter = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
armIdleWatchdog();
|
||||
received += chunk.length;
|
||||
if (total > 0 && onPercent !== undefined) {
|
||||
onPercent(Math.min(99, Math.floor((received / total) * 100)));
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
function armIdleWatchdog(): void {
|
||||
if (idleTimer !== undefined) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
meter.destroy(new Error(`Download stalled for ${idleTimeoutMs}ms: ${url}`));
|
||||
}, idleTimeoutMs);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
armIdleWatchdog();
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(resp.body), meter, createWriteStream(destPath));
|
||||
} finally {
|
||||
if (idleTimer !== undefined) clearTimeout(idleTimer);
|
||||
}
|
||||
onPercent?.(100);
|
||||
return received;
|
||||
}
|
||||
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
||||
57
packages/agent-core-v2/src/app/capability/types.ts
Normal file
57
packages/agent-core-v2/src/app/capability/types.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `capability` domain types — built-in product capabilities (kimi-cu,
|
||||
* kimi-webbridge) that bundle a binary runtime + agent wiring + manual
|
||||
* user steps. A capability is NOT a plugin: plugins are declarative
|
||||
* contributions to a session, while capabilities own imperative install
|
||||
* orchestration and a layered readiness state machine for product-specific
|
||||
* runtimes (macOS app + launchd service + TCC permissions; local HTTP
|
||||
* daemon + browser extension). Steps marked `optional` never block
|
||||
* `ready`; `install.note` is a machine key clients localize.
|
||||
*/
|
||||
|
||||
export type CapabilityId = 'kimi-cu' | 'kimi-webbridge';
|
||||
|
||||
export type CapabilityReadiness = 'not_installed' | 'partial' | 'ready' | 'unsupported';
|
||||
|
||||
export type CapabilityStepState = 'ok' | 'missing' | 'failed';
|
||||
|
||||
export interface CapabilityStep {
|
||||
readonly id: string;
|
||||
readonly state: CapabilityStepState;
|
||||
readonly detail?: string;
|
||||
readonly optional?: boolean;
|
||||
}
|
||||
|
||||
export interface CapabilityInstallProgress {
|
||||
readonly running: boolean;
|
||||
readonly step?: string;
|
||||
readonly percent?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface CapabilityDetectResult {
|
||||
readonly version?: string;
|
||||
readonly steps: readonly CapabilityStep[];
|
||||
}
|
||||
|
||||
export interface CapabilityStatus {
|
||||
readonly id: CapabilityId;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly supported: boolean;
|
||||
readonly state: CapabilityReadiness;
|
||||
readonly version?: string;
|
||||
readonly steps: readonly CapabilityStep[];
|
||||
readonly install: CapabilityInstallProgress;
|
||||
}
|
||||
|
||||
export type CapabilityInstallReporter = (step: string, percent?: number) => void;
|
||||
|
||||
export interface CapabilityEntry {
|
||||
readonly id: CapabilityId;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly supported: boolean;
|
||||
detect(): Promise<CapabilityDetectResult>;
|
||||
install(report: CapabilityInstallReporter): Promise<void>;
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ import { IProviderService } from '#/kosong/provider/provider';
|
|||
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
|
||||
import type { HookDef } from '#/agent/externalHooks/types';
|
||||
import type { McpServerConfig } from '#/mcpCore/config-schema';
|
||||
import type { PluginAgentRoot } from './types';
|
||||
import type { SkillRoot } from '#/app/skillCatalog/types';
|
||||
|
||||
import { PluginManager } from './manager';
|
||||
|
|
@ -38,6 +37,7 @@ import type {
|
|||
EnabledPluginSystemPrompt,
|
||||
PluginCommandDef,
|
||||
PluginInfo,
|
||||
PluginAgentRoot,
|
||||
PluginSummary,
|
||||
PluginUpdateStatus,
|
||||
ReloadSummary,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { AuthErrors } from '#/app/auth/errors';
|
|||
import { TaskErrors } from '#/agent/task/errors';
|
||||
import { ProtocolErrors } from '#/kosong/protocol/errors';
|
||||
import { ConfigErrors } from '#/app/config/errors';
|
||||
import { CapabilityErrors } from '#/app/capability/errors';
|
||||
import { CronErrors } from '#/app/cron/errors';
|
||||
import { FileErrors } from '#/app/file/fileService';
|
||||
import { FsErrors } from '#/workspace/workspaceFs/internal/errors';
|
||||
|
|
@ -44,6 +45,7 @@ export { AuthErrors } from '#/app/auth/errors';
|
|||
export { TaskErrors } from '#/agent/task/errors';
|
||||
export { ProtocolErrors } from '#/kosong/protocol/errors';
|
||||
export { ConfigErrors } from '#/app/config/errors';
|
||||
export { CapabilityErrors } from '#/app/capability/errors';
|
||||
export { CronErrors } from '#/app/cron/errors';
|
||||
export { FileErrors } from '#/app/file/fileService';
|
||||
export { FsErrors } from '#/workspace/workspaceFs/internal/errors';
|
||||
|
|
@ -75,6 +77,7 @@ export const ErrorCodes = {
|
|||
...TaskErrors.codes,
|
||||
...ProtocolErrors.codes,
|
||||
...ConfigErrors.codes,
|
||||
...CapabilityErrors.codes,
|
||||
...CronErrors.codes,
|
||||
...FileErrors.codes,
|
||||
...FsErrors.codes,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ export * from '#/app/plugin/archive';
|
|||
export * from '#/app/plugin/manager';
|
||||
export * from '#/app/plugin/plugin';
|
||||
export * from '#/app/plugin/pluginService';
|
||||
export * from '#/app/capability/capability';
|
||||
export * from '#/app/capability/capabilityService';
|
||||
export * from '#/app/capability/errors';
|
||||
export * from '#/app/capability/types';
|
||||
export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader';
|
||||
export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* `CapabilityService` — registry semantics, readiness computation, and
|
||||
* install orchestration (progress transitions, serialized runs, coded
|
||||
* errors). Entries are fakes; entry internals are covered per-entry.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isError2 } from '#/_base/errors/errors';
|
||||
import { CapabilityErrors } from '#/app/capability/errors';
|
||||
import { CapabilityService } from '#/app/capability/capabilityService';
|
||||
import type {
|
||||
CapabilityDetectResult,
|
||||
CapabilityEntry,
|
||||
CapabilityInstallReporter,
|
||||
} from '#/app/capability/types';
|
||||
|
||||
function fakeEntry(overrides: {
|
||||
id: 'kimi-cu' | 'kimi-webbridge';
|
||||
supported?: boolean;
|
||||
detect?: CapabilityDetectResult;
|
||||
install?: (report: CapabilityInstallReporter) => Promise<void>;
|
||||
}): CapabilityEntry {
|
||||
return {
|
||||
id: overrides.id,
|
||||
displayName: overrides.id,
|
||||
description: 'fake',
|
||||
supported: overrides.supported ?? true,
|
||||
detect: () =>
|
||||
Promise.resolve(
|
||||
overrides.detect ?? { steps: [{ id: 'plugin', state: 'ok' }] },
|
||||
),
|
||||
install: overrides.install ?? (() => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeService(entries: readonly CapabilityEntry[]): CapabilityService {
|
||||
// bootstrap / hostProcess are unused when entries are injected.
|
||||
return new CapabilityService(
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
function expectErrorCode(error: unknown, code: string): void {
|
||||
expect(isError2(error)).toBe(true);
|
||||
expect((error as { code: string }).code).toBe(code);
|
||||
}
|
||||
|
||||
describe('CapabilityService', () => {
|
||||
it('lists entries with readiness computed from required steps', async () => {
|
||||
const service = fakeService([
|
||||
fakeEntry({ id: 'kimi-cu', detect: { steps: [{ id: 'plugin', state: 'ok' }] } }),
|
||||
fakeEntry({
|
||||
id: 'kimi-webbridge',
|
||||
detect: {
|
||||
steps: [
|
||||
{ id: 'daemon', state: 'ok' },
|
||||
{ id: 'skill', state: 'missing' },
|
||||
{ id: 'extension', state: 'missing', optional: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const list = await service.listCapabilities();
|
||||
expect(list.map((c) => [c.id, c.state])).toEqual([
|
||||
['kimi-cu', 'ready'],
|
||||
['kimi-webbridge', 'partial'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('isolates a failing detector to its own entry', async () => {
|
||||
const broken: CapabilityEntry = {
|
||||
id: 'kimi-cu',
|
||||
displayName: 'kimi-cu',
|
||||
description: 'fake',
|
||||
supported: true,
|
||||
detect: () => Promise.reject(new Error('probe timed out')),
|
||||
install: () => Promise.resolve(),
|
||||
};
|
||||
const service = fakeService([
|
||||
broken,
|
||||
fakeEntry({ id: 'kimi-webbridge', detect: { steps: [{ id: 'daemon', state: 'ok' }] } }),
|
||||
]);
|
||||
|
||||
// One entry's broken probe must not take down the whole list.
|
||||
const list = await service.listCapabilities();
|
||||
expect(list.find((c) => c.id === 'kimi-webbridge')?.state).toBe('ready');
|
||||
const cu = list.find((c) => c.id === 'kimi-cu');
|
||||
expect(cu?.state).toBe('partial');
|
||||
expect(cu?.steps).toEqual([{ id: 'detect', state: 'failed', detail: 'probe timed out' }]);
|
||||
});
|
||||
|
||||
it('marks optional steps as non-blocking for ready', async () => {
|
||||
const service = fakeService([
|
||||
fakeEntry({
|
||||
id: 'kimi-webbridge',
|
||||
detect: {
|
||||
version: 'v1.11.3',
|
||||
steps: [
|
||||
{ id: 'daemon', state: 'ok' },
|
||||
{ id: 'extension', state: 'missing', optional: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const status = await service.getCapability('kimi-webbridge');
|
||||
expect(status.state).toBe('ready');
|
||||
expect(status.version).toBe('v1.11.3');
|
||||
});
|
||||
|
||||
it('reports not_installed when no step is ok, and unsupported as-is', async () => {
|
||||
const service = fakeService([
|
||||
fakeEntry({ id: 'kimi-cu', detect: { steps: [{ id: 'plugin', state: 'missing' }] } }),
|
||||
fakeEntry({ id: 'kimi-webbridge', supported: false }),
|
||||
]);
|
||||
const list = await service.listCapabilities();
|
||||
expect(list.find((c) => c.id === 'kimi-cu')?.state).toBe('not_installed');
|
||||
const unsupported = list.find((c) => c.id === 'kimi-webbridge');
|
||||
expect(unsupported?.state).toBe('unsupported');
|
||||
expect(unsupported?.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('throws capability.not_found for unknown ids', async () => {
|
||||
const service = fakeService([]);
|
||||
await service.getCapability('nope').then(
|
||||
() => {
|
||||
expect.unreachable();
|
||||
},
|
||||
(error) => {
|
||||
expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_NOT_FOUND);
|
||||
},
|
||||
);
|
||||
await service.installCapability('nope').then(
|
||||
() => {
|
||||
expect.unreachable();
|
||||
},
|
||||
(error) => {
|
||||
expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_NOT_FOUND);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects install on an unsupported entry', async () => {
|
||||
const service = fakeService([fakeEntry({ id: 'kimi-cu', supported: false })]);
|
||||
await service.installCapability('kimi-cu').then(
|
||||
() => {
|
||||
expect.unreachable();
|
||||
},
|
||||
(error) => {
|
||||
expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_UNSUPPORTED);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes installs and clears progress on success', async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const service = fakeService([
|
||||
fakeEntry({
|
||||
id: 'kimi-cu',
|
||||
install: (report) => {
|
||||
report('download', 42);
|
||||
return new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const started = await service.installCapability('kimi-cu');
|
||||
expect(started.install.running).toBe(true);
|
||||
|
||||
await service.installCapability('kimi-cu').then(
|
||||
() => {
|
||||
expect.unreachable();
|
||||
},
|
||||
(error) => {
|
||||
expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS);
|
||||
},
|
||||
);
|
||||
|
||||
const during = await service.getCapability('kimi-cu');
|
||||
expect(during.install).toEqual({ running: true, step: 'download', percent: 42 });
|
||||
|
||||
release?.();
|
||||
// Wait for the background install to settle.
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const status = await service.getCapability('kimi-cu');
|
||||
if (!status.install.running) {
|
||||
expect(status.install.error).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect.unreachable('install never settled');
|
||||
});
|
||||
|
||||
it('surfaces install errors through progress until the next attempt', async () => {
|
||||
let attempts = 0;
|
||||
const service = fakeService([
|
||||
fakeEntry({
|
||||
id: 'kimi-cu',
|
||||
install: () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? Promise.reject(new Error('boom'))
|
||||
: Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await service.installCapability('kimi-cu');
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const status = await service.getCapability('kimi-cu');
|
||||
if (!status.install.running) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const failed = await service.getCapability('kimi-cu');
|
||||
expect(failed.install).toEqual({ running: false, error: 'boom' });
|
||||
|
||||
// Retry clears the error.
|
||||
await service.installCapability('kimi-cu');
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const status = await service.getCapability('kimi-cu');
|
||||
if (!status.install.running) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const retried = await service.getCapability('kimi-cu');
|
||||
expect(retried.install.error).toBeUndefined();
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
157
packages/agent-core-v2/test/app/capability/host.test.ts
Normal file
157
packages/agent-core-v2/test/app/capability/host.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Capability host helpers — command timeout cleanup and late process-stream
|
||||
* failures after a timed-out command.
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Writable } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { downloadToFile, runCommand } from '#/app/capability/host';
|
||||
import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
|
||||
|
||||
describe('capability host runCommand', () => {
|
||||
it('does not leak a rejected promise when a timed-out process fails while being killed', async () => {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
let rejectWait: ((error: Error) => void) | undefined;
|
||||
const wait = new Promise<number>((_resolve, reject) => {
|
||||
rejectWait = reject;
|
||||
});
|
||||
const proc = {
|
||||
_serviceBrand: undefined,
|
||||
pid: 1234,
|
||||
exitCode: null,
|
||||
stdin: new Writable({
|
||||
write: (_chunk, _encoding, callback) => {
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
stdout,
|
||||
stderr,
|
||||
wait: () => wait,
|
||||
kill: () => {
|
||||
stdout.destroy(new Error('stream closed after timeout'));
|
||||
stderr.end();
|
||||
rejectWait?.(new Error('process killed'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
dispose: () => undefined,
|
||||
} as IHostProcess;
|
||||
const host = {
|
||||
_serviceBrand: undefined,
|
||||
spawn: () => Promise.resolve(proc),
|
||||
} as IHostProcessService;
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (error: unknown): void => {
|
||||
unhandled.push(error);
|
||||
};
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
|
||||
try {
|
||||
await expect(runCommand(host, 'hang', [], { timeout: 5 })).rejects.toThrow(
|
||||
'command timed out after 5ms: hang',
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability host downloadToFile', () => {
|
||||
let root: string;
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'capability-download-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fakeFetchWith(body: ReadableStream): typeof fetch {
|
||||
return (() =>
|
||||
Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-length': '100' },
|
||||
}),
|
||||
)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it('aborts a response whose byte stream goes quiet', async () => {
|
||||
// One chunk flows, then the server goes silent — the install must fail
|
||||
// (clearing the running state) instead of hanging forever.
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
// never enqueues or closes again
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
downloadToFile(
|
||||
'https://cdn.example.test/blob',
|
||||
path.join(root, 'blob'),
|
||||
undefined,
|
||||
fakeFetchWith(body) as never,
|
||||
{ idleTimeoutMs: 5 },
|
||||
),
|
||||
).rejects.toThrow(/stalled/);
|
||||
});
|
||||
|
||||
it('aborts when the response headers never arrive', async () => {
|
||||
// The CDN accepted the connection but never completes the headers —
|
||||
// the header phase has its own deadline via the fetch's abort signal.
|
||||
const hangingFetch = ((_url: string, init?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('This operation was aborted.', 'AbortError'));
|
||||
});
|
||||
})) as never;
|
||||
|
||||
await expect(
|
||||
downloadToFile(
|
||||
'https://cdn.example.test/headers',
|
||||
path.join(root, 'headers'),
|
||||
undefined,
|
||||
hangingFetch,
|
||||
{ idleTimeoutMs: 5 },
|
||||
),
|
||||
).rejects.toThrow(/no response within 5ms/);
|
||||
});
|
||||
|
||||
it('lets a slow but flowing download finish intact', async () => {
|
||||
const chunks = ['hel', 'lo ', 'wor', 'ld'];
|
||||
const body = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk));
|
||||
// Per-chunk gaps stay under the budget, but the total stream time
|
||||
// exceeds it — the header deadline must not abort a flowing body.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const dest = path.join(root, 'hello.txt');
|
||||
const received = await downloadToFile(
|
||||
'https://cdn.example.test/hello',
|
||||
dest,
|
||||
undefined,
|
||||
fakeFetchWith(body) as never,
|
||||
{ idleTimeoutMs: 50 },
|
||||
);
|
||||
|
||||
expect(received).toBe(11);
|
||||
expect(await readFile(dest, 'utf-8')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
500
packages/agent-core-v2/test/app/capability/kimiCu.test.ts
Normal file
500
packages/agent-core-v2/test/app/capability/kimiCu.test.ts
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
/**
|
||||
* `kimi-cu` capability entry — permission-status parsing, app bundle
|
||||
* version reading, layered detect (plugin / app / service / permissions),
|
||||
* and platform gating. Host effects are faked (temp app bundle, scripted
|
||||
* host processes, fake plugins).
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile, chmod } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
|
||||
import type { CapabilityEntryContext } from '#/app/capability/entries/context';
|
||||
import {
|
||||
createKimiCuEntry,
|
||||
elevatedDittoScript,
|
||||
parsePermissionStatus,
|
||||
readAppBundleVersion,
|
||||
} from '#/app/capability/entries/kimiCu';
|
||||
|
||||
function fakeProc(code: number, stdout = '', stderr = ''): IHostProcess {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
pid: 1234,
|
||||
exitCode: code,
|
||||
stdin: new Writable({
|
||||
write: (_c, _e, cb) => {
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
stdout: Readable.from([stdout]),
|
||||
stderr: Readable.from([stderr]),
|
||||
wait: () => Promise.resolve(code),
|
||||
kill: () => Promise.resolve(),
|
||||
dispose: () => undefined,
|
||||
} as IHostProcess;
|
||||
}
|
||||
|
||||
function fakeHostProcess(
|
||||
script: Array<{ match: string; code: number; stdout?: string; stderr?: string; hang?: boolean }>,
|
||||
): { service: IHostProcessService; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const service: IHostProcessService = {
|
||||
_serviceBrand: undefined,
|
||||
spawn: (command: string, args: readonly string[] = []) => {
|
||||
const key = `${command} ${args.join(' ')}`;
|
||||
calls.push(key);
|
||||
const hit = script.find((s) => key.includes(s.match));
|
||||
if (hit?.hang === true) {
|
||||
return Promise.resolve({
|
||||
_serviceBrand: undefined,
|
||||
pid: 1234,
|
||||
exitCode: null,
|
||||
stdin: new Writable({
|
||||
write: (_c, _e, cb) => {
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
stdout: Readable.from(['']),
|
||||
stderr: Readable.from(['']),
|
||||
// Never settles — the caller's own timeout must fire.
|
||||
wait: () => new Promise<number>(() => {}),
|
||||
kill: () => Promise.resolve(),
|
||||
dispose: () => undefined,
|
||||
} as IHostProcess);
|
||||
}
|
||||
return Promise.resolve(fakeProc(hit?.code ?? 0, hit?.stdout ?? '', hit?.stderr ?? ''));
|
||||
},
|
||||
} as IHostProcessService;
|
||||
return { service, calls };
|
||||
}
|
||||
|
||||
function fakePlugins(
|
||||
installed: Array<{ id: string; enabled: boolean; state: string; version?: string; enabledMcp?: number }>,
|
||||
): {
|
||||
service: IPluginService;
|
||||
installs: string[];
|
||||
enabledCalls: Array<{ id: string; enabled: boolean }>;
|
||||
mcpEnabledCalls: Array<{ id: string; server: string; enabled: boolean }>;
|
||||
} {
|
||||
const installs: string[] = [];
|
||||
const enabledCalls: Array<{ id: string; enabled: boolean }> = [];
|
||||
const mcpEnabledCalls: Array<{ id: string; server: string; enabled: boolean }> = [];
|
||||
const service = {
|
||||
listPlugins: () =>
|
||||
Promise.resolve(
|
||||
installed.map((p) => ({
|
||||
id: p.id,
|
||||
displayName: p.id,
|
||||
version: p.version,
|
||||
enabled: p.enabled,
|
||||
state: p.state,
|
||||
skillCount: 1,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: p.enabledMcp ?? 1,
|
||||
hookCount: 0,
|
||||
commandCount: 0,
|
||||
hasErrors: false,
|
||||
source: 'zip-url',
|
||||
})),
|
||||
),
|
||||
getPluginInfo: (input: { id: string }) => {
|
||||
const existing = installed.find((p) => p.id === input.id);
|
||||
return Promise.resolve({
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'mac',
|
||||
runtimeName: 'mac',
|
||||
enabled: (existing?.enabledMcp ?? 1) === 1,
|
||||
transport: 'stdio',
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
},
|
||||
installPlugin: (input: { source: string }) => {
|
||||
installs.push(input.source);
|
||||
// Upsert semantics of the real manager: a new id installs enabled, an
|
||||
// existing record keeps its (possibly disabled) enabled flag.
|
||||
const existing = installed.find((p) => p.id === 'kimi-cu');
|
||||
if (existing === undefined) {
|
||||
installed.push({ id: 'kimi-cu', enabled: true, state: 'ok' });
|
||||
return Promise.resolve({ enabled: true, mcpServerCount: 1, enabledMcpServerCount: 1 } as never);
|
||||
}
|
||||
existing.state = 'ok';
|
||||
return Promise.resolve({
|
||||
enabled: existing.enabled,
|
||||
mcpServerCount: 1,
|
||||
enabledMcpServerCount: existing.enabledMcp ?? 1,
|
||||
} as never);
|
||||
},
|
||||
setPluginEnabled: (input: { id: string; enabled: boolean }) => {
|
||||
enabledCalls.push(input);
|
||||
const existing = installed.find((p) => p.id === input.id);
|
||||
if (existing !== undefined) existing.enabled = input.enabled;
|
||||
return Promise.resolve();
|
||||
},
|
||||
setPluginMcpServerEnabled: (input: { id: string; server: string; enabled: boolean }) => {
|
||||
mcpEnabledCalls.push(input);
|
||||
const existing = installed.find((p) => p.id === input.id);
|
||||
if (existing !== undefined) existing.enabledMcp = input.enabled ? 1 : 0;
|
||||
return Promise.resolve();
|
||||
},
|
||||
} as unknown as IPluginService;
|
||||
return { service, installs, enabledCalls, mcpEnabledCalls };
|
||||
}
|
||||
|
||||
describe('parsePermissionStatus', () => {
|
||||
it('parses the machine-readable request-permissions output', () => {
|
||||
expect(parsePermissionStatus('permissions: accessibility=true screenRecording=true')).toEqual({
|
||||
accessibility: true,
|
||||
screenRecording: true,
|
||||
});
|
||||
expect(parsePermissionStatus('permissions: accessibility=true screenRecording=false')).toEqual({
|
||||
accessibility: true,
|
||||
screenRecording: false,
|
||||
});
|
||||
expect(parsePermissionStatus('permissionStatus: accessibility=false screenRecording=true')).toEqual({
|
||||
accessibility: false,
|
||||
screenRecording: true,
|
||||
});
|
||||
expect(parsePermissionStatus('unknown command')).toBeUndefined();
|
||||
expect(parsePermissionStatus('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('elevatedDittoScript', () => {
|
||||
it('shell-quotes both paths so spaces and metacharacters stay literal', () => {
|
||||
// The elevated path runs the string through /bin/sh with administrator
|
||||
// privileges: every path must be exactly one literal argument.
|
||||
expect(elevatedDittoScript('/tmp/kimi cu/app', '/Applications/KimiCU.app')).toBe(
|
||||
"/usr/bin/ditto '/tmp/kimi cu/app' '/Applications/KimiCU.app'",
|
||||
);
|
||||
const script = elevatedDittoScript("$(touch /tmp/pwned); echo '", '/Applications/KimiCU.app');
|
||||
expect(script).toBe("/usr/bin/ditto '$(touch /tmp/pwned); echo '\\''' '/Applications/KimiCU.app'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAppBundleVersion', () => {
|
||||
let root: string;
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'kimi-cu-version-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads CFBundleShortVersionString from Info.plist', async () => {
|
||||
const plist = path.join(root, 'Info.plist');
|
||||
await writeFile(
|
||||
plist,
|
||||
`<?xml version="1.0"?><plist><dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.4.18</string>
|
||||
</dict></plist>`,
|
||||
);
|
||||
expect(await readAppBundleVersion(plist)).toBe('0.4.18');
|
||||
});
|
||||
|
||||
it('returns undefined for a missing file', async () => {
|
||||
expect(await readAppBundleVersion(path.join(root, 'nope.plist'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('kimi-cu entry', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'kimi-cu-entry-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fakeAppBundle(): Promise<string> {
|
||||
const applicationsDir = path.join(root, 'Applications');
|
||||
const macosDir = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS');
|
||||
await mkdir(macosDir, { recursive: true });
|
||||
const appBin = path.join(macosDir, 'kimi-cu');
|
||||
await writeFile(appBin, '#!/bin/sh\n');
|
||||
// Real bundles are executable; anything less reads as a broken install.
|
||||
await chmod(appBin, 0o755);
|
||||
await writeFile(
|
||||
path.join(applicationsDir, 'KimiCU.app', 'Contents', 'Info.plist'),
|
||||
'<key>CFBundleShortVersionString</key>\n<string>0.5.4</string>',
|
||||
);
|
||||
return applicationsDir;
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<CapabilityEntryContext> = {}): CapabilityEntryContext {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
kimiHomeDir: path.join(root, 'kimi-home'),
|
||||
userHomeDir: path.join(root, 'user-home'),
|
||||
plugins: fakePlugins([]).service,
|
||||
hostProcess: fakeHostProcess([]).service,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('is supported only on macOS', () => {
|
||||
expect(createKimiCuEntry(makeCtx()).supported).toBe(true);
|
||||
expect(createKimiCuEntry(makeCtx({ platform: 'linux' })).supported).toBe(false);
|
||||
expect(createKimiCuEntry(makeCtx({ platform: 'win32' })).supported).toBe(false);
|
||||
});
|
||||
|
||||
it('detects all four layers with details', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4' }]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'service-status', code: 0, stdout: 'SMAppService status=1 (1=enabled); fallback plist exists=false' },
|
||||
{ match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=false' },
|
||||
]);
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({ applicationsDir, plugins: plugins.service, hostProcess: host.service }),
|
||||
);
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.version).toBe('0.5.4');
|
||||
expect(detected.steps).toEqual([
|
||||
{ id: 'plugin', state: 'ok', detail: '0.5.4' },
|
||||
{ id: 'app', state: 'ok', detail: '0.5.4' },
|
||||
{ id: 'service', state: 'ok' },
|
||||
{ id: 'permissions', state: 'missing', detail: 'screenRecording' },
|
||||
]);
|
||||
expect(host.calls.some((call) => call.endsWith(' xpc-ping'))).toBe(true);
|
||||
expect(host.calls.some((call) => call.includes('request-permissions'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports missing layers on a bare machine', async () => {
|
||||
const entry = createKimiCuEntry(makeCtx({ applicationsDir: path.join(root, 'Applications') }));
|
||||
const detected = await entry.detect();
|
||||
expect(detected.version).toBeUndefined();
|
||||
expect(detected.steps.map((s) => [s.id, s.state])).toEqual([
|
||||
['plugin', 'missing'],
|
||||
['app', 'missing'],
|
||||
['service', 'missing'],
|
||||
['permissions', 'missing'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects install on non-macOS before any side effect', async () => {
|
||||
const plugins = fakePlugins([]);
|
||||
const entry = createKimiCuEntry(makeCtx({ platform: 'linux', plugins: plugins.service }));
|
||||
await expect(entry.install(() => {})).rejects.toThrow(/only supported on macOS/);
|
||||
expect(plugins.installs).toEqual([]);
|
||||
});
|
||||
|
||||
it('resumes a partial install without repeating completed runtime layers', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'service-status', code: 0, stdout: 'SMAppService status=1' },
|
||||
{ match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' },
|
||||
]);
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({
|
||||
applicationsDir,
|
||||
plugins: plugins.service,
|
||||
hostProcess: host.service,
|
||||
fetchImpl: (() => Promise.reject(new Error('download should be skipped'))) as never,
|
||||
}),
|
||||
);
|
||||
const reports: string[] = [];
|
||||
|
||||
await entry.install((step) => reports.push(step));
|
||||
|
||||
expect(plugins.installs).toHaveLength(1);
|
||||
expect(reports).toEqual(['plugin']);
|
||||
expect(host.calls.every((call) => call.includes('service-status') || call.includes('xpc-ping'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks probe steps failed instead of throwing when the binary is wedged', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'service-status', code: 0, hang: true },
|
||||
{ match: 'xpc-ping', code: 0, hang: true },
|
||||
]);
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({
|
||||
applicationsDir,
|
||||
plugins: plugins.service,
|
||||
hostProcess: host.service,
|
||||
detectProbeTimeoutMs: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.steps.find((s) => s.id === 'service')).toEqual({
|
||||
id: 'service',
|
||||
state: 'failed',
|
||||
detail: expect.stringContaining('timed out'),
|
||||
});
|
||||
expect(detected.steps.find((s) => s.id === 'permissions')).toEqual({
|
||||
id: 'permissions',
|
||||
state: 'failed',
|
||||
detail: expect.stringContaining('timed out'),
|
||||
});
|
||||
|
||||
// The install path uses the same detect — it must still repair the
|
||||
// wiring layer instead of dying on the wedged probes. The service
|
||||
// itself legitimately stays broken and reports the clean error.
|
||||
await expect(entry.install(() => {})).rejects.toThrow(/not running after install/);
|
||||
expect(plugins.installs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-enables a previously disabled wiring plugin during setup', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([{ id: 'kimi-cu', enabled: false, state: 'ok', version: '0.5.4' }]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'service-status', code: 0, stdout: 'SMAppService status=1' },
|
||||
{ match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' },
|
||||
]);
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({ applicationsDir, plugins: plugins.service, hostProcess: host.service }),
|
||||
);
|
||||
|
||||
// Everything else ready, only the disabled wiring blocks readiness —
|
||||
// setup must not strand the capability at partial by leaving it off.
|
||||
await entry.install(() => {});
|
||||
expect(plugins.enabledCalls).toEqual([{ id: 'kimi-cu', enabled: true }]);
|
||||
});
|
||||
|
||||
it('continues the replacement when the old-binary cleanup hangs', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4' }]);
|
||||
const host = fakeHostProcess([
|
||||
// The wedged old binary makes `kimi-cu uninstall` hang — cleanup must
|
||||
// swallow the timeout (`|| true` semantics) instead of killing the
|
||||
// reinstall before ditto can replace the app.
|
||||
{ match: 'uninstall', code: 0, hang: true },
|
||||
{ match: 'service-status', code: 0, stdout: 'SMAppService status=1' },
|
||||
{ match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' },
|
||||
]);
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '3' },
|
||||
}),
|
||||
)) as never;
|
||||
// The fake ditto must materialize the copied binary: moveAppIntoPlace
|
||||
// rm's the old bundle first, and the post-install service check probes
|
||||
// the new one.
|
||||
const appBin = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS', 'kimi-cu');
|
||||
const hostProcess = {
|
||||
spawn: async (command: string, args: readonly string[] = []) => {
|
||||
const proc = await host.service.spawn(command, args);
|
||||
if (command === 'ditto' && String(args.at(-1)).includes('KimiCU.app')) {
|
||||
await mkdir(path.dirname(appBin), { recursive: true });
|
||||
await writeFile(appBin, '#!/bin/sh\n');
|
||||
await chmod(appBin, 0o755);
|
||||
}
|
||||
return proc;
|
||||
},
|
||||
} as IHostProcessService;
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({
|
||||
applicationsDir,
|
||||
plugins: plugins.service,
|
||||
hostProcess,
|
||||
fetchImpl,
|
||||
commandTimeoutMs: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fully ready → explicit reinstall exercises the cleanup path.
|
||||
await entry.install(() => {});
|
||||
expect(host.calls.some((call) => call.includes('ditto'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the plugin layer missing when its MCP server is disabled', async () => {
|
||||
const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4', enabledMcp: 0 }]);
|
||||
const entry = createKimiCuEntry(makeCtx({ plugins: plugins.service }));
|
||||
|
||||
// The plugin toggle is on but the stdio MCP wrapper is off: readiness
|
||||
// must not claim ready — new sessions would get no Computer Use tools.
|
||||
const detected = await entry.detect();
|
||||
expect(detected.steps.find((s) => s.id === 'plugin')).toEqual({
|
||||
id: 'plugin',
|
||||
state: 'missing',
|
||||
detail: 'mcp 0/1 enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-enables disabled MCP servers during setup', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4', enabledMcp: 0 }]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'service-status', code: 0, stdout: 'SMAppService status=1' },
|
||||
{ match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' },
|
||||
]);
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({ applicationsDir, plugins: plugins.service, hostProcess: host.service }),
|
||||
);
|
||||
|
||||
// Upsert preserves the per-server disabled state, so setup repairs it
|
||||
// explicitly — the plugin toggle alone is not enough.
|
||||
await entry.install(() => {});
|
||||
expect(plugins.mcpEnabledCalls).toEqual([{ id: 'kimi-cu', server: 'mac', enabled: true }]);
|
||||
});
|
||||
|
||||
it('never stops the old service when the downloaded archive is corrupt', async () => {
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess([
|
||||
{ match: 'ditto -x -k', code: 1, stderr: 'ditto: Not a zip file' },
|
||||
]);
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response(new TextEncoder().encode('<html>captive portal</html>'), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '26' },
|
||||
}),
|
||||
)) as never;
|
||||
const entry = createKimiCuEntry(
|
||||
makeCtx({
|
||||
applicationsDir: path.join(root, 'Applications'),
|
||||
plugins: plugins.service,
|
||||
hostProcess: host.service,
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
|
||||
// A corrupt archive must fail before any teardown — a failed update
|
||||
// never breaks a previously working setup.
|
||||
await expect(entry.install(() => {})).rejects.toThrow(/Failed to unzip/);
|
||||
expect(host.calls.some((call) => call.includes('uninstall'))).toBe(false);
|
||||
expect(host.calls.some((call) => call.includes('bootout'))).toBe(false);
|
||||
expect(host.calls.some((call) => call.includes('pkill'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a bundle missing its Info.plist as a broken install', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
// Executable binary but the bundle metadata is gone (partial copy).
|
||||
await rm(path.join(applicationsDir, 'KimiCU.app', 'Contents', 'Info.plist'));
|
||||
const entry = createKimiCuEntry(makeCtx({ applicationsDir }));
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.steps.find((s) => s.id === 'app')?.state).toBe('missing');
|
||||
});
|
||||
|
||||
it('reads a non-executable leftover app binary as a broken install', async () => {
|
||||
const applicationsDir = await fakeAppBundle();
|
||||
// An interrupted ditto leaves the binary present but not executable.
|
||||
await chmod(path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS', 'kimi-cu'), 0o644);
|
||||
const entry = createKimiCuEntry(makeCtx({ applicationsDir }));
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.steps.find((s) => s.id === 'app')).toEqual({
|
||||
id: 'app',
|
||||
state: 'missing',
|
||||
detail: 'not executable',
|
||||
});
|
||||
});
|
||||
});
|
||||
391
packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts
Normal file
391
packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* `kimi-webbridge` capability entry — platform asset mapping, layered
|
||||
* detect, and the idempotent install flow (download → start-if-down →
|
||||
* plugin wiring). All host effects are faked
|
||||
* (temp dirs, scripted fetch, scripted host processes, fake plugins).
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, readdir, rm, mkdir, writeFile, access, chmod, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
|
||||
import {
|
||||
__kimiWebbridgeInternals,
|
||||
createKimiWebbridgeEntry,
|
||||
} from '#/app/capability/entries/kimiWebbridge';
|
||||
import type { CapabilityEntryContext } from '#/app/capability/entries/context';
|
||||
|
||||
const DAEMON_BASE = 'http://127.0.0.1:10086';
|
||||
|
||||
function fakeProc(code: number, stdout = '', stderr = ''): IHostProcess {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
pid: 1234,
|
||||
exitCode: code,
|
||||
stdin: new Writable({
|
||||
write: (_c, _e, cb) => {
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
stdout: Readable.from([stdout]),
|
||||
stderr: Readable.from([stderr]),
|
||||
wait: () => Promise.resolve(code),
|
||||
kill: () => Promise.resolve(),
|
||||
dispose: () => undefined,
|
||||
} as IHostProcess;
|
||||
}
|
||||
|
||||
interface SpawnCall {
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
}
|
||||
|
||||
function fakeHostProcess(script?: Array<{ match: string; code: number; stdout?: string; stderr?: string }>): {
|
||||
service: IHostProcessService;
|
||||
calls: SpawnCall[];
|
||||
} {
|
||||
const calls: SpawnCall[] = [];
|
||||
const service: IHostProcessService = {
|
||||
_serviceBrand: undefined,
|
||||
spawn: (command: string, args: readonly string[] = []) => {
|
||||
calls.push({ command, args });
|
||||
const key = `${command} ${args.join(' ')}`;
|
||||
const hit = script?.find((s) => key.includes(s.match));
|
||||
return Promise.resolve(fakeProc(hit?.code ?? 0, hit?.stdout ?? '', hit?.stderr ?? ''));
|
||||
},
|
||||
} as IHostProcessService;
|
||||
return { service, calls };
|
||||
}
|
||||
|
||||
function fakePlugins(installed: Array<{ id: string; enabled: boolean; state: string; version?: string }>): {
|
||||
service: IPluginService;
|
||||
installs: string[];
|
||||
enabledCalls: Array<{ id: string; enabled: boolean }>;
|
||||
} {
|
||||
const installs: string[] = [];
|
||||
const enabledCalls: Array<{ id: string; enabled: boolean }> = [];
|
||||
const service = {
|
||||
listPlugins: () =>
|
||||
Promise.resolve(
|
||||
installed.map((p) => ({
|
||||
id: p.id,
|
||||
displayName: p.id,
|
||||
version: p.version,
|
||||
enabled: p.enabled,
|
||||
state: p.state,
|
||||
skillCount: 1,
|
||||
mcpServerCount: 0,
|
||||
enabledMcpServerCount: 0,
|
||||
hookCount: 0,
|
||||
commandCount: 0,
|
||||
hasErrors: false,
|
||||
source: 'zip-url',
|
||||
})),
|
||||
),
|
||||
installPlugin: (input: { source: string }) => {
|
||||
installs.push(input.source);
|
||||
// Upsert semantics of the real manager: a new id installs enabled, an
|
||||
// existing record keeps its (possibly disabled) enabled flag.
|
||||
const existing = installed.find((p) => p.id === 'kimi-webbridge');
|
||||
if (existing === undefined) {
|
||||
installed.push({ id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' });
|
||||
return Promise.resolve({ enabled: true } as never);
|
||||
}
|
||||
existing.state = 'ok';
|
||||
existing.version = '1.11.3';
|
||||
return Promise.resolve({ enabled: existing.enabled } as never);
|
||||
},
|
||||
setPluginEnabled: (input: { id: string; enabled: boolean }) => {
|
||||
enabledCalls.push(input);
|
||||
const existing = installed.find((p) => p.id === input.id);
|
||||
if (existing !== undefined) existing.enabled = input.enabled;
|
||||
return Promise.resolve();
|
||||
},
|
||||
} as unknown as IPluginService;
|
||||
return { service, installs, enabledCalls };
|
||||
}
|
||||
|
||||
/** Scripted fetch: answers daemon /status and CDN binary downloads. */
|
||||
function fakeFetch(opts: {
|
||||
statusSequence?: Array<object | 'error'>;
|
||||
binary?: Uint8Array;
|
||||
}): { fetchImpl: typeof fetch } {
|
||||
let statusCalls = 0;
|
||||
const fetchImpl = (async (url: string | URL): Promise<Response> => {
|
||||
const u = String(url);
|
||||
if (u === `${DAEMON_BASE}/status`) {
|
||||
const step = opts.statusSequence?.[Math.min(statusCalls, (opts.statusSequence?.length ?? 1) - 1)];
|
||||
statusCalls += 1;
|
||||
if (step === 'error' || step === undefined) throw new Error('connection refused');
|
||||
return new Response(JSON.stringify(step), { status: 200 });
|
||||
}
|
||||
if (u.includes('cdn.kimi.com/webbridge/')) {
|
||||
const bytes = opts.binary ?? new Uint8Array([1, 2, 3, 4]);
|
||||
return new Response(bytes, {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(bytes.length) },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetchImpl };
|
||||
}
|
||||
|
||||
describe('kimi-webbridge entry', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'kimi-webbridge-entry-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeCtx(overrides: Partial<CapabilityEntryContext> = {}): CapabilityEntryContext {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
kimiHomeDir: path.join(root, 'kimi-home'),
|
||||
userHomeDir: path.join(root, 'user-home'),
|
||||
plugins: fakePlugins([]).service,
|
||||
hostProcess: fakeHostProcess().service,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('maps platforms to CDN asset names', () => {
|
||||
const { binaryAssetName } = __kimiWebbridgeInternals;
|
||||
expect(binaryAssetName('darwin', 'arm64')).toBe('kimi-webbridge-darwin-arm64');
|
||||
expect(binaryAssetName('darwin', 'x64')).toBe('kimi-webbridge-darwin-amd64');
|
||||
expect(binaryAssetName('linux', 'arm64')).toBe('kimi-webbridge-linux-arm64');
|
||||
expect(binaryAssetName('linux', 'x64')).toBe('kimi-webbridge-linux-amd64');
|
||||
expect(binaryAssetName('win32', 'x64')).toBe('kimi-webbridge-windows-amd64.exe');
|
||||
expect(binaryAssetName('win32', 'arm64')).toBeUndefined();
|
||||
expect(binaryAssetName('freebsd', 'x64')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('EXDEV fallback replaces the destination without opening it for write', async () => {
|
||||
const { renameAcrossDevicesFallback } = __kimiWebbridgeInternals;
|
||||
const from = path.join(root, 'staging', 'kimi-webbridge');
|
||||
const to = path.join(root, 'bin', 'kimi-webbridge');
|
||||
await mkdir(path.dirname(from), { recursive: true });
|
||||
await mkdir(path.dirname(to), { recursive: true });
|
||||
await writeFile(from, 'new');
|
||||
await writeFile(to, 'old-running');
|
||||
|
||||
// Stage-then-rename on the target filesystem: the live destination is
|
||||
// replaced atomically (never opened for write — ETXTBSY-safe), the
|
||||
// source is removed, and no sibling temp is left behind.
|
||||
await renameAcrossDevicesFallback(from, to);
|
||||
|
||||
expect(await readFile(to, 'utf-8')).toBe('new');
|
||||
await expect(access(from)).rejects.toThrow();
|
||||
const binEntries = await readdir(path.dirname(to));
|
||||
expect(binEntries.filter((entry) => entry.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('is unsupported on unknown platforms', () => {
|
||||
const entry = createKimiWebbridgeEntry(makeCtx({ platform: 'freebsd' }));
|
||||
expect(entry.supported).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a fully installed daemon with extension as soft gate', async () => {
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true });
|
||||
const binPath = path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge');
|
||||
await writeFile(binPath, 'bin');
|
||||
await chmod(binPath, 0o755);
|
||||
const plugins = fakePlugins([{ id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' }]);
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: false }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(makeCtx({ plugins: plugins.service, fetchImpl }));
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.version).toBe('v1.11.3');
|
||||
expect(detected.steps).toEqual([
|
||||
{ id: 'daemon-binary', state: 'ok' },
|
||||
{ id: 'daemon', state: 'ok', detail: 'v1.11.3' },
|
||||
{ id: 'skill', state: 'ok', detail: '1.11.3' },
|
||||
{ id: 'extension', state: 'missing', optional: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports user skill shadows for manual cleanup without deleting them', async () => {
|
||||
const kimiHome = path.join(root, 'kimi-home');
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await mkdir(path.join(kimiHome, 'skills', 'kimi-webbridge'), { recursive: true });
|
||||
await writeFile(path.join(kimiHome, 'skills', 'kimi-webbridge', 'SKILL.md'), 'old');
|
||||
await mkdir(path.join(userHome, '.agents', 'skills', 'kimi-webbridge'), { recursive: true });
|
||||
await writeFile(path.join(userHome, '.agents', 'skills', 'kimi-webbridge', 'SKILL.md'), 'old');
|
||||
const plugins = fakePlugins([{ id: 'kimi-webbridge', enabled: true, state: 'ok' }]);
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(makeCtx({ plugins: plugins.service, fetchImpl }));
|
||||
|
||||
const detected = await entry.detect();
|
||||
|
||||
expect(detected.steps.find((step) => step.id === 'skill-shadow')).toEqual({
|
||||
id: 'skill-shadow',
|
||||
state: 'failed',
|
||||
detail: `${path.join(kimiHome, 'skills', 'kimi-webbridge')}, ${path.join(userHome, '.agents', 'skills', 'kimi-webbridge')}`,
|
||||
optional: true,
|
||||
});
|
||||
await access(path.join(kimiHome, 'skills', 'kimi-webbridge', 'SKILL.md'));
|
||||
await access(path.join(userHome, '.agents', 'skills', 'kimi-webbridge', 'SKILL.md'));
|
||||
});
|
||||
|
||||
it('installs end-to-end: download, start-if-down, and plugin wiring', async () => {
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess();
|
||||
// First status poll (before start): down. Subsequent polls: up.
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [
|
||||
{ running: false },
|
||||
{ running: false },
|
||||
{ running: true, version: 'v1.11.3', extension_connected: true },
|
||||
],
|
||||
});
|
||||
const reports: Array<[string, number | undefined]> = [];
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
|
||||
);
|
||||
|
||||
await entry.install((step, percent) => reports.push([step, percent]));
|
||||
|
||||
// Binary downloaded into place and made executable.
|
||||
const binPath = path.join(root, 'user-home', '.kimi-webbridge', 'bin', 'kimi-webbridge');
|
||||
await access(binPath);
|
||||
// Daemon started exactly once (start-if-down).
|
||||
expect(host.calls.map((c) => `${c.command} ${c.args.join(' ')}`)).toEqual([`${binPath} start`]);
|
||||
// Plugin wiring installed from the official CDN zip.
|
||||
expect(plugins.installs).toEqual([
|
||||
'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip',
|
||||
]);
|
||||
// Progress reported download steps.
|
||||
expect(reports[0]).toEqual(['download', 0]);
|
||||
expect(reports.some(([step]) => step === 'daemon')).toBe(true);
|
||||
expect(reports.some(([step]) => step === 'skill')).toBe(true);
|
||||
});
|
||||
|
||||
it('never starts the daemon when one is already running (coexistence)', async () => {
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess();
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
|
||||
);
|
||||
|
||||
await entry.install(() => {});
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reinstalls the latest binary and plugin for a ready capability', async () => {
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true });
|
||||
const binPath = path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge');
|
||||
await writeFile(binPath, 'old-bin');
|
||||
await chmod(binPath, 0o755);
|
||||
const plugins = fakePlugins([{ id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' }]);
|
||||
const host = fakeHostProcess();
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
binary: new TextEncoder().encode('latest-bin'),
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
|
||||
);
|
||||
const reports: string[] = [];
|
||||
|
||||
await entry.install((step) => reports.push(step));
|
||||
|
||||
expect(reports[0]).toBe('download');
|
||||
expect(reports).toContain('skill');
|
||||
expect(host.calls).toEqual([]);
|
||||
expect(plugins.installs).toEqual([
|
||||
'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip',
|
||||
]);
|
||||
expect(await readFile(binPath, 'utf8')).toBe('latest-bin');
|
||||
});
|
||||
|
||||
it('resumes partial setup without repeating completed runtime layers', async () => {
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true });
|
||||
const binPath = path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge');
|
||||
await writeFile(binPath, 'bin');
|
||||
await chmod(binPath, 0o755);
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess();
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
|
||||
);
|
||||
const reports: string[] = [];
|
||||
|
||||
await entry.install((step) => reports.push(step));
|
||||
|
||||
expect(reports).toEqual(['skill']);
|
||||
expect(host.calls).toEqual([]);
|
||||
expect(plugins.installs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects install on unsupported platforms before any side effect', async () => {
|
||||
const plugins = fakePlugins([]);
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ platform: 'freebsd', plugins: plugins.service }),
|
||||
);
|
||||
await expect(entry.install(() => {})).rejects.toThrow(/not supported/);
|
||||
expect(plugins.installs).toEqual([]);
|
||||
});
|
||||
it('treats a non-executable leftover binary as missing and re-downloads it', async () => {
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true });
|
||||
// An install interrupted between rename and chmod leaves this behind.
|
||||
const binPath = path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge');
|
||||
await writeFile(binPath, 'stale');
|
||||
await chmod(binPath, 0o644);
|
||||
const plugins = fakePlugins([]);
|
||||
const host = fakeHostProcess();
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(
|
||||
makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }),
|
||||
);
|
||||
|
||||
const detected = await entry.detect();
|
||||
expect(detected.steps.find((step) => step.id === 'daemon-binary')).toEqual({
|
||||
id: 'daemon-binary',
|
||||
state: 'missing',
|
||||
detail: 'not executable',
|
||||
});
|
||||
|
||||
await entry.install(() => {});
|
||||
expect((await stat(binPath)).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('re-enables a previously disabled wiring plugin during setup', async () => {
|
||||
const plugins = fakePlugins([{ id: 'kimi-webbridge', enabled: false, state: 'ok', version: '1.11.3' }]);
|
||||
const { fetchImpl } = fakeFetch({
|
||||
statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }],
|
||||
});
|
||||
const entry = createKimiWebbridgeEntry(makeCtx({ plugins: plugins.service, fetchImpl }));
|
||||
|
||||
// installPlugin preserves the disabled flag, but setup must not strand
|
||||
// the capability at partial by leaving the wiring off.
|
||||
await entry.install(() => {});
|
||||
expect(plugins.enabledCalls).toEqual([{ id: 'kimi-webbridge', enabled: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
39
packages/klient/src/contract/global/capabilities.ts
Normal file
39
packages/klient/src/contract/global/capabilities.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* `capabilityService` — built-in product capability readiness and install
|
||||
* orchestration. Mirrors `agent-core-v2/app/capability/types.ts`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ServiceContract } from '../types.js';
|
||||
|
||||
export const capabilityStepSchema = z.object({
|
||||
id: z.string(),
|
||||
state: z.enum(['ok', 'missing', 'failed']),
|
||||
detail: z.string().optional(),
|
||||
optional: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const capabilityInstallProgressSchema = z.object({
|
||||
running: z.boolean(),
|
||||
step: z.string().optional(),
|
||||
percent: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const capabilityStatusSchema = z.object({
|
||||
id: z.enum(['kimi-cu', 'kimi-webbridge']),
|
||||
displayName: z.string(),
|
||||
description: z.string(),
|
||||
supported: z.boolean(),
|
||||
state: z.enum(['not_installed', 'partial', 'ready', 'unsupported']),
|
||||
version: z.string().optional(),
|
||||
steps: z.array(capabilityStepSchema),
|
||||
install: capabilityInstallProgressSchema,
|
||||
});
|
||||
|
||||
export const capabilitiesContract = {
|
||||
listCapabilities: { input: z.tuple([]), output: z.array(capabilityStatusSchema) },
|
||||
getCapability: { input: z.tuple([z.string()]), output: capabilityStatusSchema },
|
||||
installCapability: { input: z.tuple([z.string()]), output: capabilityStatusSchema },
|
||||
} satisfies ServiceContract;
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
agentUsageContract,
|
||||
} from './agent/services.js';
|
||||
import { authContract, authSummaryContract } from './global/auth.js';
|
||||
import { capabilitiesContract } from './global/capabilities.js';
|
||||
import { catalogContract } from './global/catalog.js';
|
||||
import { providerDiscoveryContract } from './global/providerDiscovery.js';
|
||||
import { configContract } from './global/config.js';
|
||||
|
|
@ -53,6 +54,7 @@ export const globalContract: KlientContract = {
|
|||
authSummaryService: authSummaryContract,
|
||||
flagService: flagsContract,
|
||||
pluginService: pluginsContract,
|
||||
capabilityService: capabilitiesContract,
|
||||
hostFolderBrowser: hostFsContract,
|
||||
bootstrapService: envContract,
|
||||
// workspace scope (+ the app-registered handler registry)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import type {
|
|||
PluginUpdateStatus,
|
||||
ReloadSummary,
|
||||
} from '@moonshot-ai/agent-core-v2/app/plugin/types';
|
||||
import type { CapabilityStatus } from '@moonshot-ai/agent-core-v2/app/capability/types';
|
||||
|
||||
/** Low-level caller the klient factory builds: routes + validates one service call. */
|
||||
export type Caller = (service: string, method: string, args: unknown[]) => Promise<unknown>;
|
||||
|
|
@ -197,6 +198,12 @@ export interface GlobalFlagsFacade {
|
|||
snapshot(): Promise<Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface GlobalCapabilitiesFacade {
|
||||
list(): Promise<readonly CapabilityStatus[]>;
|
||||
get(id: string): Promise<CapabilityStatus>;
|
||||
install(id: string): Promise<CapabilityStatus>;
|
||||
}
|
||||
|
||||
export interface GlobalPluginsFacade {
|
||||
list(): Promise<readonly PluginSummary[]>;
|
||||
info(id: string): Promise<PluginInfo>;
|
||||
|
|
@ -238,6 +245,7 @@ export interface GlobalFacade {
|
|||
readonly auth: GlobalAuthFacade;
|
||||
readonly flags: GlobalFlagsFacade;
|
||||
readonly plugins: GlobalPluginsFacade;
|
||||
readonly capabilities: GlobalCapabilitiesFacade;
|
||||
readonly hostFs: GlobalHostFsFacade;
|
||||
env(): Promise<KlientEnvInfo>;
|
||||
}
|
||||
|
|
@ -448,6 +456,13 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr
|
|||
call('pluginService', 'listPluginCommands', []) as Promise<readonly PluginCommandDef[]>,
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
list: () => call('capabilityService', 'listCapabilities', []) as Promise<readonly CapabilityStatus[]>,
|
||||
get: (id) => call('capabilityService', 'getCapability', [id]) as Promise<CapabilityStatus>,
|
||||
install: (id) =>
|
||||
call('capabilityService', 'installCapability', [id]) as Promise<CapabilityStatus>,
|
||||
},
|
||||
|
||||
hostFs: {
|
||||
browse: (absPath) =>
|
||||
call('hostFolderBrowser', 'browse', [absPath]) as Promise<FsBrowseResponse>,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@moonshot-ai/agent-core-v2/app/auth/auth';
|
||||
import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag';
|
||||
import { IPluginService } from '@moonshot-ai/agent-core-v2/app/plugin/plugin';
|
||||
import { ICapabilityService } from '@moonshot-ai/agent-core-v2/app/capability/capability';
|
||||
import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap';
|
||||
import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event';
|
||||
import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
|
||||
|
|
@ -52,6 +53,7 @@ export const serviceTokens: Readonly<Record<string, ServiceIdentifier<unknown>>>
|
|||
authSummaryService: IAuthSummaryService,
|
||||
flagService: IFlagService,
|
||||
pluginService: IPluginService,
|
||||
capabilityService: ICapabilityService,
|
||||
hostFolderBrowser: IHostFolderBrowser,
|
||||
bootstrapService: IBootstrapService,
|
||||
workspaceLifecycleService: IWorkspaceLifecycleService,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ import type {
|
|||
ConfigInspectValue,
|
||||
ConfigTarget,
|
||||
} from '@moonshot-ai/agent-core-v2/app/config/config';
|
||||
import type {
|
||||
CapabilityInstallProgress,
|
||||
CapabilityStatus,
|
||||
CapabilityStep,
|
||||
} from '@moonshot-ai/agent-core-v2/app/capability/types';
|
||||
import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag';
|
||||
import type {
|
||||
FsBrowseResponse,
|
||||
|
|
@ -239,6 +244,11 @@ import {
|
|||
configInspectValueSchema,
|
||||
configTargetSchema,
|
||||
} from '../src/contract/global/config.js';
|
||||
import {
|
||||
capabilityInstallProgressSchema,
|
||||
capabilityStatusSchema,
|
||||
capabilityStepSchema,
|
||||
} from '../src/contract/global/capabilities.js';
|
||||
import {
|
||||
modelCatalogItemSchema,
|
||||
providerCatalogItemSchema,
|
||||
|
|
@ -326,6 +336,14 @@ const _configInspectValue: AssertEngineToWire<typeof configInspectValueSchema, C
|
|||
const _configDiagnostic: AssertWire<typeof configDiagnosticSchema, ConfigDiagnostic> = true;
|
||||
const _configTarget: AssertWire<typeof configTargetSchema, ConfigTargetValues> = true;
|
||||
|
||||
// capabilities.ts
|
||||
const _capabilityStep: AssertWire<typeof capabilityStepSchema, CapabilityStep> = true;
|
||||
const _capabilityInstallProgress: AssertWire<
|
||||
typeof capabilityInstallProgressSchema,
|
||||
CapabilityInstallProgress
|
||||
> = true;
|
||||
const _capabilityStatus: AssertWire<typeof capabilityStatusSchema, CapabilityStatus> = true;
|
||||
|
||||
// providers.ts
|
||||
const _providerConfig: AssertWire<typeof providerConfigSchema, ProviderConfig> = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,32 @@ describe('facade routing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('routes capability calls through the registered app service contract', async () => {
|
||||
const channel = new FakeChannel();
|
||||
const klient = createKlientFromChannel(channel);
|
||||
const status = {
|
||||
id: 'kimi-cu',
|
||||
displayName: 'Kimi Computer Use',
|
||||
description: 'Background GUI automation',
|
||||
supported: true,
|
||||
state: 'partial',
|
||||
steps: [{ id: 'permissions', state: 'missing' }],
|
||||
install: { running: false },
|
||||
};
|
||||
channel.result = [status];
|
||||
|
||||
await expect(klient.global.capabilities.list()).resolves.toEqual([status]);
|
||||
channel.result = status;
|
||||
await expect(klient.global.capabilities.get('kimi-cu')).resolves.toEqual(status);
|
||||
await expect(klient.global.capabilities.install('kimi-cu')).resolves.toEqual(status);
|
||||
|
||||
expect(channel.calls).toEqual([
|
||||
{ scope: {}, service: 'capabilityService', method: 'listCapabilities', args: [] },
|
||||
{ scope: {}, service: 'capabilityService', method: 'getCapability', args: ['kimi-cu'] },
|
||||
{ scope: {}, service: 'capabilityService', method: 'installCapability', args: ['kimi-cu'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('env() fans out property reads and merges them', async () => {
|
||||
const channel = new FakeChannel();
|
||||
const klient = createKlientFromChannel(channel);
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ import {
|
|||
import type {
|
||||
AddAdditionalDirInput,
|
||||
AddAdditionalDirResult,
|
||||
CapabilityStatus,
|
||||
BackgroundTaskInfo,
|
||||
CompactOptions,
|
||||
ConfigDiagnostics,
|
||||
|
|
@ -721,6 +722,24 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
return { ...info, manifest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability surface (v2-only): built-in product capabilities (kimi-cu,
|
||||
* kimi-webbridge) with layered readiness and idempotent installs. v1 has
|
||||
* no capability domain, so these stay off the shared base — callers
|
||||
* feature-detect via `in` before use.
|
||||
*/
|
||||
async listCapabilities(): Promise<readonly CapabilityStatus[]> {
|
||||
return this.klient.global.capabilities.list();
|
||||
}
|
||||
|
||||
async getCapability(id: string): Promise<CapabilityStatus> {
|
||||
return this.klient.global.capabilities.get(id);
|
||||
}
|
||||
|
||||
async installCapability(id: string): Promise<CapabilityStatus> {
|
||||
return this.klient.global.capabilities.install(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope gap: v1 answers from the session's creation-time snapshot of the
|
||||
* enabled plugin commands, while the v2 engine only exposes the app-global
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SDKRpcClientBase } from '#/rpc';
|
|||
import type {
|
||||
AddAdditionalDirOptions,
|
||||
AddAdditionalDirResult,
|
||||
CapabilityStatus,
|
||||
BackgroundTaskInfo,
|
||||
CompactOptions,
|
||||
CreateGoalInput,
|
||||
|
|
@ -49,6 +50,30 @@ export interface SessionOptions {
|
|||
readonly onClose?: (() => void | Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability surface (built-in product capabilities: kimi-cu,
|
||||
* kimi-webbridge) exists only on the v2 engine — v1 has no capability
|
||||
* domain. Feature-detect structurally so a Session backed by v1 fails with
|
||||
* a clear message instead of a confusing missing-method error.
|
||||
*/
|
||||
interface CapabilityRpcSurface {
|
||||
listCapabilities(): Promise<readonly CapabilityStatus[]>;
|
||||
getCapability(id: string): Promise<CapabilityStatus>;
|
||||
installCapability(id: string): Promise<CapabilityStatus>;
|
||||
}
|
||||
|
||||
function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface {
|
||||
const candidate = rpc as Partial<CapabilityRpcSurface>;
|
||||
if (
|
||||
typeof candidate.listCapabilities !== 'function' ||
|
||||
typeof candidate.getCapability !== 'function' ||
|
||||
typeof candidate.installCapability !== 'function'
|
||||
) {
|
||||
throw new TypeError('The capability surface is unavailable on this engine (requires v2).');
|
||||
}
|
||||
return candidate as CapabilityRpcSurface;
|
||||
}
|
||||
|
||||
export class Session {
|
||||
readonly id: string;
|
||||
readonly workDir: string;
|
||||
|
|
@ -524,6 +549,27 @@ export class Session {
|
|||
await this.rpc.setPluginEnabled(id, enabled);
|
||||
}
|
||||
|
||||
/** Built-in capabilities with layered readiness (v2 engine only). */
|
||||
async listCapabilities(): Promise<readonly CapabilityStatus[]> {
|
||||
this.ensureOpen();
|
||||
return capabilityRpc(this.rpc).listCapabilities();
|
||||
}
|
||||
|
||||
/** One capability's layered readiness + live install progress. */
|
||||
async getCapability(id: string): Promise<CapabilityStatus> {
|
||||
this.ensureOpen();
|
||||
return capabilityRpc(this.rpc).getCapability(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an idempotent capability install (binary runtime + wiring) in the
|
||||
* background; poll `getCapability` for progress.
|
||||
*/
|
||||
async installCapability(id: string): Promise<CapabilityStatus> {
|
||||
this.ensureOpen();
|
||||
return capabilityRpc(this.rpc).installCapability(id);
|
||||
}
|
||||
|
||||
async setPluginMcpServerEnabled(
|
||||
id: string,
|
||||
server: string,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type JsonObject = { readonly [key: string]: JsonValue };
|
|||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export type { CapabilityStatus } from '@moonshot-ai/agent-core-v2/app/capability/types';
|
||||
|
||||
export type {
|
||||
AgentReplayRecord,
|
||||
AgentBackgroundTaskInfo,
|
||||
|
|
|
|||
24
plugins/official/kimi-webbridge/kimi.plugin.json
Normal file
24
plugins/official/kimi-webbridge/kimi.plugin.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "https://kimi.com/schemas/kimi.plugin.schema.json",
|
||||
"name": "kimi-webbridge",
|
||||
"version": "1.11.3",
|
||||
"description": "Control your real browser (with your login sessions) from Kimi Code via the local Kimi WebBridge daemon — navigate, click, type, read pages, and screenshot any website.",
|
||||
"keywords": [
|
||||
"browser",
|
||||
"webbridge",
|
||||
"cdp",
|
||||
"automation",
|
||||
"web",
|
||||
"scraping"
|
||||
],
|
||||
"author": "Moonshot AI",
|
||||
"license": "Proprietary",
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Kimi WebBridge",
|
||||
"shortDescription": "Control your real browser from Kimi Code — navigate, click, type, and screenshot",
|
||||
"longDescription": "Kimi WebBridge lets AI control the user's real browser — navigate, click, type, read, screenshot, and interact with any website using the user's actual login sessions. The skill talks to the local WebBridge daemon (http://127.0.0.1:10086), which drives Chrome/Edge through a browser extension over CDP. Everything runs locally; login state and page content never leave the device.\n\nRequires the Kimi WebBridge daemon and browser extension: https://www.kimi.com/features/webbridge",
|
||||
"developerName": "Moonshot AI",
|
||||
"websiteURL": "https://www.kimi.com/features/webbridge"
|
||||
}
|
||||
}
|
||||
158
plugins/official/kimi-webbridge/skills/kimi-webbridge/SKILL.md
Normal file
158
plugins/official/kimi-webbridge/skills/kimi-webbridge/SKILL.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
name: kimi-webbridge
|
||||
description: |
|
||||
Kimi WebBridge lets AI control the user's real browser — navigate, click, type, read, screenshot, and interact with any website using the user's actual login sessions. Use this skill whenever the user wants to interact with websites, automate browser tasks, scrape web content, or perform any action requiring a real browser. Also use when the user mentions "browser", "webpage", "open URL", "screenshot", or asks to read/interact with any website. Use even for simple-sounding browser requests — the daemon handles all complexity.
|
||||
metadata:
|
||||
version: "1.11.3"
|
||||
---
|
||||
|
||||
# Kimi WebBridge
|
||||
|
||||
Control the user's real browser (with their login sessions) via a local daemon at `http://127.0.0.1:10086`.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Args | Returns | Note |
|
||||
|------|------|---------|------|
|
||||
| `navigate` | `url`, `newTab`(bool), `group_title` | `{success, url, tabId}` | First call opens a tab — see [Tabs](#tabs-and-the-current-tab). `group_title` sets the group's visible label |
|
||||
| `find_tab` | `url`, `active`(bool) | `{success, url, tabId, borrowed}` | Re-select a tab **this session** opened; `active:true` borrows the tab the **user** is viewing — see [Tabs](#tabs-and-the-current-tab) |
|
||||
| `snapshot` | — | `{url, title, tree}` with `@e` refs | **Accessibility tree** (text) — use this to read page content and locate elements |
|
||||
| `click` | `selector` (@e ref or CSS) | `{success, tag, text}` | Synthetic `el.click()` |
|
||||
| `fill` | `selector`, `value` | `{success, tag, mode}` | Works on `<input>`/`<textarea>` AND `[contenteditable]` (ProseMirror/Lexical/Slate). `mode` is `"value"` or `"contenteditable"` |
|
||||
| `evaluate` | `code` (supports async/await) | `{type, value}` | |
|
||||
| `cdp` | `method`, `params` | raw CDP response | Raw `chrome.debugger` passthrough — what `evaluate` is to JS, `cdp` is to CDP. Low-level escape hatch for cases the tools above don't cover |
|
||||
| `screenshot` | `format`(png\|jpeg), `quality`(0-100), optional `selector` (@e/CSS), optional `path` | `{format, path, sizeBytes, mimeType}` | Returns a file path, not base64 — see [Screenshots](#screenshots) |
|
||||
| `network` | `cmd`(start\|stop\|list\|detail), `filter`, `requestId` | request/response data | |
|
||||
| `upload` | `selector`, `files`(string[]) | `{success, fileCount}` | |
|
||||
| `save_as_pdf` | `paper_format`, `landscape`, `scale`, `print_background`, optional `path` | `{path, sizeBytes, mimeType, pageTitle}` | Render current page → PDF, returns a file path — see [Save as PDF](#save-the-current-page-as-pdf) |
|
||||
| `list_tabs` | — | `{success, tabs:[{tabId, url, title, active, groupTitle}]}` | Inspect tabs in the current session |
|
||||
| `close_tab` | — | `{success, closed: bool}` | Close the current tab in the session |
|
||||
| `close_session` | — | `{success, closed: int}` | Close all tabs in the session — `closed` is the count. See [Sessions](#sessions) for when to call |
|
||||
|
||||
### Tabs and the current tab
|
||||
|
||||
Single-tab tools (`snapshot`, `click`, `fill`, `screenshot`, `save_as_pdf`) act on the **current tab** — the one you most recently opened with `navigate` or selected with `find_tab`.
|
||||
|
||||
- **Opening pages**: use `newTab:true` when pages should coexist (comparing, cross-referencing); omit it to send the current tab to a new URL.
|
||||
- **Going back to an earlier tab**: call `find_tab` to make a tab **you opened earlier in this session** the current one again. Pass the tab's **full URL** — take it from `list_tabs` or the earlier `navigate` result. A bare root domain (`kimi.com`) may miss a `www.kimi.com` tab, so prefer the exact URL. By default `find_tab` searches **only this session's own tabs** — it never reaches into the user's other tabs or windows.
|
||||
- **Acting on a page the user already has open**: pass `active:true` ("use my open X tab" / "the X page I'm viewing"). It **borrows** the tab the user is currently viewing (returns `borrowed:true`); the borrowed tab is operated in place — it is not pulled into the session's tab group.
|
||||
- If `find_tab` errors with "no tab matching … in this session", the page isn't open in this session — `navigate` with `newTab:true` instead.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:10086/command \
|
||||
-d '{"action":"find_tab","args":{"url":"https://www.kimi.com","active":true},"session":"k26-research"}'
|
||||
```
|
||||
|
||||
### Call Format
|
||||
|
||||
Every command carries a top-level `session` naming the current task — see [Sessions](#sessions) below. The examples in later sections omit it only for brevity; in real calls always include it. The command format depends on the user's OS.
|
||||
|
||||
**macOS / Linux** — inline JSON is fine:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:10086/command \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"navigate","args":{"url":"https://example.com","newTab":true,"group_title":"My task"},"session":"my-task"}'
|
||||
```
|
||||
|
||||
**Windows (PowerShell / cmd)** — the shell corrupts non-ASCII characters (Chinese etc.) carried inline in command arguments or pipes; they reach the daemon as `?` and the text is unrecoverable. Send **every** request as a file body instead:
|
||||
|
||||
1. Write the JSON body to a **uniquely-named** temp file with your own file-write tool — never with shell `echo`/heredoc, which corrupts non-ASCII the same way. Give **every** request its own filename with a random suffix (e.g. `webbridge-req-<random>.json`) so concurrent requests never share a file and overwrite each other.
|
||||
2. POST the file with `curl.exe` — always `curl.exe`, never bare `curl`, which Windows PowerShell aliases to `Invoke-WebRequest`:
|
||||
|
||||
```powershell
|
||||
curl.exe -s -X POST http://127.0.0.1:10086/command -H "Content-Type: application/json" --data-binary "@$env:TEMP\webbridge-req-<random>.json"
|
||||
```
|
||||
|
||||
3. Delete the temp file as soon as the request returns — don't leave request bodies on disk.
|
||||
|
||||
## Sessions
|
||||
|
||||
**One task = one session = one tab group.** A `session` collects every tab the task opens into one tab group, so the user sees a single group for "what the agent is doing right now". Pass it as a **top-level field** of the request body (not inside `args`).
|
||||
|
||||
- **Pick one session name at the task's start, put it on every command, and never switch mid-task — even across different sites.** Switching session names per site is the #1 cause of fragmented tab groups.
|
||||
- Name it after the **task**, not the site (`camping-research`, `phone-compare`). Use multiple sessions only for genuinely unrelated parallel tasks.
|
||||
- `group_title` is the human-readable group label — write it in the user's language, on the **first** `navigate` of the task.
|
||||
- When you create the group (the first `navigate` of a task), tell the user once that this task's pages are collected under group «title», and that you'll close them whenever they ask.
|
||||
|
||||
```bash
|
||||
# First tab: set session + a human label (in the user's language)
|
||||
curl -s -X POST http://127.0.0.1:10086/command \
|
||||
-d '{"action":"navigate","args":{"url":"https://www.kimi.com","newTab":true,"group_title":"K2.6 feature research"},"session":"k26-research"}'
|
||||
# Another site, same task → same session → joins the same group automatically
|
||||
curl -s -X POST http://127.0.0.1:10086/command \
|
||||
-d '{"action":"navigate","args":{"url":"https://www.moonshot.cn","newTab":true},"session":"k26-research"}'
|
||||
```
|
||||
|
||||
Closing is always user-initiated: call `close_session` only when the user explicitly asks ("close those", "clear the tabs"). It clears the whole group in one call.
|
||||
|
||||
## Screenshots
|
||||
|
||||
The daemon writes the image to disk and returns `{format, path, sizeBytes, mimeType}` — never base64, since the model can't read raw image bytes. Take the `.path` and open it with the `Read` tool to actually see it.
|
||||
|
||||
```bash
|
||||
# Default: PNG of the visible viewport, daemon picks a temp path
|
||||
curl ... -d '{"action":"screenshot","args":{}}'
|
||||
# Options (each independent): JPEG quality, element-only via @e/CSS selector, custom output path
|
||||
curl ... -d '{"action":"screenshot","args":{"format":"jpeg","quality":60}}'
|
||||
curl ... -d '{"action":"screenshot","args":{"selector":"@e123"}}'
|
||||
```
|
||||
|
||||
A caller-supplied `path` is honored verbatim (parent dirs created, existing file overwritten) — use a unique name to avoid clobbering. `save_as_pdf` follows the same rule.
|
||||
|
||||
## Prefer snapshot over CSS/JS selectors
|
||||
|
||||
`snapshot` returns interactive elements with `@e` refs based on semantic role/name. Use them directly with click/fill — they survive CSS class hash changes that break manually-written selectors.
|
||||
|
||||
Fall back to `evaluate` (JS) only when:
|
||||
- The target has no `@e` ref in the snapshot
|
||||
- You need attributes not in the snapshot (e.g., `href`)
|
||||
- You need to dispatch complex event sequences, or scroll
|
||||
|
||||
## Evaluate Tips
|
||||
|
||||
- Always use compact `JSON.stringify(data)` — never add `null, 2` formatting. Indentation and newlines can inflate the response several times over, causing truncation during transmission.
|
||||
- `evaluate` calls share the page's JS realm — re-declaring the same `const`/`let` across two calls throws `SyntaxError`. Wrap in an IIFE for a fresh scope: `(() => { const x = ...; return x; })()`.
|
||||
|
||||
## Text input — use `fill`
|
||||
|
||||
`fill` (selector = CSS or `@e` ref, plus the value) works on `<input>`/`<textarea>` (returns `mode: "value"`) and on `[contenteditable]` rich editors — ProseMirror, TipTap, Lexical, Slate, Quill, etc. (returns `mode: "contenteditable"`), firing the right input events so the page reacts.
|
||||
|
||||
`fill` is **clear-and-insert**: existing content is replaced. To append, read the current value via `evaluate`, concatenate, then `fill` with the result.
|
||||
|
||||
## Form submit / special keys
|
||||
|
||||
There's no separate "press Enter" tool. To submit a form, click the submit button directly (`click` on the @e ref or selector). To dispatch a key event programmatically (e.g. Escape to close a modal):
|
||||
|
||||
```bash
|
||||
{"action":"evaluate","args":{"code":"document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))"}}
|
||||
```
|
||||
|
||||
## Save the current page as PDF
|
||||
|
||||
`save_as_pdf` renders the current page to PDF and returns the file path. All args optional:
|
||||
- `paper_format`: `letter` (default) \| `a4` \| `legal` \| `a3` \| `tabloid`
|
||||
- `landscape`: `false` (default)
|
||||
- `scale`: `1.0` (default), range `[0.1, 2.0]`
|
||||
- `print_background`: `true` (default) — keep background colors
|
||||
- `path`: caller-supplied output path; if absent, daemon picks a default under OS temp dir using the page title as the filename
|
||||
|
||||
`path` semantics match `screenshot`: written verbatim, parent dirs auto-created, existing files overwritten.
|
||||
|
||||
Decoded PDF cap is 100 MB. Above that the daemon refuses; reduce `scale` or split the page.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Sites that strictly check `event.isTrusted`** (some banking portals, captchas) ignore `click` / `fill` because those fire DOM-level synthetic events (`isTrusted=false`). For these, tell the user the page needs manual interaction. (Trusted input is possible at the protocol level via the `cdp` escape hatch, but treat that as advanced.)
|
||||
- **Cross-origin iframes**: `fill`, `click`, `evaluate`, and `snapshot` operate on the top frame. If a target element lives in a same-page iframe from a different origin (e.g. embedded sandbox demos), navigate to the iframe's URL directly instead.
|
||||
|
||||
## If a tool call fails (daemon or extension not ready)
|
||||
|
||||
Read [operations.md](references/operations.md) when the daemon or extension is unavailable, or when the user asks to install, start, or troubleshoot WebBridge. Follow its recovery order and never stop, restart, or uninstall the daemon automatically.
|
||||
|
||||
## Version mismatches
|
||||
|
||||
If a tool returns an error containing **"Please update the Kimi WebBridge extension"**, the user's browser extension is older than this skill. Don't try to reconcile versions yourself — just tell the user, in their language, to update the extension and retry:
|
||||
|
||||
- English: https://www.kimi.com/features/webbridge
|
||||
- 中文: https://www.kimi.com/zh-cn/features/webbridge
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Operations: daemon lifecycle and recovery
|
||||
|
||||
Read this only when a tool call can't reach the daemon, or the user explicitly asks to install / start / troubleshoot kimi-webbridge.
|
||||
|
||||
## The daemon
|
||||
|
||||
The `kimi-webbridge` binary lives at `~/.kimi-webbridge/bin/kimi-webbridge` (Windows: `%USERPROFILE%\.kimi-webbridge\bin\kimi-webbridge.exe`) and serves a local HTTP daemon on `127.0.0.1:10086`. Status, PID, and logs live under `~/.kimi-webbridge/`.
|
||||
|
||||
## Recovery — what to do when a tool call fails
|
||||
|
||||
1. **Daemon not reachable (connection refused)** → start it yourself, don't ask the user. `start` is idempotent: it no-ops if the daemon is already up, and concurrent starts converge to a single daemon (the OS lets only one process bind port 10086).
|
||||
- macOS / Linux: `~/.kimi-webbridge/bin/kimi-webbridge start`
|
||||
- Windows: `& "$env:USERPROFILE\.kimi-webbridge\bin\kimi-webbridge.exe" start`
|
||||
|
||||
Then retry the tool call.
|
||||
2. **`command not found` / binary missing** → not installed. Point the user to the help page below to install it.
|
||||
3. **Extension missing or won't connect** → give the user both official installation paths:
|
||||
- Chrome Web Store: https://chromewebstore.google.com/detail/kimi-webbridge/fldmhceldgbpfpkbgopacenieobmligc
|
||||
- Restricted-network fallback: download https://kimi-web-img.moonshot.cn/webbridge/latest/extension/kimi-webbridge-extension.zip, unzip it, open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select the extracted folder.
|
||||
4. **Anything still broken after a `start` + retry** → don't deep-troubleshoot. Point the user to the help page:
|
||||
- English: https://www.kimi.com/features/webbridge
|
||||
- 中文: https://www.kimi.com/zh-cn/features/webbridge
|
||||
|
||||
## Do NOT do automatically
|
||||
|
||||
Never run `stop` / `restart` / `uninstall` on your own. They kill the running daemon; if the user runs the **Kimi Desktop App** (which manages its own daemon), an external stop/restart also fights the app. If a hard restart is genuinely needed, ask the user to do it themselves — reopen the Kimi Desktop App, or run `kimi-webbridge restart` by hand.
|
||||
|
||||
## /status JSON fields
|
||||
|
||||
- `running` (bool) — daemon listening on `:10086`
|
||||
- `version` (string) — daemon build version
|
||||
- `extension_connected` (bool) — a WebSocket client (the browser extension) is attached
|
||||
- `extension_id` (string) — the Chrome/Edge extension ID, empty if none
|
||||
- `uptime_seconds` (int)
|
||||
Loading…
Add table
Add a link
Reference in a new issue