mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f979cac18 |
No files matched your search
+8
-51
@@ -4,12 +4,11 @@
|
||||
# alias = Display Name <[email protected]>
|
||||
#
|
||||
# The right-hand side must use GitHub's numeric noreply address so a harvested
|
||||
# commit authored with it, or a Co-authored-by trailer in the commit's final
|
||||
# trailer block, links to the contributor's account. GitHub resolves the
|
||||
# address by its numeric id, so the id must be the contributor's own
|
||||
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
|
||||
# old-style noreply address, raw email from a contributor commit, or local
|
||||
# machine email seen in older harvested history.
|
||||
# commit authored with it links to the contributor's account. Only commit
|
||||
# authorship puts someone in the repository's contributor list; a co-author
|
||||
# trailer shows on their profile but not there. The left-hand side may be a
|
||||
# GitHub login, old-style noreply address, raw email from a contributor commit,
|
||||
# or local machine email seen in older harvested history.
|
||||
|
||||
hmbown = Hmbown <[email protected]>
|
||||
[email protected] = Hmbown <[email protected]>
|
||||
@@ -51,7 +50,6 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
|
||||
idling11 = idling11 <[email protected]>
|
||||
Hanmiao Li = idling11 <[email protected]>
|
||||
[email protected] = idling11 <[email protected]>
|
||||
894876246 = idling11 <[email protected]>
|
||||
angziii = angziii <[email protected]>
|
||||
aboimpinto = aboimpinto <[email protected]>
|
||||
Paulo Aboim Pinto = aboimpinto <[email protected]>
|
||||
@@ -81,24 +79,19 @@ Vince = Liu-Vince <[email protected]>
|
||||
[email protected] = Liu-Vince <[email protected]>
|
||||
LI-Jialu = LI-Jialu <[email protected]>
|
||||
JiarenWang = JiarenWang <[email protected]>
|
||||
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
|
||||
mars-base = kitty <[email protected]>
|
||||
[email protected] = kitty <[email protected]>
|
||||
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
|
||||
pengyou200902 = pengyou200902 <[email protected]>
|
||||
linzhiqin2003 = linzhiqin2003 <[email protected]>
|
||||
LING71671 = LING71671 <[email protected]>
|
||||
[email protected] = LING71671 <[email protected]>
|
||||
JasonOA888 = JasonOA888 <[email protected]>
|
||||
Inference1 = Inference1 <[email protected]>
|
||||
hongqitai = hongqitai <[email protected]>
|
||||
gordonlu = gordonlu <[email protected]>
|
||||
angus-guo = gus <217034332+im[email protected]>
|
||||
imgusguo = gus <217034332+im[email protected]>
|
||||
[email protected] = gus <[email protected]>
|
||||
angus-guo = gus <217034332+angus-[email protected]>
|
||||
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
|
||||
gaord = gaord <[email protected]>
|
||||
Ben Gao = gaord <[email protected]>
|
||||
[email protected] = gaord <[email protected]>
|
||||
bengao168 = gaord <[email protected]>
|
||||
MXAntian = MXAntian <[email protected]>
|
||||
[email protected] = MXAntian <[email protected]>
|
||||
DarrellThomas = Darrell Thomas <[email protected]>
|
||||
@@ -254,7 +247,6 @@ [email protected] = zhuowp <[email protected]>
|
||||
[email protected] = 秋月凉梦 <[email protected]>
|
||||
|
||||
asto18089 = asto18089 <[email protected]>
|
||||
[email protected] = asto18089 <[email protected]>
|
||||
xiechimon = Xmon Dai <[email protected]>
|
||||
yrk111222 = Rongkun Yan <[email protected]>
|
||||
VincentCorleone = Vincent Corleone <[email protected]>
|
||||
@@ -286,38 +278,3 @@ Felixkw12 = Felix Wunderlich <[email protected]>
|
||||
[email protected] = Felix Wunderlich <[email protected]>
|
||||
Felix Wunderlich = Felix Wunderlich <[email protected]>
|
||||
[email protected] = luochen211 <[email protected]>
|
||||
|
||||
AiurArtanis = AiurArtanis <[email protected]>
|
||||
[email protected] = AiurArtanis <[email protected]>
|
||||
Apeiron0w0 = Apeiron <[email protected]>
|
||||
[email protected] = Apeiron <[email protected]>
|
||||
[email protected] = ChaceLyee2101 <[email protected]>
|
||||
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
|
||||
ddlw = ddlw <[email protected]>
|
||||
mo-vic = movic <[email protected]>
|
||||
[email protected] = movic <[email protected]>
|
||||
[email protected] = merchloubna70-dot <[email protected]>
|
||||
[email protected] = merchloubna70-dot <[email protected]>
|
||||
xulongzhe = luola <[email protected]>
|
||||
[email protected] = luola <[email protected]>
|
||||
ZhulongNT = SONG ZHENRUI <[email protected]>
|
||||
[email protected] = SONG ZHENRUI <[email protected]>
|
||||
xiluoduyu = xiluoduyu <[email protected]>
|
||||
[email protected] = xiluoduyu <[email protected]>
|
||||
wlon = Stephen Xu <[email protected]>
|
||||
[email protected] = Stephen Xu <[email protected]>
|
||||
krisclarkdev = Kristopher Clark <[email protected]>
|
||||
THatch26 = THatch26 <[email protected]>
|
||||
jeoor = Kayro <[email protected]>
|
||||
harvey2011888 = harvey2011888 <[email protected]>
|
||||
duanchao-lab = DC <[email protected]>
|
||||
dumbjack = dumbjack <[email protected]>
|
||||
[email protected] = dumbjack <[email protected]>
|
||||
AquaZhang = IceTear <[email protected]>
|
||||
disappear00 = idontknow <[email protected]>
|
||||
namezzb = sailorZ <[email protected]>
|
||||
alphapku-ux = Alpha Wu <[email protected]>
|
||||
BX166 = BX166 <[email protected]>
|
||||
c020627 = c020627 <[email protected]>
|
||||
[email protected] = Parman Mohammadalizadeh <[email protected]>
|
||||
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
|
||||
@@ -259,16 +259,9 @@ prefer merging the contributor's PR itself). When a contributor's work
|
||||
lands as our commit, the contributor is that commit's **author** (`git commit
|
||||
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
|
||||
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
|
||||
count takes commit authors and the `Co-authored-by` trailers in a commit's final
|
||||
trailer block, and only addresses linked to an account: a trailer cut off by a
|
||||
blank line, an unlinked email, or a noreply address carrying someone else's
|
||||
numeric id credits nobody or the wrong person. Harvested commits authored by
|
||||
maintainer identities without such a trailer left their contributors off it,
|
||||
which is why `docs/CONTRIBUTORS.md` carries the full wall
|
||||
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
|
||||
restored by a later commit with one trailer per person, naming each pull
|
||||
request; `scripts/check-contributor-credit.py` fails the release window when a
|
||||
harvest, a trailer or a noreply id would leave someone uncounted.
|
||||
list counts commit authors only: about 200 harvested commits authored by
|
||||
maintainer identities left their contributors off it, which is why
|
||||
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
|
||||
A maintainer closes their PR by hand with a thank-you naming the commit;
|
||||
nothing closes a PR automatically. Canonical human identities come from
|
||||
`.github/AUTHOR_MAP`.
|
||||
|
||||
+1
-23
@@ -7,29 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
|
||||
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
|
||||
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
|
||||
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
|
||||
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
|
||||
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
|
||||
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
|
||||
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
|
||||
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
|
||||
@@ -44,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
|
||||
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
|
||||
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
|
||||
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
|
||||
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
|
||||
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
|
||||
|
||||
@@ -47,18 +47,6 @@ impl Drop for TurnClaim {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn admitted_call<'a>(
|
||||
calls: &'a HashMap<String, PendingToolCall>,
|
||||
execution: &str,
|
||||
) -> Option<(&'a str, &'a PendingToolCall)> {
|
||||
let mut id = execution;
|
||||
loop {
|
||||
if let Some((key, call)) = calls.get_key_value(id) {
|
||||
return Some((key.as_str(), call));
|
||||
}
|
||||
id = id.rsplit_once('.')?.0;
|
||||
}
|
||||
}
|
||||
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
|
||||
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
|
||||
}
|
||||
@@ -431,50 +419,27 @@ impl PromptRun {
|
||||
&& pending.turn_id == turn
|
||||
&& pending.tool_call_id.as_deref() == Some(execution)
|
||||
}) {
|
||||
let (shown, call) = admitted_call(calls, execution)
|
||||
let call = calls
|
||||
.get(execution)
|
||||
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let nested = shown != execution;
|
||||
let nested_call;
|
||||
let shown_call = if nested {
|
||||
nested_call = PendingToolCall {
|
||||
execution_id: execution.to_string(),
|
||||
name: p
|
||||
.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&call.name)
|
||||
.to_string(),
|
||||
input: p.get("input").cloned().unwrap_or(Value::Null),
|
||||
};
|
||||
&nested_call
|
||||
} else {
|
||||
call
|
||||
};
|
||||
let title = tool_call_title(shown_call);
|
||||
let description = p
|
||||
let title = tool_call_title(call);
|
||||
let text = p
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty());
|
||||
let summary = p
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| nested && !text.trim().is_empty());
|
||||
let text = match (summary, description) {
|
||||
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
|
||||
(Some(text), None) | (None, Some(text)) => text.to_string(),
|
||||
(None, None) => title.clone(),
|
||||
};
|
||||
let mut fields = acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(shown_call))
|
||||
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]);
|
||||
if !nested {
|
||||
fields = fields.status(acp::ToolCallStatus::Pending);
|
||||
}
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map_or_else(|| title.clone(), str::to_string);
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
session.to_string(),
|
||||
acp::ToolCallUpdate::new(shown.to_string(), fields),
|
||||
acp::ToolCallUpdate::new(
|
||||
execution.to_string(),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(call))
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]),
|
||||
),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
"allow-once",
|
||||
|
||||
@@ -41,34 +41,6 @@ mod platform {
|
||||
tokio::io::WriteHalf<Connection>,
|
||||
>;
|
||||
|
||||
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
|
||||
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
|
||||
let mut tail = Vec::new();
|
||||
let mut probe = checked.as_path();
|
||||
loop {
|
||||
match probe.canonicalize() {
|
||||
Ok(real) => {
|
||||
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
tail.push(probe.file_name()?.to_owned());
|
||||
probe = probe.parent()?;
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
|
||||
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
left == right
|
||||
|| (cfg!(any(target_os = "macos", windows))
|
||||
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
if left == right {
|
||||
return true;
|
||||
@@ -256,13 +228,15 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
|
||||
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
|
||||
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
|
||||
&& matches!(
|
||||
selected.symlink_metadata(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
});
|
||||
let gone =
|
||||
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
|
||||
.is_ok_and(|selected| {
|
||||
selected == self.receipt().data_dir
|
||||
&& matches!(
|
||||
selected.symlink_metadata(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
});
|
||||
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -76,18 +76,6 @@ impl McpServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_shell_tools(&mut self) -> Vec<String> {
|
||||
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
|
||||
.into_iter()
|
||||
.partition(|name| {
|
||||
build_exposed_tools(std::slice::from_ref(name))
|
||||
.iter()
|
||||
.any(|tool| tool.internal == "exec_shell")
|
||||
});
|
||||
self.expose_tools = kept;
|
||||
refused
|
||||
}
|
||||
|
||||
fn from_toml(contents: &str) -> Result<Self> {
|
||||
let config: McpServerConfigFile = toml::from_str(contents)?;
|
||||
Ok(Self {
|
||||
@@ -110,21 +98,12 @@ pub async fn run_mcp_server(
|
||||
workspace: PathBuf,
|
||||
skills_dir: PathBuf,
|
||||
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
|
||||
tool_launcher_configured: bool,
|
||||
) -> Result<()> {
|
||||
// Settings load is a synchronous config read; keep it off the async
|
||||
// worker per the blocking-call convention.
|
||||
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
.await
|
||||
.context("MCP server settings task failed")??;
|
||||
if tool_launcher_configured {
|
||||
for name in settings.refuse_shell_tools() {
|
||||
eprintln!(
|
||||
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
|
||||
tool_launcher, and this server does not run commands through it."
|
||||
);
|
||||
}
|
||||
}
|
||||
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
|
||||
// stdout carries the protocol; the notice goes to stderr so an operator
|
||||
// whose config lists write tools sees why they are missing.
|
||||
@@ -927,21 +906,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
|
||||
let mut settings = McpServerSettings::from_toml(
|
||||
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
|
||||
)
|
||||
.expect("parse config");
|
||||
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
|
||||
let server = test_server(PathBuf::from("."), settings);
|
||||
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||
assert_eq!(names, vec!["file_read"]);
|
||||
for internal in ["exec_shell", "Bash", "bash"] {
|
||||
assert!(server.registry.get(internal).is_none(), "{internal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
|
||||
let settings = McpServerSettings::from_toml(
|
||||
|
||||
@@ -129,7 +129,6 @@ mod members;
|
||||
mod memory_lens;
|
||||
mod mobile;
|
||||
mod plans;
|
||||
mod plugin_index;
|
||||
mod plugins;
|
||||
pub(crate) mod review;
|
||||
mod secrets;
|
||||
@@ -3373,7 +3372,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/skills/{name}/audit", get(audit_skill_api))
|
||||
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
|
||||
.route("/v1/apps/plugins", get(plugins::list_plugins))
|
||||
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
|
||||
.route("/v1/apps/avatars", get(avatars::list_avatars))
|
||||
.route(
|
||||
"/v1/apps/avatars/{handle}/atlases/{page}",
|
||||
@@ -5877,10 +5875,6 @@ struct CommandCatalogEntry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
origin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
plugin_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
workspace_sourced: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
registration_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -5939,8 +5933,6 @@ fn command_catalog(
|
||||
shadowed_by,
|
||||
shadowed_aliases,
|
||||
origin: None,
|
||||
plugin_id: None,
|
||||
workspace_sourced: false,
|
||||
registration_id: None,
|
||||
});
|
||||
}
|
||||
@@ -5964,8 +5956,6 @@ fn command_catalog(
|
||||
shadowed_by: None,
|
||||
shadowed_aliases: Vec::new(),
|
||||
origin: None,
|
||||
plugin_id: command.plugin_id.clone(),
|
||||
workspace_sourced: command.workspace_sourced,
|
||||
registration_id: None,
|
||||
});
|
||||
}
|
||||
@@ -6048,8 +6038,6 @@ async fn list_commands(
|
||||
shadowed_by: None,
|
||||
shadowed_aliases: Vec::new(),
|
||||
origin: Some(reference.origin.clone()),
|
||||
plugin_id: Some(reference.plugin_id.clone()),
|
||||
workspace_sourced: false,
|
||||
registration_id: query
|
||||
.thread_id
|
||||
.as_deref()
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::plugins::{self, PluginSummaryEntry};
|
||||
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
|
||||
|
||||
const MAX_ADDED_NAMES: usize = 512;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct PluginIndexResponse {
|
||||
workspace: String,
|
||||
items: Vec<IndexItem>,
|
||||
unavailable: Vec<IndexNote>,
|
||||
problems: Vec<IndexNote>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IndexNote {
|
||||
kind: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IndexItem {
|
||||
id: String,
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
source: &'static str,
|
||||
state: &'static str,
|
||||
labels: Vec<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
plugin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
adds: Option<PluginAdds>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tier: Option<&'static str>,
|
||||
owner: &'static str,
|
||||
row: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct PluginAdds {
|
||||
skills: Vec<String>,
|
||||
connectors: Vec<String>,
|
||||
hooks: Vec<String>,
|
||||
commands: Vec<String>,
|
||||
profiles: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginAdds {
|
||||
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
|
||||
[
|
||||
("skill", !self.skills.is_empty()),
|
||||
("connector", !self.connectors.is_empty()),
|
||||
("hook", !self.hooks.is_empty()),
|
||||
("command", !self.commands.is_empty()),
|
||||
(
|
||||
"tool",
|
||||
entry.inventory.native > 0 || entry.inventory.lsp > 0,
|
||||
),
|
||||
("profile", !self.profiles.is_empty()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, present)| present.then_some(label))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_value<T: Serialize>(row: &T) -> Value {
|
||||
serde_json::to_value(row).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
|
||||
unavailable.push(IndexNote {
|
||||
kind,
|
||||
message: error.message.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn plugin_source(origin: &str) -> &'static str {
|
||||
match origin {
|
||||
"codewhale-builtin" => "built_in",
|
||||
"workspace-codewhale" => "project",
|
||||
_ => "user",
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
|
||||
if matches!(
|
||||
entry.trust_status,
|
||||
"content-changed" | "capabilities-changed"
|
||||
) || entry.state == "enabled-untrusted"
|
||||
{
|
||||
return "needs_review";
|
||||
}
|
||||
match entry.state {
|
||||
"active" => "enabled",
|
||||
"disabled" => "disabled",
|
||||
_ => "unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
|
||||
if let Some(wanted) = extension
|
||||
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
|
||||
names.insert(if lowercase {
|
||||
stem.to_lowercase()
|
||||
} else {
|
||||
stem.to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn component_names(
|
||||
paths: &[PathBuf],
|
||||
extension: Option<&str>,
|
||||
lowercase: bool,
|
||||
omitted: &mut bool,
|
||||
) -> Vec<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
for path in paths {
|
||||
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_file() {
|
||||
add_stem(&mut names, path, extension, lowercase);
|
||||
} else if meta.is_dir()
|
||||
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
|
||||
{
|
||||
let mut seen = 0;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
seen += 1;
|
||||
if seen > MAX_ADDED_NAMES {
|
||||
*omitted = true;
|
||||
break;
|
||||
}
|
||||
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
|
||||
add_stem(&mut names, &entry.path(), extension, lowercase);
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.len() >= MAX_ADDED_NAMES {
|
||||
*omitted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if names.iter().any(|name| name.chars().any(char::is_control)) {
|
||||
*omitted = true;
|
||||
names.retain(|name| !name.chars().any(char::is_control));
|
||||
}
|
||||
names.into_iter().take(MAX_ADDED_NAMES).collect()
|
||||
}
|
||||
|
||||
async fn plugin_adds(
|
||||
plugin: &codewhale_plugins::types::LoadedPlugin,
|
||||
omitted: &mut bool,
|
||||
) -> PluginAdds {
|
||||
let mut skills: Vec<String> = plugin
|
||||
.skill_snapshots
|
||||
.iter()
|
||||
.map(|skill| skill.name.clone())
|
||||
.collect();
|
||||
skills.sort();
|
||||
let mut connectors: Vec<String> = plugin
|
||||
.manifest
|
||||
.mcp_servers
|
||||
.as_ref()
|
||||
.map(|servers| servers.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
connectors.sort();
|
||||
PluginAdds {
|
||||
skills,
|
||||
connectors,
|
||||
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
|
||||
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
|
||||
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_adds_by_id(
|
||||
state: &RuntimeApiState,
|
||||
omitted: &mut BTreeSet<String>,
|
||||
) -> HashMap<String, PluginAdds> {
|
||||
let registry = state
|
||||
.plugin_discovery
|
||||
.registry_for_workspace(&state.workspace);
|
||||
let mut adds = HashMap::new();
|
||||
for plugin in registry.list() {
|
||||
let mut cut = false;
|
||||
adds.insert(
|
||||
plugin.id.as_str().to_string(),
|
||||
plugin_adds(plugin, &mut cut).await,
|
||||
);
|
||||
if cut {
|
||||
omitted.insert(plugin.name().to_string());
|
||||
}
|
||||
}
|
||||
adds
|
||||
}
|
||||
|
||||
fn skill_source(
|
||||
path: Option<&PathBuf>,
|
||||
is_bundled: bool,
|
||||
skills_dir: &FsPath,
|
||||
workspace: &FsPath,
|
||||
) -> &'static str {
|
||||
if is_bundled {
|
||||
return "built_in";
|
||||
}
|
||||
match path {
|
||||
Some(path) if path.starts_with(skills_dir) => "user",
|
||||
Some(path) if path.starts_with(workspace) => "project",
|
||||
Some(_) => "other_app",
|
||||
None => "plugin",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_plugin_index(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<PluginIndexResponse>, ApiError> {
|
||||
let mut items = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
|
||||
let commands = super::list_commands(
|
||||
State(state.clone()),
|
||||
Query(CommandsQuery { thread_id: None }),
|
||||
)
|
||||
.await;
|
||||
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
if let Ok(Json(response)) = &commands {
|
||||
for command in &response.commands {
|
||||
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
|
||||
plugin_commands
|
||||
.entry(plugin_id.clone())
|
||||
.or_default()
|
||||
.insert(command.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match plugins::list_plugins(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
let mut omitted = BTreeSet::new();
|
||||
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
|
||||
for name in &omitted {
|
||||
problems.push(IndexNote {
|
||||
kind: "plugin",
|
||||
message: format!(
|
||||
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagnostic in &response.registry_diagnostics {
|
||||
problems.push(IndexNote {
|
||||
kind: "plugin",
|
||||
message: diagnostic.message.clone(),
|
||||
});
|
||||
}
|
||||
for entry in &response.plugins {
|
||||
let mut adds = adds_by_id.remove(&entry.id);
|
||||
if entry.state == "active"
|
||||
&& commands.is_ok()
|
||||
&& let Some(adds) = adds.as_mut()
|
||||
{
|
||||
adds.commands = plugin_commands
|
||||
.remove(&entry.id)
|
||||
.map(|names| names.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
items.push(IndexItem {
|
||||
id: format!("plugin:{}", entry.id),
|
||||
kind: "plugin",
|
||||
name: entry
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| entry.name.clone()),
|
||||
description: entry.description.clone(),
|
||||
source: plugin_source(entry.origin),
|
||||
state: plugin_state(entry),
|
||||
labels: adds
|
||||
.as_ref()
|
||||
.map(|adds| adds.labels(entry))
|
||||
.unwrap_or_default(),
|
||||
plugin: None,
|
||||
adds,
|
||||
tier: None,
|
||||
owner: "/v1/apps/plugins",
|
||||
row: row_value(entry),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "plugin", &error),
|
||||
}
|
||||
|
||||
match plugins::list_plugin_catalog(State(state.clone())).await {
|
||||
Ok(Json(catalog)) => {
|
||||
for (tier, rows) in [
|
||||
("builtin", &catalog.builtin),
|
||||
("experimental", &catalog.experimental),
|
||||
("more", &catalog.more),
|
||||
] {
|
||||
for row in rows {
|
||||
items.push(IndexItem {
|
||||
id: format!("plugin:catalog:{}", row.id),
|
||||
kind: "plugin",
|
||||
name: row.title.clone(),
|
||||
description: row.description.clone(),
|
||||
source: "built_in",
|
||||
state: if !row.available {
|
||||
"unavailable"
|
||||
} else if row.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
labels: Vec::new(),
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: Some(tier),
|
||||
owner: "/v1/apps/plugins/catalog",
|
||||
row: row_value(row),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "plugin", &error),
|
||||
}
|
||||
|
||||
match super::list_skills(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
for warning in &response.warnings {
|
||||
problems.push(IndexNote {
|
||||
kind: "skill",
|
||||
message: warning.clone(),
|
||||
});
|
||||
}
|
||||
for skill in &response.skills {
|
||||
if skill.plugin_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
items.push(IndexItem {
|
||||
id: format!("skill:{}", skill.name),
|
||||
kind: "skill",
|
||||
name: skill.name.clone(),
|
||||
description: Some(skill.description.clone()),
|
||||
source: skill_source(
|
||||
skill.path.as_ref(),
|
||||
skill.is_bundled,
|
||||
&response.directory,
|
||||
&state.workspace,
|
||||
),
|
||||
state: if skill.enabled { "enabled" } else { "disabled" },
|
||||
labels: vec!["skill"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/skills",
|
||||
row: row_value(skill),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "skill", &error),
|
||||
}
|
||||
|
||||
match super::list_mcp_servers(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
for server in &response.servers {
|
||||
let source = match server.origin {
|
||||
"plugin" => continue,
|
||||
"project" => "project",
|
||||
"runtime" => "runtime",
|
||||
_ => "user",
|
||||
};
|
||||
items.push(IndexItem {
|
||||
id: format!("connector:{}", server.name),
|
||||
kind: "connector",
|
||||
name: server.name.clone(),
|
||||
description: None,
|
||||
source,
|
||||
state: if !server.enabled {
|
||||
"disabled"
|
||||
} else if server.auth_required {
|
||||
"needs_sign_in"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
labels: vec!["connector"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/apps/mcp/servers",
|
||||
row: row_value(server),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "connector", &error),
|
||||
}
|
||||
|
||||
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
|
||||
Ok(Json(response)) => {
|
||||
for problem in &response.problems {
|
||||
problems.push(IndexNote {
|
||||
kind: "hook",
|
||||
message: problem.clone(),
|
||||
});
|
||||
}
|
||||
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
|
||||
for hook in &response.hooks {
|
||||
let ordinal = ordinals.entry(hook.event).or_default();
|
||||
*ordinal += 1;
|
||||
let source = match hook.source {
|
||||
"plugin" => continue,
|
||||
"project" => "project",
|
||||
_ => "user",
|
||||
};
|
||||
let name = hook
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
|
||||
items.push(IndexItem {
|
||||
id: format!("hook:{source}:{}:{name}", hook.event),
|
||||
kind: "hook",
|
||||
name,
|
||||
description: None,
|
||||
source,
|
||||
state: if response.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
labels: vec!["hook"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/hooks",
|
||||
row: row_value(hook),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "hook", &error),
|
||||
}
|
||||
|
||||
match commands {
|
||||
Ok(Json(response)) => {
|
||||
for command in &response.commands {
|
||||
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
|
||||
("builtin", _) => continue,
|
||||
("user", Some(_)) => continue,
|
||||
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
|
||||
_ if command.workspace_sourced => ("project", None),
|
||||
_ => ("user", None),
|
||||
};
|
||||
items.push(IndexItem {
|
||||
id: format!("command:{}", command.name),
|
||||
kind: "command",
|
||||
name: command.name.clone(),
|
||||
description: command.summary.clone(),
|
||||
source,
|
||||
state: "enabled",
|
||||
labels: vec!["command"],
|
||||
plugin,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/commands",
|
||||
row: row_value(command),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "command", &error),
|
||||
}
|
||||
|
||||
Ok(Json(PluginIndexResponse {
|
||||
workspace: state.workspace.display().to_string(),
|
||||
items,
|
||||
unavailable,
|
||||
problems,
|
||||
}))
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use tokio::sync::{Mutex, mpsc, oneshot};
|
||||
use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod catalog_parity;
|
||||
mod command_catalog;
|
||||
#[cfg(unix)]
|
||||
mod control_socket_selection;
|
||||
@@ -29,7 +28,6 @@ mod headless_catalog;
|
||||
mod member_operations;
|
||||
#[cfg(unix)]
|
||||
mod owner_bootstrap;
|
||||
mod plugin_index;
|
||||
mod plugin_ui;
|
||||
mod profile_constitution;
|
||||
#[cfg(any(unix, windows))]
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
use super::*;
|
||||
use codewhale_config::catalog::{
|
||||
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
|
||||
now_unix,
|
||||
};
|
||||
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
|
||||
use codewhale_engine::model_inventory::ModelInventory;
|
||||
use codewhale_runtime::config::ProviderIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SINGULAR: &str = "model list / model resolve";
|
||||
const PLURAL: &str = "models";
|
||||
const RUNTIME: &str = "runtime api";
|
||||
const ROUTING: &str = "routing inventory";
|
||||
const COST: &str = "cost";
|
||||
const UNKNOWN: &str = "unknown";
|
||||
|
||||
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
|
||||
|
||||
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
|
||||
|
||||
struct Route {
|
||||
label: &'static str,
|
||||
api_path: &'static str,
|
||||
identity: ProviderIdentity,
|
||||
base_url: String,
|
||||
models: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn cold_process() {
|
||||
codewhale_engine::provider_catalog_live::reset_cache_for_test();
|
||||
codewhale_engine::provider_lake::clear_live_snapshot();
|
||||
}
|
||||
|
||||
fn known<T: ToString>(value: Option<T>) -> String {
|
||||
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
|
||||
}
|
||||
|
||||
fn rate(value: &Value) -> String {
|
||||
let number = match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::String(text) => text.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
};
|
||||
known(number)
|
||||
}
|
||||
|
||||
fn roster(mut models: Vec<String>) -> String {
|
||||
models.sort();
|
||||
models.dedup();
|
||||
models.join(",")
|
||||
}
|
||||
|
||||
fn read(
|
||||
readings: &mut Readings,
|
||||
route: &Route,
|
||||
model: &str,
|
||||
field: &'static str,
|
||||
surface: &'static str,
|
||||
value: String,
|
||||
) {
|
||||
readings
|
||||
.entry((route.label.to_string(), model.to_string(), field))
|
||||
.or_default()
|
||||
.insert(surface, value);
|
||||
}
|
||||
|
||||
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
|
||||
let fingerprint = base_url_fingerprint(base_url);
|
||||
let fetched_at = now_unix();
|
||||
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
base_url,
|
||||
);
|
||||
assert_eq!(
|
||||
codewhale_engine::provider_catalog_live::record_success_if_current(
|
||||
&ticket,
|
||||
ProviderCatalogDelta {
|
||||
provider: identity.key.to_string(),
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
offerings: ROSTER
|
||||
.iter()
|
||||
.map(|id| CatalogOffering {
|
||||
provider: identity.key.to_string(),
|
||||
wire_model_id: (*id).to_string(),
|
||||
endpoint_key: "chat".into(),
|
||||
source: CatalogSource::Live {
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
),
|
||||
Some(CatalogStatus::Fresh)
|
||||
);
|
||||
}
|
||||
|
||||
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
|
||||
cold_process();
|
||||
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
|
||||
let listed =
|
||||
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
|
||||
.into_iter()
|
||||
.find(|(listed, _)| listed == route.identity.key.as_str())
|
||||
.map(|(_, models)| roster(models));
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
SINGULAR,
|
||||
listed.unwrap_or_else(|| "route not listed".into()),
|
||||
);
|
||||
for model in route.models {
|
||||
let limits = codewhale_engine::route_preferences::resolve_model(
|
||||
config_path,
|
||||
route.identity.key.as_str(),
|
||||
&route.base_url,
|
||||
Some(model),
|
||||
)
|
||||
.map(|candidate| candidate.limits());
|
||||
let (context, output) = match limits {
|
||||
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
|
||||
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
|
||||
};
|
||||
read(readings, route, model, "limit.context", SINGULAR, context);
|
||||
read(readings, route, model, "limit.output", SINGULAR, output);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
|
||||
cold_process();
|
||||
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&route.identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
PLURAL,
|
||||
roster(codewhale_engine::provider_lake::listed_models_for_route(
|
||||
&route_config,
|
||||
&route.identity,
|
||||
)),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_runtime(
|
||||
readings: &mut Readings,
|
||||
client: &reqwest::Client,
|
||||
addr: &SocketAddr,
|
||||
route: &Route,
|
||||
) -> Result<()> {
|
||||
let body: Value = client
|
||||
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let models = body["models"].as_array().context("models array")?;
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
RUNTIME,
|
||||
roster(
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|model| model["id"].as_str().map(str::to_string))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for model in route.models {
|
||||
let facts = models
|
||||
.iter()
|
||||
.find(|entry| entry["id"] == *model)
|
||||
.map_or(&Value::Null, |entry| &entry["facts"]);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.context",
|
||||
RUNTIME,
|
||||
known(facts["effective_limits"]["context_tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.output",
|
||||
RUNTIME,
|
||||
known(facts["effective_limits"]["output_tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window",
|
||||
RUNTIME,
|
||||
known(facts["effective_context"]["tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window.source",
|
||||
RUNTIME,
|
||||
known(facts["effective_context"]["source"].as_str()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.input",
|
||||
RUNTIME,
|
||||
rate(&facts["pricing"]["input_per_million"]),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.output",
|
||||
RUNTIME,
|
||||
rate(&facts["pricing"]["output_per_million"]),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.basis",
|
||||
RUNTIME,
|
||||
format!(
|
||||
"provenance={} status={} source={}",
|
||||
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
|
||||
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
|
||||
let candidates: Vec<_> = inventory
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.identity == route.identity)
|
||||
.collect();
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
ROUTING,
|
||||
roster(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.model.clone())
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for model in route.models {
|
||||
let candidate = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.model == *model);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window",
|
||||
ROUTING,
|
||||
known(candidate.map(|candidate| candidate.context_window)),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window.source",
|
||||
ROUTING,
|
||||
known(candidate.map(|candidate| {
|
||||
if candidate.context_window_unverified {
|
||||
"unverified"
|
||||
} else {
|
||||
"verified"
|
||||
}
|
||||
})),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.output",
|
||||
ROUTING,
|
||||
known(candidate.and_then(|candidate| candidate.max_output)),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
|
||||
let usage = Usage {
|
||||
input_tokens: 100_000,
|
||||
output_tokens: 10_000,
|
||||
..Default::default()
|
||||
};
|
||||
for model in route.models {
|
||||
let envelope = EffectiveRouteEnvelope::from_admitted(
|
||||
Some(config),
|
||||
&route.identity,
|
||||
*model,
|
||||
Some(&route.base_url),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
let quote = envelope.provider_live_pricing.as_ref();
|
||||
let audit = envelope.audit(&usage);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.input",
|
||||
COST,
|
||||
quote
|
||||
.and_then(|quote| quote.input_per_million.as_deref())
|
||||
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.output",
|
||||
COST,
|
||||
quote
|
||||
.and_then(|quote| quote.output_per_million.as_deref())
|
||||
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.basis",
|
||||
COST,
|
||||
format!(
|
||||
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
|
||||
quote.map(|quote| quote.provenance.clone()),
|
||||
envelope.billing_mode,
|
||||
known(audit.exact_usd.as_deref()),
|
||||
audit.unpriced_reason,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
|
||||
let root = tempfile::tempdir()?;
|
||||
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
|
||||
let catalog_path = root.path().join("captured-models-dev.json");
|
||||
fs::write(&catalog_path, captured)?;
|
||||
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
|
||||
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
|
||||
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
|
||||
cold_process();
|
||||
|
||||
let config_path = root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "anthropic"
|
||||
|
||||
[providers.anthropic]
|
||||
api_key = "parity-fixture-key"
|
||||
model = "claude-opus-5"
|
||||
|
||||
[providers.zai]
|
||||
api_key = "parity-fixture-key"
|
||||
model = "GLM-5.3"
|
||||
|
||||
[providers.lab]
|
||||
kind = "openai-compatible"
|
||||
base_url = "http://127.0.0.1:9/v1"
|
||||
api_key = "parity-fixture-key"
|
||||
model = "claude-haiku-4-5"
|
||||
"#,
|
||||
)?;
|
||||
let config = Config::load(Some(config_path.clone()), None)?;
|
||||
let route = |label: &'static str,
|
||||
api_path: &'static str,
|
||||
key: &str,
|
||||
models: &'static [&'static str]|
|
||||
-> Result<Route> {
|
||||
let identity = config
|
||||
.resolve_provider_pin_identity(key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(Route {
|
||||
label,
|
||||
api_path,
|
||||
base_url: config.base_url_for_route(&identity),
|
||||
identity,
|
||||
models,
|
||||
})
|
||||
};
|
||||
let routes = [
|
||||
route(
|
||||
"anthropic (official, id-only roster)",
|
||||
"anthropic/models",
|
||||
"anthropic",
|
||||
ROSTER,
|
||||
)?,
|
||||
route(
|
||||
"lab (custom endpoint, same spelling)",
|
||||
"custom/models?model_provider_id=lab",
|
||||
"lab",
|
||||
&["claude-haiku-4-5"],
|
||||
)?,
|
||||
route(
|
||||
"zai (plan endpoint)",
|
||||
"zai/models",
|
||||
"zai",
|
||||
&["GLM-5.3", "GLM-5.2"],
|
||||
)?,
|
||||
];
|
||||
|
||||
assert!(
|
||||
codewhale_engine::models_dev_live::refresh(false)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?
|
||||
> 0
|
||||
);
|
||||
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
|
||||
|
||||
let mut readings = Readings::new();
|
||||
for route in &routes {
|
||||
read_singular(&mut readings, &config_path, route)?;
|
||||
read_plural(&mut readings, &config, route)?;
|
||||
}
|
||||
cold_process();
|
||||
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
|
||||
.await?
|
||||
.expect("loopback server required");
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
for route in &routes {
|
||||
read_runtime(&mut readings, &client, &addr, route).await?;
|
||||
read_routing(&mut readings, &config, route)?;
|
||||
read_cost(&mut readings, &config, route);
|
||||
}
|
||||
handle.abort();
|
||||
cold_process();
|
||||
|
||||
println!(
|
||||
"PARITY captured_catalog sha256={} bytes={}",
|
||||
Sha256::digest(captured.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
captured.len()
|
||||
);
|
||||
let mut differing = Vec::new();
|
||||
let mut known_differences: Vec<String> = Vec::new();
|
||||
for ((route, model, field), surfaces) in &readings {
|
||||
let mut values: Vec<String> = surfaces
|
||||
.values()
|
||||
.map(|value| {
|
||||
if *field == "roster" {
|
||||
roster(
|
||||
value
|
||||
.to_ascii_lowercase()
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
value.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
let compared = !matches!(*field, "price.basis" | "window.source");
|
||||
let borrowed_window = *field == "window"
|
||||
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
|
||||
&& readings
|
||||
.get(&(route.clone(), model.clone(), "window.source"))
|
||||
.and_then(|sources| sources.get(ROUTING))
|
||||
.map(String::as_str)
|
||||
== Some("unverified");
|
||||
let verdict = if !compared {
|
||||
"info"
|
||||
} else if borrowed_window {
|
||||
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
|
||||
"KNOWN-DIFFERENCE"
|
||||
} else if values.len() == 1 {
|
||||
"agree"
|
||||
} else {
|
||||
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
|
||||
"DIFFER"
|
||||
};
|
||||
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
|
||||
}
|
||||
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
|
||||
readings
|
||||
.get(&(route.label.to_string(), model.to_string(), field))
|
||||
.and_then(|surfaces| surfaces.get(surface))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
value(&routes[0], "*", "roster", SINGULAR),
|
||||
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
|
||||
"the singular command lists the route's own roster, not every bundled row"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
|
||||
"1000000"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
|
||||
"200000"
|
||||
);
|
||||
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
|
||||
"5"
|
||||
);
|
||||
for field in ["limit.context", "limit.output", "price.input"] {
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
|
||||
UNKNOWN,
|
||||
"a custom endpoint borrows no fact from a same-named model: {field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
|
||||
"unverified",
|
||||
"routing never presents a same-named model's window as this endpoint's"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
|
||||
UNKNOWN
|
||||
);
|
||||
for model in ["GLM-5.3", "GLM-5.2"] {
|
||||
let basis = value(&routes[2], model, "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
|
||||
"a plan route is never billed per token: {model}: {basis}"
|
||||
);
|
||||
}
|
||||
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
|
||||
for surface in [COST, RUNTIME] {
|
||||
assert_eq!(
|
||||
value(&routes[2], model, "price.input", surface),
|
||||
rate,
|
||||
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
|
||||
"a custom endpoint is never given a charge: {basis}"
|
||||
);
|
||||
for surface in [COST, RUNTIME] {
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
|
||||
UNKNOWN,
|
||||
"a metered first-party model with no price row reads unknown: {surface}"
|
||||
);
|
||||
}
|
||||
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
|
||||
"an unpriced model produces no charge and says why: {basis}"
|
||||
);
|
||||
assert_eq!(
|
||||
known_differences,
|
||||
vec![
|
||||
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
|
||||
],
|
||||
"the only named difference is the custom endpoint's borrowed routing window"
|
||||
);
|
||||
assert!(
|
||||
differing.is_empty(),
|
||||
"surfaces disagree on the same captured catalog:\n{}",
|
||||
differing.join("\n")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,8 +28,6 @@ fn user(name: &str) -> UserCommand {
|
||||
takes_arguments: false,
|
||||
hidden: false,
|
||||
extension: false,
|
||||
plugin_id: None,
|
||||
workspace_sourced: false,
|
||||
builtin_bindings: vec![name.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
use super::*;
|
||||
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
|
||||
|
||||
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
|
||||
|
||||
impl CommandCatalog for Catalog {
|
||||
fn answers_to(&self, _name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn builtins(&self) -> Vec<BuiltinCommand> {
|
||||
Vec::new()
|
||||
}
|
||||
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
|
||||
let plugin_id = self.0.lock().expect("plugin id").clone();
|
||||
let command = |name: &str, plugin: Option<String>| UserCommand {
|
||||
name: name.to_string(),
|
||||
aliases: Vec::new(),
|
||||
description: Some("A command.".to_string()),
|
||||
usage: None,
|
||||
takes_arguments: false,
|
||||
hidden: false,
|
||||
extension: false,
|
||||
plugin_id: plugin,
|
||||
workspace_sourced: false,
|
||||
builtin_bindings: Vec::new(),
|
||||
};
|
||||
vec![command("mine", None), command("ship", plugin_id)]
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root = temp.path().join("index-route");
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let plugin_id = Arc::new(std::sync::Mutex::new(None));
|
||||
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
|
||||
plugin_id.clone(),
|
||||
)));
|
||||
let sources = root.join("sources");
|
||||
fs::create_dir_all(&sources)?;
|
||||
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
|
||||
fs::create_dir_all(bundle.join("commands"))?;
|
||||
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
|
||||
fs::create_dir_all(bundle.join("agents"))?;
|
||||
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
|
||||
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
|
||||
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
|
||||
)?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
plugin_ui::install_active(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/apps/plugins"),
|
||||
bundle.display().to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let listed: Value = client
|
||||
.get(format!("http://{addr}/v1/apps/plugins"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let id = listed["plugins"]
|
||||
.as_array()
|
||||
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
|
||||
.and_then(|plugin| plugin["id"].as_str())
|
||||
.expect("installed plugin id")
|
||||
.to_string();
|
||||
*plugin_id.lock().expect("plugin id") = Some(id.clone());
|
||||
|
||||
let commands: Value = client
|
||||
.get(format!("http://{addr}/v1/commands"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let ship = commands["commands"]
|
||||
.as_array()
|
||||
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
|
||||
.expect("ship row");
|
||||
assert_eq!(ship["plugin_id"], id.as_str());
|
||||
|
||||
let index: Value = client
|
||||
.get(format!("http://{addr}/v1/apps/plugin-index"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let items = index["items"].as_array().expect("items");
|
||||
let find = |id: &str| items.iter().find(|item| item["id"] == id);
|
||||
|
||||
let plugin = items
|
||||
.iter()
|
||||
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
|
||||
.unwrap_or_else(|| panic!("plugin row in {index}"));
|
||||
assert_eq!(plugin["kind"], "plugin");
|
||||
assert_eq!(plugin["state"], "enabled");
|
||||
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
|
||||
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
|
||||
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
|
||||
assert_eq!(
|
||||
plugin["labels"],
|
||||
serde_json::json!(["skill", "command", "profile"])
|
||||
);
|
||||
assert_eq!(plugin["row"]["name"], "indexed");
|
||||
|
||||
assert!(find("skill:greet").is_none());
|
||||
assert!(find("command:ship").is_none());
|
||||
let mine = find("command:mine").expect("a hand-written command is a row");
|
||||
assert_eq!(mine["source"], "user");
|
||||
assert_eq!(mine["labels"], serde_json::json!(["command"]));
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
|
||||
"built-in switches are rows"
|
||||
);
|
||||
assert_eq!(
|
||||
index["unavailable"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"{index}"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
pub(super) async fn install_active(
|
||||
async fn install_active(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
source: String,
|
||||
|
||||
@@ -3,13 +3,13 @@ mod acp_support;
|
||||
use acp_support::{Client, fixture_config, text_of, updates};
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_app_server::acp_server::capture_frontend;
|
||||
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
|
||||
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
|
||||
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
|
||||
use codewhale_engine::runtime_threads::manager::{
|
||||
RuntimeThreadManager, RuntimeThreadManagerConfig,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
@@ -497,346 +497,3 @@ async fn extension_journey() -> Result<()> {
|
||||
host.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
|
||||
readline.createInterface({input:process.stdin}).on('line', line => {
|
||||
const r = JSON.parse(line); if (r.id === undefined) return;
|
||||
let result = {};
|
||||
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
|
||||
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
|
||||
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
|
||||
});
|
||||
"#;
|
||||
|
||||
struct Outcome {
|
||||
response: Value,
|
||||
frames: Vec<Value>,
|
||||
asked: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
fn rendered(&self) -> String {
|
||||
serde_json::to_string(&self.frames).unwrap_or_default()
|
||||
}
|
||||
fn completed(&self) -> usize {
|
||||
updates(&self.frames, "tool_call_update")
|
||||
.iter()
|
||||
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
|
||||
.count()
|
||||
}
|
||||
fn titles(&self) -> Vec<String> {
|
||||
self.asked
|
||||
.iter()
|
||||
.map(|frame| {
|
||||
frame["params"]["toolCall"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn announced_ids(&self) -> Vec<String> {
|
||||
updates(&self.frames, "tool_call")
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_prompt(
|
||||
client: &mut Client,
|
||||
session: &str,
|
||||
allow: &dyn Fn(usize) -> bool,
|
||||
) -> Result<Outcome> {
|
||||
client.next_id += 1;
|
||||
let prompt_id = client.next_id;
|
||||
client
|
||||
.send(
|
||||
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
|
||||
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
|
||||
)
|
||||
.await?;
|
||||
let mut frames = Vec::new();
|
||||
let mut asked = Vec::new();
|
||||
let response = loop {
|
||||
let frame = client.recv().await?;
|
||||
if frame["method"] == "session/request_permission" {
|
||||
let option = if allow(asked.len()) {
|
||||
"allow-once"
|
||||
} else {
|
||||
"reject-once"
|
||||
};
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":frame["id"],
|
||||
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
|
||||
.await?;
|
||||
asked.push(frame);
|
||||
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
|
||||
break frame;
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
};
|
||||
Ok(Outcome {
|
||||
response,
|
||||
frames,
|
||||
asked,
|
||||
})
|
||||
}
|
||||
|
||||
async fn new_session(
|
||||
client: &mut Client,
|
||||
manager: &RuntimeThreadManager,
|
||||
workspace: &Path,
|
||||
turns: Vec<CannedTurn>,
|
||||
plan: bool,
|
||||
) -> Result<String> {
|
||||
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
|
||||
let (created, _) = client
|
||||
.request("session/new", json!({"cwd":workspace}))
|
||||
.await?;
|
||||
let session = created["result"]["sessionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
|
||||
.to_string();
|
||||
if plan {
|
||||
let (set, _) = client
|
||||
.request(
|
||||
"session/set_mode",
|
||||
json!({"sessionId":session,"modeId":"plan"}),
|
||||
)
|
||||
.await?;
|
||||
assert!(set.get("error").is_none(), "{set}");
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(64 << 20)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(180), gates())
|
||||
.await
|
||||
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
|
||||
})
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("extension gates thread panicked"))?
|
||||
}
|
||||
|
||||
async fn gates() -> Result<()> {
|
||||
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
|
||||
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
|
||||
return Ok(());
|
||||
};
|
||||
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
|
||||
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
|
||||
let host = fixture.manager(node.clone());
|
||||
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
|
||||
let workspace = fixture.workspace().to_path_buf();
|
||||
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
|
||||
let server = dir.path().join("fixture-mcp.mjs");
|
||||
std::fs::write(&server, GATES_MCP_FIXTURE)?;
|
||||
let mcp_config = dir.path().join("mcp.json");
|
||||
std::fs::write(
|
||||
&mcp_config,
|
||||
serde_json::to_vec(&json!({"servers":{
|
||||
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
|
||||
"off":{"command":node,"args":[server],"disabled":true}
|
||||
}}))?,
|
||||
)?;
|
||||
let runtime_dir = dir.path().join("runtime");
|
||||
let mut config = fixture_config();
|
||||
config.set_feature("mcp", true).unwrap();
|
||||
config.set_feature("extension_host", true).unwrap();
|
||||
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
RuntimeThreadManagerConfig {
|
||||
mcp_auth_context: Default::default(),
|
||||
data_dir: runtime_dir.clone(),
|
||||
task_data_dir: runtime_dir,
|
||||
sessions_dir: None,
|
||||
max_active_threads: 16,
|
||||
},
|
||||
fixture.registry(),
|
||||
codewhale_engine::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
let frontend = capture_frontend(
|
||||
config,
|
||||
"fixture-model".into(),
|
||||
workspace.clone(),
|
||||
Arc::clone(&manager),
|
||||
codewhale_engine::session_manager::default_sessions_dir()?,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let (mut client, agent_read, agent_write) = Client::connect(false);
|
||||
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let manager_for_script = Arc::clone(&manager);
|
||||
let script =
|
||||
async {
|
||||
let manager = manager_for_script;
|
||||
let result = async {
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion":1,"clientCapabilities":{}}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"n1",
|
||||
"cc_call",
|
||||
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
|
||||
),
|
||||
canned::simple_text_turn("nested allowed"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
|
||||
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
|
||||
assert!(allowed.titles()[0].starts_with("cc_call"));
|
||||
let announced = allowed.announced_ids();
|
||||
for request in &allowed.asked {
|
||||
let id = request["params"]["toolCall"]["toolCallId"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
announced.iter().any(|known| known == id),
|
||||
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
|
||||
);
|
||||
}
|
||||
let nested = &allowed.asked[1]["params"]["toolCall"];
|
||||
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
|
||||
assert_eq!(nested["kind"], "edit", "{nested}");
|
||||
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
|
||||
assert!(nested.get("status").is_none(), "{nested}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("nested.txt"))?,
|
||||
"via-plugin"
|
||||
);
|
||||
|
||||
std::fs::remove_file(workspace.join("nested.txt"))?;
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"n2",
|
||||
"cc_call",
|
||||
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
|
||||
),
|
||||
canned::simple_text_turn("nested denied"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
|
||||
assert!(denied.response.get("error").is_none(), "{}", denied.response);
|
||||
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
|
||||
assert!(
|
||||
!workspace.join("nested.txt").exists(),
|
||||
"a rejected nested write must not run"
|
||||
);
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("r1", "ctx_probe", "{}"),
|
||||
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
|
||||
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
|
||||
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
|
||||
canned::simple_text_turn("rejections done"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
|
||||
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
|
||||
let rendered = rejected.rendered();
|
||||
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
|
||||
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
|
||||
assert!(!rendered.contains("note.txt"), "{rendered}");
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("p1", "ctx_probe", "{}"),
|
||||
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
|
||||
canned::simple_text_turn("plan done"),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let plan = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
|
||||
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
|
||||
assert!(!plan.rendered().contains("mcp-ran:"));
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
|
||||
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
|
||||
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
|
||||
canned::tool_call_turn(
|
||||
"f4",
|
||||
"start_mcp_server",
|
||||
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
|
||||
),
|
||||
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
|
||||
canned::simple_text_turn("fabricated done"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
|
||||
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
|
||||
assert!(!fabricated.rendered().contains("mcp-ran:"));
|
||||
|
||||
client.request("shutdown", json!({})).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = client.write.shutdown().await;
|
||||
}
|
||||
result
|
||||
};
|
||||
let (served, scripted) = tokio::join!(connection, script);
|
||||
served?;
|
||||
scripted?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
host.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -296,53 +296,6 @@ async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds()
|
||||
fixture.bind_replacement(None).await.unwrap();
|
||||
}
|
||||
|
||||
async fn assert_gone_store_spelled_as_is_discarded(
|
||||
label: &str,
|
||||
spell: impl FnOnce(&Path) -> PathBuf,
|
||||
) {
|
||||
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
|
||||
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
|
||||
let spelled = spell(&fixture.harness.root);
|
||||
assert_ne!(spelled, fixture.owner.data_dir);
|
||||
assert!(matches!(
|
||||
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
&spelled,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
root.join("alias").join("runtime")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
|
||||
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
|
||||
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
|
||||
assert!(upper.exists(), "this test needs a case-insensitive volume");
|
||||
upper.join("RUNTIME")
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_owner_with_a_missing_store_is_never_discarded() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
|
||||
|
||||
+2
-30
@@ -117,7 +117,7 @@ struct Cli {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BOOL",
|
||||
help = "Control usage reporting (default on; Codewhale + PostHog; \
|
||||
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
|
||||
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
|
||||
)]
|
||||
telemetry: Option<bool>,
|
||||
@@ -1957,7 +1957,7 @@ enum ConfigCommand {
|
||||
Unset {
|
||||
key: String,
|
||||
},
|
||||
/// Review usage reporting by Codewhale and PostHog (default on).
|
||||
/// Review aggregate usage counting by Codewhale and PostHog (default on).
|
||||
Telemetry {
|
||||
/// Optional compatibility form: enable future sessions under this policy version.
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
@@ -6792,10 +6792,6 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
|
||||
codewhale_config::TELEMETRY_FLOOR_ENV,
|
||||
if floor { "1" } else { "0" },
|
||||
);
|
||||
set_tui_env(
|
||||
codewhale_config::TELEMETRY_SOURCE_ENV,
|
||||
resolved_runtime.telemetry_source.as_str(),
|
||||
);
|
||||
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
|
||||
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
|
||||
}
|
||||
@@ -7230,30 +7226,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
|
||||
let _lock = env_lock();
|
||||
let _guards = [
|
||||
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
|
||||
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
|
||||
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
|
||||
];
|
||||
|
||||
let cli = parse_ok(&["codewhale"]);
|
||||
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
|
||||
runtime.telemetry = false;
|
||||
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
|
||||
apply_tui_env(&cli, &runtime, &[]);
|
||||
|
||||
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
|
||||
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
|
||||
assert_eq!(
|
||||
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
|
||||
Ok("config")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_command_definition_is_consistent() {
|
||||
Cli::command().debug_assert();
|
||||
|
||||
@@ -140,17 +140,48 @@ impl EnvRuntimeOverrides {
|
||||
Self {
|
||||
provider,
|
||||
provider_source,
|
||||
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
|
||||
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
|
||||
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
|
||||
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
|
||||
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
|
||||
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
|
||||
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
|
||||
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
|
||||
novita_model: env_non_blank(&["NOVITA_MODEL"]),
|
||||
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
|
||||
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
|
||||
model: std::env::var("CODEWHALE_MODEL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
|
||||
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
volcengine_model: std::env::var("VOLCENGINE_MODEL")
|
||||
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
|
||||
.or_else(|_| std::env::var("WANJIE_MODEL"))
|
||||
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openrouter_model: std::env::var("OPENROUTER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
moonshot_model: std::env::var("MOONSHOT_MODEL")
|
||||
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
|
||||
.or_else(|_| std::env::var("KIMI_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
|
||||
.or_else(|_| std::env::var("MIMO_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
|
||||
.or_else(|_| std::env::var("MIMO_MODE"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
novita_model: std::env::var("NOVITA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
fireworks_model: std::env::var("FIREWORKS_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
arcee_model: std::env::var("ARCEE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
|
||||
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
|
||||
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
|
||||
@@ -185,7 +216,9 @@ impl EnvRuntimeOverrides {
|
||||
None
|
||||
}
|
||||
}),
|
||||
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
|
||||
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
|
||||
.ok()
|
||||
.and_then(|value| match parse_http_headers(&value) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
@@ -194,85 +227,290 @@ impl EnvRuntimeOverrides {
|
||||
}
|
||||
})
|
||||
.filter(|headers| !headers.is_empty()),
|
||||
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
|
||||
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
|
||||
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
|
||||
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
|
||||
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
|
||||
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
|
||||
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
|
||||
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
|
||||
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
|
||||
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
|
||||
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
|
||||
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
|
||||
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
|
||||
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
|
||||
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
|
||||
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
|
||||
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
|
||||
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
|
||||
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
|
||||
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
|
||||
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
|
||||
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
|
||||
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
|
||||
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
|
||||
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
|
||||
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
|
||||
together_model: env_non_blank(&["TOGETHER_MODEL"]),
|
||||
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
|
||||
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
|
||||
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
|
||||
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
|
||||
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
|
||||
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
|
||||
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
|
||||
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
|
||||
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
|
||||
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
|
||||
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
|
||||
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
|
||||
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
|
||||
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
|
||||
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
|
||||
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
|
||||
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
|
||||
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
|
||||
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
|
||||
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
|
||||
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
|
||||
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
|
||||
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
|
||||
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
|
||||
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
|
||||
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
|
||||
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
|
||||
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
|
||||
xai_model: env_non_blank(&["XAI_MODEL"]),
|
||||
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
|
||||
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
|
||||
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
|
||||
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
|
||||
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
|
||||
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
|
||||
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
|
||||
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
|
||||
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
|
||||
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
|
||||
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
|
||||
csdn_model: env_non_blank(&["CSDN_MODEL"]),
|
||||
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
|
||||
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
|
||||
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
|
||||
.or_else(|_| std::env::var("NIM_BASE_URL"))
|
||||
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_base_url: std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
|
||||
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ARK_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
|
||||
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
|
||||
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
|
||||
.or_else(|_| std::env::var("MIMO_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
novita_base_url: std::env::var("NOVITA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
arcee_base_url: std::env::var("ARCEE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
|
||||
.or_else(|_| std::env::var("KIMI_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sglang_base_url: std::env::var("SGLANG_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
vllm_base_url: std::env::var("VLLM_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
|
||||
.or_else(|_| std::env::var("HF_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
|
||||
.or_else(|_| std::env::var("HF_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelscope_model: std::env::var("MODELSCOPE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
together_base_url: std::env::var("TOGETHER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
together_model: std::env::var("TOGETHER_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("BAIDU_QIANFAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
qianfan_model: std::env::var("QIANFAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("BAIDU_QIANFAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
|
||||
.or_else(|_| std::env::var("CODEX_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
|
||||
.or_else(|_| std::env::var("CODEX_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
anthropic_model: std::env::var("ANTHROPIC_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openmodel_model: std::env::var("OPENMODEL_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zai_base_url: std::env::var("ZAI_BASE_URL")
|
||||
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
|
||||
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
|
||||
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zai_model: std::env::var("ZAI_MODEL")
|
||||
.or_else(|_| std::env::var("Z_AI_MODEL"))
|
||||
.or_else(|_| std::env::var("ZHIPU_MODEL"))
|
||||
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
|
||||
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
|
||||
.or_else(|_| std::env::var("GLM_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
|
||||
.or_else(|_| std::env::var("STEP_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
stepfun_model: std::env::var("STEPFUN_MODEL")
|
||||
.or_else(|_| std::env::var("STEP_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
minimax_model: std::env::var("MINIMAX_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sakana_base_url: std::env::var("SAKANA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
sakana_model: std::env::var("SAKANA_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
longcat_model: std::env::var("LONGCAT_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MODEL_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
meta_model: std::env::var("META_MODEL_API_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MODEL_API_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
xai_base_url: std::env::var("XAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xai_model: std::env::var("XAI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
google_base_url: std::env::var("GOOGLE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("GEMINI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
google_model: std::env::var("GOOGLE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("GEMINI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}),
|
||||
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
mistral_model: std::env::var("MISTRAL_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
telecomjs_model: std::env::var("TELECOMJS_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
edenai_base_url: std::env::var("EDENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
edenai_model: std::env::var("EDENAI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
zenmux_model: std::env::var("ZENMUX_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
csdn_base_url: std::env::var("CSDN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
csdn_model: std::env::var("CSDN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
concentrate_model: std::env::var("CONCENTRATE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
|
||||
// an origin this route would refuse to send a bearer to is
|
||||
// dropped here rather than resolved into a route.
|
||||
codewhale_base_url: provider::codewhale_api_base_from_env(),
|
||||
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
|
||||
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
|
||||
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
|
||||
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
|
||||
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
|
||||
codewhale_model: std::env::var("CODEWHALE_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"telemetry",
|
||||
SettingKind::Bool(TELEMETRY),
|
||||
"true",
|
||||
"false",
|
||||
ui(
|
||||
TAB_TRUST,
|
||||
"network",
|
||||
|
||||
@@ -27,11 +27,10 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
|
||||
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
|
||||
),
|
||||
_ => stream_open_timeout_from_env(
|
||||
crate::env_overrides::env_non_blank(&[
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
])
|
||||
.as_deref(),
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -51,7 +50,9 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
|
||||
/// `false`, ...) leaves HTTP/2 on.
|
||||
#[must_use]
|
||||
pub fn force_http1_from_env() -> bool {
|
||||
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
@@ -33,8 +33,6 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
|
||||
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
|
||||
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
|
||||
|
||||
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
|
||||
|
||||
/// Whether an environment-level kill switch forces telemetry off here.
|
||||
///
|
||||
/// A floor is *not* the same as "telemetry resolved to false": off is the
|
||||
@@ -94,32 +92,6 @@ impl TelemetrySource {
|
||||
Self::Default => "default",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"cli" => Some(Self::Cli),
|
||||
"env" => Some(Self::Env),
|
||||
"config" => Some(Self::Config),
|
||||
"default" => Some(Self::Default),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
|
||||
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
|
||||
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
|
||||
let (on, source) = resolved_telemetry_consent(file_telemetry);
|
||||
match (source, dispatched_telemetry_source()) {
|
||||
(TelemetrySource::Env, Some(declared)) => (on, declared),
|
||||
_ => (on, source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the telemetry environment override, reporting an unreadable value
|
||||
|
||||
@@ -8763,7 +8763,6 @@ struct TelemetryEnvGuard {
|
||||
codewhale: Option<OsString>,
|
||||
deepseek: Option<OsString>,
|
||||
floor: Option<OsString>,
|
||||
source: Option<OsString>,
|
||||
codewhale_endpoint: Option<OsString>,
|
||||
deepseek_endpoint: Option<OsString>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
@@ -8776,7 +8775,6 @@ impl TelemetryEnvGuard {
|
||||
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
|
||||
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
|
||||
floor: env::var_os(TELEMETRY_FLOOR_ENV),
|
||||
source: env::var_os(TELEMETRY_SOURCE_ENV),
|
||||
// The endpoint variables are cleared too. Resolution now has a
|
||||
// shipped default, so an ambient endpoint in the developer's or
|
||||
// CI's environment is the difference between pinning the default
|
||||
@@ -8790,7 +8788,6 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
|
||||
}
|
||||
@@ -8811,13 +8808,6 @@ impl TelemetryEnvGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_source(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::set_var(TELEMETRY_SOURCE_ENV, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_floor(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
@@ -8831,7 +8821,6 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8852,10 +8841,6 @@ impl Drop for TelemetryEnvGuard {
|
||||
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_FLOOR_ENV),
|
||||
}
|
||||
match self.source.take() {
|
||||
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_SOURCE_ENV),
|
||||
}
|
||||
match self.codewhale_endpoint.take() {
|
||||
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
|
||||
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
|
||||
@@ -9010,57 +8995,6 @@ fn telemetry_consent_names_its_source() {
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Config);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("env");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
|
||||
guard.set("true");
|
||||
guard.set_floor("0");
|
||||
guard.set_source("default");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(on);
|
||||
assert_eq!(source, TelemetrySource::Default);
|
||||
|
||||
guard.set("false");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("cli");
|
||||
let (on, source) = dispatched_telemetry_consent(None);
|
||||
assert!(!on);
|
||||
assert_eq!(source, TelemetrySource::Cli);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_source("config");
|
||||
let (on, source) = dispatched_telemetry_consent(Some(false));
|
||||
assert!(!on);
|
||||
assert_eq!(
|
||||
source,
|
||||
TelemetrySource::Env,
|
||||
"a source with no dispatcher floor statement is not believed"
|
||||
);
|
||||
|
||||
guard.clear();
|
||||
guard.set("0");
|
||||
guard.set_floor("1");
|
||||
guard.set_source("nonsense");
|
||||
let (_, source) = dispatched_telemetry_consent(None);
|
||||
assert_eq!(source, TelemetrySource::Env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_runtime_options_mints_a_route_candidate() {
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
@@ -10767,60 +10701,6 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let names = [
|
||||
"CODEWHALE_FORCE_HTTP1",
|
||||
"DEEPSEEK_FORCE_HTTP1",
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
];
|
||||
let saved: Vec<(&str, Option<OsString>)> = names
|
||||
.iter()
|
||||
.map(|name| (*name, env::var_os(name)))
|
||||
.collect();
|
||||
// SAFETY: env mutation is serialized by `env_lock` and restored below.
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_MODEL", "");
|
||||
env::set_var("DEEPSEEK_MODEL", "legacy-model");
|
||||
env::set_var("CODEWHALE_BASE_URL", " ");
|
||||
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
|
||||
env::set_var("MOONSHOT_BASE_URL", "");
|
||||
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
|
||||
env::set_var("CODEWHALE_FORCE_HTTP1", "");
|
||||
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
|
||||
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
|
||||
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
|
||||
}
|
||||
|
||||
let env_overrides = EnvRuntimeOverrides::load();
|
||||
let force_http1 = crate::stream_timeouts::force_http1_from_env();
|
||||
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
|
||||
|
||||
unsafe {
|
||||
for (name, value) in saved {
|
||||
match value {
|
||||
Some(value) => env::set_var(name, value),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
|
||||
assert_eq!(
|
||||
env_overrides.active_route_base_url.as_deref(),
|
||||
Some("https://legacy.example/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
env_overrides.moonshot_base_url.as_deref(),
|
||||
Some("https://kimi.example/v1")
|
||||
);
|
||||
assert!(force_http1);
|
||||
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_legacy_state_copy_leaves_no_partial_primary() {
|
||||
|
||||
@@ -1183,7 +1183,7 @@ impl Git {
|
||||
overrides: Vec<(String, &'static str)>,
|
||||
) -> anyhow::Result<Command> {
|
||||
let mut command = Self::review_base(workspace)?;
|
||||
let mut count = 3;
|
||||
let mut count = 2;
|
||||
for (key, value) in overrides {
|
||||
// A subsection may contain '='; `-c key=value` would then
|
||||
// override a different key. Separate env fields preserve it.
|
||||
@@ -1195,7 +1195,7 @@ impl Git {
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
|
||||
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
|
||||
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
|
||||
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
|
||||
use anyhow::Context;
|
||||
@@ -1257,7 +1257,7 @@ impl Git {
|
||||
// Both phases must observe the same effective repository config.
|
||||
.env_remove("GIT_CONFIG")
|
||||
.env_remove("GIT_CONFIG_PARAMETERS")
|
||||
.env("GIT_CONFIG_COUNT", "3")
|
||||
.env("GIT_CONFIG_COUNT", "2")
|
||||
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
|
||||
.env("GIT_CONFIG_VALUE_0", "false")
|
||||
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
|
||||
@@ -1265,8 +1265,6 @@ impl Git {
|
||||
"GIT_CONFIG_VALUE_1",
|
||||
if cfg!(windows) { "NUL" } else { "/dev/null" },
|
||||
)
|
||||
.env("GIT_CONFIG_KEY_2", "core.quotePath")
|
||||
.env("GIT_CONFIG_VALUE_2", "false")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1");
|
||||
Ok(command)
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
const SOURCES = {
|
||||
compatibility: join(root, 'src/dsh/compatibility.json'),
|
||||
hostRoot: join(root, 'src/root.ts'),
|
||||
shellHooks: join(root, 'src/dsh/shell-hooks.ts'),
|
||||
seatTable: join(root, '../../app-server/src/runtime_api/plugins.rs'),
|
||||
catalog: join(root, 'src/dsh/inspect-catalog.json'),
|
||||
}
|
||||
|
||||
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
const SERVICES = {
|
||||
tools: {
|
||||
summary: 'The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: ToolDefinition): () => void', summary: 'Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer.' },
|
||||
{ name: 'get', signature: 'get(name: string, agent: Agent): { name: string } | undefined', summary: 'Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call\'s own agent.' },
|
||||
],
|
||||
},
|
||||
commands: {
|
||||
summary: 'Slash commands a person types. A handler runs only when the person types the command.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void', summary: 'Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer.' },
|
||||
],
|
||||
},
|
||||
storage: {
|
||||
summary: 'Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'get', signature: 'get(key: string): Promise<Json | undefined>', summary: 'Read one stored value.' },
|
||||
{ name: 'set', signature: 'set(key: string, value: Json): Promise<void>', summary: 'Store one JSON value, within the bounded key, value and owner limits.' },
|
||||
{ name: 'delete', signature: 'delete(key: string): Promise<void>', summary: 'Remove one stored value.' },
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
summary: 'Skill roots: reviewed folders of skills added to the Engine\'s skill catalog.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerRoot', signature: 'registerRoot(definition: { path: string }): () => void', summary: 'Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused.' },
|
||||
],
|
||||
},
|
||||
sessions: {
|
||||
summary: 'The session of the calling tool invocation.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'flush', signature: 'flush(session: Session): Promise<boolean>', summary: 'Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available.' },
|
||||
],
|
||||
},
|
||||
userQuestions: {
|
||||
summary: 'Questions put to the person through the Engine\'s own question card.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'ask', signature: 'ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>', summary: 'Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported.' },
|
||||
],
|
||||
},
|
||||
subagents: {
|
||||
summary: 'One independent child agent a root tool call may start.',
|
||||
availability: 'tool-invocation',
|
||||
methods: [
|
||||
{ name: 'getProvider', signature: 'getProvider(id: \'spawn\'): Provider', summary: 'Describe the one provider: a fresh child with no inherited conversation.' },
|
||||
{ name: 'startActivation', signature: 'startActivation(options: { provider: \'spawn\', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: \'caller\' | \'parent\' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>', summary: 'Start the child through the Engine\'s approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child.' },
|
||||
{ name: 'result', signature: 'activation.result: Promise<{ stopReason: string, output: { type: \'text\', text: string }[], diagnostic?: string }>', summary: 'A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks.' },
|
||||
{ name: 'dispose', signature: 'activation.dispose(): Promise<void>', summary: 'Stop that exact child and its own descendants.' },
|
||||
],
|
||||
},
|
||||
agents: {
|
||||
summary: 'Reserved. The host offers no methods on agents.',
|
||||
availability: 'none',
|
||||
methods: [],
|
||||
},
|
||||
systemPrompt: {
|
||||
summary: 'The child agent\'s persona slot, reachable only inside an agent/created listener.',
|
||||
availability: 'child-agent-hook',
|
||||
methods: [
|
||||
{ name: 'section', signature: 'agent.ctx.systemPrompt.section({ name: \'deployment:persona-prefix\', order: agent.ctx.systemPrompt.getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\'), text: string }): () => void', summary: 'Set the child\'s persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine\'s own system prompt cannot be replaced.' },
|
||||
],
|
||||
},
|
||||
logger: {
|
||||
summary: 'Cordis logging. Every line becomes a host log notification attributed to the plugin.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'logger', signature: 'ctx.logger(name?: string): Logger', summary: 'A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info.' },
|
||||
],
|
||||
},
|
||||
events: {
|
||||
summary: 'Cordis event dispatch. Query the host Event provider for the events this host fires.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'on', signature: 'ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider).' },
|
||||
{ name: 'once', signature: 'ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event once.' },
|
||||
{ name: 'emit', signature: 'ctx.emit(name: string, ...args: any[]): void', summary: 'Dispatch an event to its listeners.' },
|
||||
],
|
||||
},
|
||||
reflect: {
|
||||
summary: 'Cordis service reflection. A plugin may provide its own services but not the Engine\'s.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'provide', signature: 'ctx.reflect.provide(name: string, value?: any): () => void', summary: 'Provide a service under a name. Names in refusedServices are refused: the Engine owns them.' },
|
||||
{ name: 'get', signature: 'ctx.reflect.get(name: string, strict?: boolean): any', summary: 'Read a provided service.' },
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
summary: 'Cordis plugin registry.',
|
||||
availability: 'always',
|
||||
cordis: true,
|
||||
methods: [
|
||||
{ name: 'get', signature: 'ctx.registry.get(plugin: Plugin): Runtime | undefined', summary: 'Look up the runtime record of a plugin.' },
|
||||
{ name: 'resolve', signature: 'ctx.registry.resolve(plugin: Plugin): Function | undefined', summary: 'Resolve a plugin shape to its callback.' },
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
summary: 'Attributed sections of the Engine\'s runtime prompt. A plugin cannot replace the system prompt.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerSection', signature: 'registerSection(definition: { id: string, text: string, interpolate?: \'model-cwd\' }): () => void', summary: 'Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded.' },
|
||||
],
|
||||
},
|
||||
avatars: {
|
||||
summary: 'Avatar packs for the terminal.',
|
||||
availability: 'always',
|
||||
methods: [
|
||||
{ name: 'registerPack', signature: 'registerPack(definition: { path: string }): () => void', summary: 'Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin.' },
|
||||
],
|
||||
},
|
||||
mcp: {
|
||||
summary: 'Proposals of MCP servers. The Engine owns connection, credentials and tool calls.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'registerServer', signature: 'registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void', summary: 'Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition.' },
|
||||
],
|
||||
},
|
||||
shellHooks: {
|
||||
summary: 'Claude Code and Codex style command hooks, run by the Engine.',
|
||||
availability: 'native-review',
|
||||
methods: [
|
||||
{ name: 'register', signature: 'register(definition: { dialect: \'claude-code\' | \'codex\', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void', summary: 'Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported.' },
|
||||
],
|
||||
},
|
||||
remote: {
|
||||
summary: 'Remote pairing, served by the Engine.',
|
||||
availability: 'review-grant',
|
||||
methods: [
|
||||
{ name: 'status', signature: 'status(): Promise<Status>', summary: 'Whether remote access is on.' },
|
||||
{ name: 'pair', signature: 'pair(options?: { label?: string }): Promise<Pairing>', summary: 'Start a pairing.' },
|
||||
{ name: 'clients', signature: 'clients(): Promise<Client[]>', summary: 'List paired clients.' },
|
||||
{ name: 'revoke', signature: 'revoke(id: string): Promise<void>', summary: 'Revoke one grant by the id pair returned.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const AVAILABILITY = {
|
||||
always: 'Available to every plugin that injects it.',
|
||||
'tool-invocation': 'Answers only inside a live tool call of the plugin.',
|
||||
'child-agent-hook': 'Answers only inside an agent/created listener, on the child agent.',
|
||||
'native-review': 'Available to a reviewed native plugin.',
|
||||
'review-grant': 'Served by the Engine only when the plugin\'s review granted it.',
|
||||
none: 'No methods.',
|
||||
}
|
||||
|
||||
const EVENTS_SUPPORTED = [
|
||||
{
|
||||
name: 'tools/pre-execute',
|
||||
status: 'supported',
|
||||
mode: 'ordered listeners, native hooks first, then plugin listeners in registration order',
|
||||
signature: 'ctx.on(\'tools/pre-execute\', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)',
|
||||
summary: 'Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: \'deny\', reason }, { kind: \'ask\', reason? }, { kind: \'revise\', input }, { kind: \'annotate\', text } or { kind: \'abstain\' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.',
|
||||
options: 'prepend and global are refused.',
|
||||
},
|
||||
{
|
||||
name: 'agent/created',
|
||||
status: 'supported',
|
||||
mode: 'listeners of the owning plugin only',
|
||||
signature: 'ctx.on(\'agent/created\', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)',
|
||||
summary: 'Fires for a child agent that this plugin\'s own subagents.startActivation creates, before the child starts. The listener may set the child\'s persona and deny tools to it; the window closes when the listeners return.',
|
||||
options: 'global is refused.',
|
||||
},
|
||||
]
|
||||
|
||||
const REFUSED_EVENT_REASON = 'Not projected by this host: no core projection exists, so a listener would never fire.'
|
||||
|
||||
const ROW_PACKAGES = {
|
||||
'@deepseek-ai/dsh-mcp-client': {
|
||||
summary: 'An MCP server row. The Engine proposes the server and owns the connection.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['transport', 'serverName'],
|
||||
properties: {
|
||||
transport: { type: 'string', enum: ['stdio', 'streamable-http', 'sse'] },
|
||||
serverName: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$' },
|
||||
command: { type: 'string', description: 'stdio only: the program to run.' },
|
||||
args: { type: 'array', items: { type: 'string' }, description: 'stdio only.' },
|
||||
env: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
cwd: { type: 'string', description: 'stdio only: a bundle-relative directory, no .. components.' },
|
||||
url: { type: 'string', description: 'streamable-http and sse: the endpoint, with no credentials, query or fragment.' },
|
||||
headers: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
|
||||
toolCallTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000 },
|
||||
failOnStartupError: { type: 'boolean', const: false, description: 'Only false: the Engine connects lazily and reports failures through /mcp.' },
|
||||
},
|
||||
},
|
||||
limits: ['stdio rows name a command; streamable-http and sse rows name a url.', 'Reconnect and instruction bounds belong to the Engine and are not configurable here.'],
|
||||
example: { transport: 'stdio', serverName: 'docs', command: 'node', args: ['server.js'], cwd: '.' },
|
||||
},
|
||||
'@deepseek-ai/dsh-skill-filesystem': {
|
||||
summary: 'A skill folder row, limited to reviewed roots inside the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['includeDefaultRoots', 'watch', 'customSkillDirs'],
|
||||
properties: {
|
||||
providerName: { type: 'string', maxLength: 64 },
|
||||
includeDefaultRoots: { type: 'boolean', const: false, description: 'Must be false: ambient roots are not read.' },
|
||||
watch: { type: 'boolean', const: false, description: 'Must be false: there is no file watcher.' },
|
||||
customSkillDirs: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Bundle-relative skill roots, no duplicates.' },
|
||||
},
|
||||
},
|
||||
limits: ['Both includeDefaultRoots and watch must be written out as false.'],
|
||||
example: { includeDefaultRoots: false, watch: false, customSkillDirs: ['skills'] },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-claude-code': {
|
||||
summary: 'Claude Code style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-hooks-codex': {
|
||||
summary: 'Codex style command hooks read from a reviewed file in the bundle.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['configPath'],
|
||||
properties: {
|
||||
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
|
||||
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
|
||||
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
|
||||
},
|
||||
},
|
||||
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
|
||||
example: { configPath: 'hooks/hooks.json' },
|
||||
},
|
||||
'@deepseek-ai/dsh-persona': {
|
||||
summary: 'Text added before and after the Engine\'s own prompt for this plugin\'s entry.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['prefix'],
|
||||
properties: {
|
||||
prefix: { type: 'string', description: 'At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded.' },
|
||||
suffix: { type: 'string', description: 'Same limits as prefix.' },
|
||||
complete: { type: 'boolean', const: false, description: 'Replacing the whole prompt belongs to the Engine.' },
|
||||
includeRuntimeContext: { type: 'boolean', const: true, description: 'Suppressing the runtime context belongs to the Engine.' },
|
||||
},
|
||||
},
|
||||
limits: ['Needs an exact Engine-selected entry scope.'],
|
||||
example: { prefix: 'Answer in short sentences.' },
|
||||
},
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
tokens: [],
|
||||
colorSchemes: ['light', 'dark'],
|
||||
scheme: 'The frame sets data-theme and color-scheme on its document to the app\'s current scheme and follows changes.',
|
||||
api: [
|
||||
{ name: 'ctx.theme.getTheme', signature: 'getTheme(): { active: { colorScheme: \'light\' | \'dark\' } }', summary: 'The scheme in use now.' },
|
||||
{ name: 'ctx.theme.overrideTokens', signature: 'overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void', summary: 'Set custom properties on the frame\'s document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer.' },
|
||||
],
|
||||
notes: [
|
||||
'The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.',
|
||||
'The frame\'s background is transparent and its margin is zero, so the plugin sits on the app\'s own surface.',
|
||||
],
|
||||
}
|
||||
|
||||
const SEAT_NOTES = {
|
||||
'customize.plugins.detail': { drawn: true, where: 'The plugin\'s own page under Customize, Plugins.' },
|
||||
'conversation.composer.dock': { drawn: false, where: 'Under the composer. The desktop app does not place it yet.' },
|
||||
'conversation.chat.node': { drawn: false, where: 'Inside the conversation. The desktop app does not place it yet.' },
|
||||
'workpane.panel': { drawn: false, where: 'The work pane. The desktop app does not place it yet.' },
|
||||
'sidebar.section': { drawn: false, where: 'The sidebar. The desktop app does not place it yet.' },
|
||||
'command.row': { drawn: false, where: 'The command list. The desktop app does not place it yet.' },
|
||||
}
|
||||
|
||||
function between(text, open, close, what) {
|
||||
const start = text.indexOf(open)
|
||||
if (start < 0) throw new Error(`${what}: cannot find ${open}`)
|
||||
const end = text.indexOf(close, start + open.length)
|
||||
if (end < 0) throw new Error(`${what}: cannot find the end of ${open}`)
|
||||
return text.slice(start + open.length, end)
|
||||
}
|
||||
|
||||
export function parseSeatTable(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const constants = Object.fromEntries([...text.matchAll(/const ([A-Z_]+): &str = "([^"]+)";/g)].map((m) => [m[1], m[2]]))
|
||||
const resolve = (literal, name) => literal !== undefined ? literal : constants[name]
|
||||
const seatBlock = between(text, 'const PLUGIN_UI_SEATS: [&str; ', '];', 'seat list')
|
||||
const declaredSeats = Number(seatBlock.slice(0, seatBlock.indexOf(']')))
|
||||
const seatBody = seatBlock.slice(seatBlock.indexOf('= [') + 3)
|
||||
const seats = [...seatBody.matchAll(/"([^"]+)"|([A-Z_]+)/g)].map((m) => resolve(m[1], m[2]))
|
||||
if (seats.length !== declaredSeats || seats.some((seat) => seat === undefined)) throw new Error('the seat list does not match its declared length')
|
||||
const tableBlock = between(text, 'const DSH_SEAT_TABLE: [(&str, DshSeat); ', '];\n', 'slot table')
|
||||
const declaredSlots = Number(tableBlock.slice(0, tableBlock.indexOf(']')))
|
||||
const tableBody = tableBlock.slice(tableBlock.indexOf('= [') + 3)
|
||||
const slots = [...tableBody.matchAll(/\(\s*"([^"]+)",\s*DshSeat::(Seat|Unsupported)\(\s*(?:"([^"]*)"|([A-Z_]+))\s*\)\s*,?\s*\)/g)].map((m) => {
|
||||
const value = resolve(m[3], m[4])
|
||||
return m[2] === 'Seat' ? { slot: m[1], seat: value, supported: true } : { slot: m[1], seat: null, supported: false, reason: value }
|
||||
})
|
||||
if (slots.length !== declaredSlots) throw new Error(`the slot table declares ${declaredSlots} rows and ${slots.length} were read`)
|
||||
for (const slot of slots) if (slot.supported && !seats.includes(slot.seat)) throw new Error(`slot ${slot.slot} maps to ${slot.seat}, which is not a seat`)
|
||||
return { seats, slots }
|
||||
}
|
||||
|
||||
export function parseRefusedEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const unsupportedDshLifecycle = new Set([', '])', 'refused events')
|
||||
return [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
}
|
||||
|
||||
export function parseHookEvents(source) {
|
||||
const text = source.replace(/\r\n/g, '\n')
|
||||
const body = between(text, 'const EVENTS:Record<string,string>={', '}', 'hook events')
|
||||
return [...body.matchAll(/([A-Za-z]+):'([a-z_]+)'/g)].map((m) => ({ event: m[1], point: m[2] }))
|
||||
}
|
||||
|
||||
export function buildCatalog(paths = SOURCES) {
|
||||
const compatibility = JSON.parse(read(paths.compatibility))
|
||||
const provided = Object.entries(compatibility.provided_services).flatMap(([group, names]) => names.map((name) => ({ group, name })))
|
||||
const known = new Set(Object.keys(SERVICES))
|
||||
const missing = provided.filter(({ name }) => !known.has(name)).map(({ name }) => name)
|
||||
if (missing.length) throw new Error(`compatibility.json provides services with no entry in the method table: ${missing.join(', ')}`)
|
||||
const extra = [...known].filter((name) => !provided.some((entry) => entry.name === name))
|
||||
if (extra.length) throw new Error(`the method table names services the host does not provide: ${extra.join(', ')}`)
|
||||
|
||||
const services = provided.map(({ group, name }) => {
|
||||
const entry = SERVICES[name]
|
||||
return {
|
||||
key: name,
|
||||
group,
|
||||
summary: entry.summary,
|
||||
availability: entry.availability,
|
||||
availabilityText: AVAILABILITY[entry.availability],
|
||||
methods: entry.methods,
|
||||
}
|
||||
})
|
||||
|
||||
const refusedEvents = parseRefusedEvents(read(paths.hostRoot))
|
||||
const hookEvents = parseHookEvents(read(paths.shellHooks))
|
||||
const events = [
|
||||
...EVENTS_SUPPORTED,
|
||||
...refusedEvents.map((name) => ({ name, status: 'refused', summary: REFUSED_EVENT_REASON })),
|
||||
]
|
||||
|
||||
const rowPackages = Object.keys(compatibility.row_bridges)
|
||||
const unknownRows = rowPackages.filter((name) => !ROW_PACKAGES[name])
|
||||
const staleRows = Object.keys(ROW_PACKAGES).filter((name) => !rowPackages.includes(name))
|
||||
if (unknownRows.length || staleRows.length) throw new Error(`row packages differ from compatibility.json: missing ${unknownRows.join(', ')}; stale ${staleRows.join(', ')}`)
|
||||
const rows = rowPackages.map((name) => ({
|
||||
id: `row:${name}`,
|
||||
name,
|
||||
bridge: compatibility.row_bridges[name],
|
||||
summary: ROW_PACKAGES[name].summary,
|
||||
limits: ROW_PACKAGES[name].limits,
|
||||
example: ROW_PACKAGES[name].example,
|
||||
...(name.includes('hooks-') ? { hookEvents } : {}),
|
||||
schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: name, ...ROW_PACKAGES[name].schema },
|
||||
}))
|
||||
|
||||
const table = parseSeatTable(read(paths.seatTable))
|
||||
const unknownSeatNotes = table.seats.filter((seat) => !SEAT_NOTES[seat])
|
||||
const staleSeatNotes = Object.keys(SEAT_NOTES).filter((seat) => !table.seats.includes(seat))
|
||||
if (unknownSeatNotes.length || staleSeatNotes.length) throw new Error(`seat notes differ from the Engine seat list: missing ${unknownSeatNotes.join(', ')}; stale ${staleSeatNotes.join(', ')}`)
|
||||
const seats = table.seats.map((seat) => ({ seat, drawn: SEAT_NOTES[seat].drawn, where: SEAT_NOTES[seat].where }))
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
targetDshVersion: compatibility.target_dsh_version,
|
||||
host: {
|
||||
services,
|
||||
refusedServices: compatibility.refused_services,
|
||||
events,
|
||||
cordisInternalEvents: 'Cordis fires its own internal/* lifecycle events. They are not part of this host\'s supported contract and are not listed.',
|
||||
config: rows,
|
||||
},
|
||||
client: {
|
||||
seats,
|
||||
slots: table.slots,
|
||||
theme: THEME,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCatalog(catalog) {
|
||||
return `${JSON.stringify(catalog, null, 2)}\n`
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const text = renderCatalog(buildCatalog())
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = existsOrEmpty(SOURCES.catalog)
|
||||
if (current !== text) {
|
||||
process.stderr.write('inspect-catalog.json is out of date: run node scripts/gen-inspect-catalog.mjs\n')
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
writeFileSync(SOURCES.catalog, text)
|
||||
}
|
||||
}
|
||||
|
||||
function existsOrEmpty(path) {
|
||||
try {
|
||||
return read(path)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"targetDshVersion": "0.2.2-rc.1",
|
||||
"host": {
|
||||
"services": [
|
||||
{
|
||||
"key": "tools",
|
||||
"group": "dsh",
|
||||
"summary": "The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: ToolDefinition): () => void",
|
||||
"summary": "Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(name: string, agent: Agent): { name: string } | undefined",
|
||||
"summary": "Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call's own agent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "commands",
|
||||
"group": "dsh",
|
||||
"summary": "Slash commands a person types. A handler runs only when the person types the command.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void",
|
||||
"summary": "Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "storage",
|
||||
"group": "dsh",
|
||||
"summary": "Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "get(key: string): Promise<Json | undefined>",
|
||||
"summary": "Read one stored value."
|
||||
},
|
||||
{
|
||||
"name": "set",
|
||||
"signature": "set(key: string, value: Json): Promise<void>",
|
||||
"summary": "Store one JSON value, within the bounded key, value and owner limits."
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"signature": "delete(key: string): Promise<void>",
|
||||
"summary": "Remove one stored value."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "skills",
|
||||
"group": "dsh",
|
||||
"summary": "Skill roots: reviewed folders of skills added to the Engine's skill catalog.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerRoot",
|
||||
"signature": "registerRoot(definition: { path: string }): () => void",
|
||||
"summary": "Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "sessions",
|
||||
"group": "dsh",
|
||||
"summary": "The session of the calling tool invocation.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "flush",
|
||||
"signature": "flush(session: Session): Promise<boolean>",
|
||||
"summary": "Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "userQuestions",
|
||||
"group": "dsh",
|
||||
"summary": "Questions put to the person through the Engine's own question card.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "ask",
|
||||
"signature": "ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>",
|
||||
"summary": "Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "subagents",
|
||||
"group": "dsh",
|
||||
"summary": "One independent child agent a root tool call may start.",
|
||||
"availability": "tool-invocation",
|
||||
"availabilityText": "Answers only inside a live tool call of the plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "getProvider",
|
||||
"signature": "getProvider(id: 'spawn'): Provider",
|
||||
"summary": "Describe the one provider: a fresh child with no inherited conversation."
|
||||
},
|
||||
{
|
||||
"name": "startActivation",
|
||||
"signature": "startActivation(options: { provider: 'spawn', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: 'caller' | 'parent' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>",
|
||||
"summary": "Start the child through the Engine's approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child."
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"signature": "activation.result: Promise<{ stopReason: string, output: { type: 'text', text: string }[], diagnostic?: string }>",
|
||||
"summary": "A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks."
|
||||
},
|
||||
{
|
||||
"name": "dispose",
|
||||
"signature": "activation.dispose(): Promise<void>",
|
||||
"summary": "Stop that exact child and its own descendants."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "agents",
|
||||
"group": "dsh",
|
||||
"summary": "Reserved. The host offers no methods on agents.",
|
||||
"availability": "none",
|
||||
"availabilityText": "No methods.",
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"key": "systemPrompt",
|
||||
"group": "dsh",
|
||||
"summary": "The child agent's persona slot, reachable only inside an agent/created listener.",
|
||||
"availability": "child-agent-hook",
|
||||
"availabilityText": "Answers only inside an agent/created listener, on the child agent.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "section",
|
||||
"signature": "agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: string }): () => void",
|
||||
"summary": "Set the child's persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine's own system prompt cannot be replaced."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "logger",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis logging. Every line becomes a host log notification attributed to the plugin.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "logger",
|
||||
"signature": "ctx.logger(name?: string): Logger",
|
||||
"summary": "A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "events",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis event dispatch. Query the host Event provider for the events this host fires.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "on",
|
||||
"signature": "ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider)."
|
||||
},
|
||||
{
|
||||
"name": "once",
|
||||
"signature": "ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
|
||||
"summary": "Listen to an event once."
|
||||
},
|
||||
{
|
||||
"name": "emit",
|
||||
"signature": "ctx.emit(name: string, ...args: any[]): void",
|
||||
"summary": "Dispatch an event to its listeners."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reflect",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis service reflection. A plugin may provide its own services but not the Engine's.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "provide",
|
||||
"signature": "ctx.reflect.provide(name: string, value?: any): () => void",
|
||||
"summary": "Provide a service under a name. Names in refusedServices are refused: the Engine owns them."
|
||||
},
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.reflect.get(name: string, strict?: boolean): any",
|
||||
"summary": "Read a provided service."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "registry",
|
||||
"group": "cordis",
|
||||
"summary": "Cordis plugin registry.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "get",
|
||||
"signature": "ctx.registry.get(plugin: Plugin): Runtime | undefined",
|
||||
"summary": "Look up the runtime record of a plugin."
|
||||
},
|
||||
{
|
||||
"name": "resolve",
|
||||
"signature": "ctx.registry.resolve(plugin: Plugin): Function | undefined",
|
||||
"summary": "Resolve a plugin shape to its callback."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "prompt",
|
||||
"group": "codewhale",
|
||||
"summary": "Attributed sections of the Engine's runtime prompt. A plugin cannot replace the system prompt.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerSection",
|
||||
"signature": "registerSection(definition: { id: string, text: string, interpolate?: 'model-cwd' }): () => void",
|
||||
"summary": "Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "avatars",
|
||||
"group": "codewhale",
|
||||
"summary": "Avatar packs for the terminal.",
|
||||
"availability": "always",
|
||||
"availabilityText": "Available to every plugin that injects it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerPack",
|
||||
"signature": "registerPack(definition: { path: string }): () => void",
|
||||
"summary": "Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mcp",
|
||||
"group": "codewhale",
|
||||
"summary": "Proposals of MCP servers. The Engine owns connection, credentials and tool calls.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "registerServer",
|
||||
"signature": "registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void",
|
||||
"summary": "Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "shellHooks",
|
||||
"group": "codewhale",
|
||||
"summary": "Claude Code and Codex style command hooks, run by the Engine.",
|
||||
"availability": "native-review",
|
||||
"availabilityText": "Available to a reviewed native plugin.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "register",
|
||||
"signature": "register(definition: { dialect: 'claude-code' | 'codex', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void",
|
||||
"summary": "Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "remote",
|
||||
"group": "codewhale",
|
||||
"summary": "Remote pairing, served by the Engine.",
|
||||
"availability": "review-grant",
|
||||
"availabilityText": "Served by the Engine only when the plugin's review granted it.",
|
||||
"methods": [
|
||||
{
|
||||
"name": "status",
|
||||
"signature": "status(): Promise<Status>",
|
||||
"summary": "Whether remote access is on."
|
||||
},
|
||||
{
|
||||
"name": "pair",
|
||||
"signature": "pair(options?: { label?: string }): Promise<Pairing>",
|
||||
"summary": "Start a pairing."
|
||||
},
|
||||
{
|
||||
"name": "clients",
|
||||
"signature": "clients(): Promise<Client[]>",
|
||||
"summary": "List paired clients."
|
||||
},
|
||||
{
|
||||
"name": "revoke",
|
||||
"signature": "revoke(id: string): Promise<void>",
|
||||
"summary": "Revoke one grant by the id pair returned."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refusedServices": [
|
||||
"shellHooks",
|
||||
"loader",
|
||||
"approval",
|
||||
"agents",
|
||||
"subagents",
|
||||
"sessions",
|
||||
"llm",
|
||||
"sandboxPolicy",
|
||||
"credentials",
|
||||
"fs",
|
||||
"subprocess",
|
||||
"systemPrompt",
|
||||
"runtimeLoop",
|
||||
"tools",
|
||||
"commands",
|
||||
"prompt",
|
||||
"storage",
|
||||
"skills",
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"name": "tools/pre-execute",
|
||||
"status": "supported",
|
||||
"mode": "ordered listeners, native hooks first, then plugin listeners in registration order",
|
||||
"signature": "ctx.on('tools/pre-execute', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)",
|
||||
"summary": "Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: 'deny', reason }, { kind: 'ask', reason? }, { kind: 'revise', input }, { kind: 'annotate', text } or { kind: 'abstain' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.",
|
||||
"options": "prepend and global are refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/created",
|
||||
"status": "supported",
|
||||
"mode": "listeners of the owning plugin only",
|
||||
"signature": "ctx.on('agent/created', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)",
|
||||
"summary": "Fires for a child agent that this plugin's own subagents.startActivation creates, before the child starts. The listener may set the child's persona and deny tools to it; the window closes when the listeners return.",
|
||||
"options": "global is refused."
|
||||
},
|
||||
{
|
||||
"name": "agent/pre-step",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "agent/turn-stopping",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "tools/post-execute",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/start",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
},
|
||||
{
|
||||
"name": "subagent/end",
|
||||
"status": "refused",
|
||||
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
|
||||
}
|
||||
],
|
||||
"cordisInternalEvents": "Cordis fires its own internal/* lifecycle events. They are not part of this host's supported contract and are not listed.",
|
||||
"config": [
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-mcp-client",
|
||||
"name": "@deepseek-ai/dsh-mcp-client",
|
||||
"bridge": "mcp",
|
||||
"summary": "An MCP server row. The Engine proposes the server and owns the connection.",
|
||||
"limits": [
|
||||
"stdio rows name a command; streamable-http and sse rows name a url.",
|
||||
"Reconnect and instruction bounds belong to the Engine and are not configurable here."
|
||||
],
|
||||
"example": {
|
||||
"transport": "stdio",
|
||||
"serverName": "docs",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"server.js"
|
||||
],
|
||||
"cwd": "."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-mcp-client",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"transport",
|
||||
"serverName"
|
||||
],
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"streamable-http",
|
||||
"sse"
|
||||
]
|
||||
},
|
||||
"serverName": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "stdio only: the program to run."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "stdio only."
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "stdio only: a bundle-relative directory, no .. components."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "streamable-http and sse: the endpoint, with no credentials, query or fragment."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 0,
|
||||
"description": "Must be empty or absent: credentials stay with the Engine."
|
||||
},
|
||||
"toolCallTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000
|
||||
},
|
||||
"failOnStartupError": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Only false: the Engine connects lazily and reports failures through /mcp."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-skill-filesystem",
|
||||
"name": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"bridge": "skills",
|
||||
"summary": "A skill folder row, limited to reviewed roots inside the bundle.",
|
||||
"limits": [
|
||||
"Both includeDefaultRoots and watch must be written out as false."
|
||||
],
|
||||
"example": {
|
||||
"includeDefaultRoots": false,
|
||||
"watch": false,
|
||||
"customSkillDirs": [
|
||||
"skills"
|
||||
]
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-skill-filesystem",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"includeDefaultRoots",
|
||||
"watch",
|
||||
"customSkillDirs"
|
||||
],
|
||||
"properties": {
|
||||
"providerName": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"includeDefaultRoots": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: ambient roots are not read."
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Must be false: there is no file watcher."
|
||||
},
|
||||
"customSkillDirs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Bundle-relative skill roots, no duplicates."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-claude-code",
|
||||
"name": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"bridge": "hooks-claude-code",
|
||||
"summary": "Claude Code style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-claude-code",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-hooks-codex",
|
||||
"name": "@deepseek-ai/dsh-hooks-codex",
|
||||
"bridge": "hooks-codex",
|
||||
"summary": "Codex style command hooks read from a reviewed file in the bundle.",
|
||||
"limits": [
|
||||
"projectDir is refused: each process uses the current workspace.",
|
||||
"Only command hooks run; non-command and asynchronous hooks fail the row.",
|
||||
"At most 128 commands per row."
|
||||
],
|
||||
"example": {
|
||||
"configPath": "hooks/hooks.json"
|
||||
},
|
||||
"hookEvents": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"point": "session_start"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"point": "message_submit"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"point": "tool_call_before"
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"point": "tool_call_after"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"point": "turn_end"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStart",
|
||||
"point": "subagent_spawn"
|
||||
},
|
||||
{
|
||||
"event": "SubagentStop",
|
||||
"point": "subagent_complete"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-hooks-codex",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"configPath"
|
||||
],
|
||||
"properties": {
|
||||
"configPath": {
|
||||
"type": "string",
|
||||
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
|
||||
},
|
||||
"pluginRoot": {
|
||||
"type": "string",
|
||||
"description": "Absent, \".\", or this reviewed bundle root."
|
||||
},
|
||||
"defaultTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"multipleOf": 1000,
|
||||
"description": "Whole seconds, used when a hook sets none; default 600000."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "row:@deepseek-ai/dsh-persona",
|
||||
"name": "@deepseek-ai/dsh-persona",
|
||||
"bridge": "persona",
|
||||
"summary": "Text added before and after the Engine's own prompt for this plugin's entry.",
|
||||
"limits": [
|
||||
"Needs an exact Engine-selected entry scope."
|
||||
],
|
||||
"example": {
|
||||
"prefix": "Answer in short sentences."
|
||||
},
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "@deepseek-ai/dsh-persona",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"prefix"
|
||||
],
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"description": "At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded."
|
||||
},
|
||||
"suffix": {
|
||||
"type": "string",
|
||||
"description": "Same limits as prefix."
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean",
|
||||
"const": false,
|
||||
"description": "Replacing the whole prompt belongs to the Engine."
|
||||
},
|
||||
"includeRuntimeContext": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
"description": "Suppressing the runtime context belongs to the Engine."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"client": {
|
||||
"seats": [
|
||||
{
|
||||
"seat": "customize.plugins.detail",
|
||||
"drawn": true,
|
||||
"where": "The plugin's own page under Customize, Plugins."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.composer.dock",
|
||||
"drawn": false,
|
||||
"where": "Under the composer. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "conversation.chat.node",
|
||||
"drawn": false,
|
||||
"where": "Inside the conversation. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "workpane.panel",
|
||||
"drawn": false,
|
||||
"where": "The work pane. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "sidebar.section",
|
||||
"drawn": false,
|
||||
"where": "The sidebar. The desktop app does not place it yet."
|
||||
},
|
||||
{
|
||||
"seat": "command.row",
|
||||
"drawn": false,
|
||||
"where": "The command list. The desktop app does not place it yet."
|
||||
}
|
||||
],
|
||||
"slots": [
|
||||
{
|
||||
"slot": "plugins.bundle.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.row.config",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "plugins.detail.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.section",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugin.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.general.item",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "settings.plugins.tab",
|
||||
"seat": "customize.plugins.detail",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.turnTail",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.assistant-actions",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.chat.text-interaction",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.user.text.node",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "tool.call.toolview",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.view",
|
||||
"seat": "conversation.chat.node",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.overlay",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.input.right",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.composer.dock",
|
||||
"seat": "conversation.composer.dock",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.right.pane.tab",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.conversation",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "main.page-menu",
|
||||
"seat": "workpane.panel",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.panellist",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.footer.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.session.row.leading",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "sidebar.workspaces.session.row.action",
|
||||
"seat": "sidebar.section",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.actions",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "conversation.session.header.utilities",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
},
|
||||
{
|
||||
"slot": "contextMenu.icon",
|
||||
"seat": null,
|
||||
"supported": false,
|
||||
"reason": "no ADE seat yet"
|
||||
}
|
||||
],
|
||||
"theme": {
|
||||
"tokens": [],
|
||||
"colorSchemes": [
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"scheme": "The frame sets data-theme and color-scheme on its document to the app's current scheme and follows changes.",
|
||||
"api": [
|
||||
{
|
||||
"name": "ctx.theme.getTheme",
|
||||
"signature": "getTheme(): { active: { colorScheme: 'light' | 'dark' } }",
|
||||
"summary": "The scheme in use now."
|
||||
},
|
||||
{
|
||||
"name": "ctx.theme.overrideTokens",
|
||||
"signature": "overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void",
|
||||
"summary": "Set custom properties on the frame's document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.",
|
||||
"The frame's background is transparent and its margin is zero, so the plugin sits on the app's own surface."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { isJson } from '../json.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './dsh-tools-compat.js'
|
||||
import catalogData from './inspect-catalog.json' with { type: 'json' }
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
export type InspectPlatform = 'host' | 'client'
|
||||
|
||||
export interface InspectMethod {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: JsonValue
|
||||
outputSchema: JsonValue
|
||||
}
|
||||
|
||||
export interface InspectProviderView {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
}
|
||||
|
||||
export interface InstalledRow {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
connected: boolean
|
||||
drawnSeats?: readonly string[]
|
||||
}
|
||||
|
||||
export interface InspectSources {
|
||||
tools?: (agent: unknown, signal: AbortSignal) => Promise<readonly JsonValue[]> | readonly JsonValue[]
|
||||
installed?: (signal: AbortSignal) => Promise<readonly InstalledRow[]> | readonly InstalledRow[]
|
||||
client?: () => ClientState
|
||||
}
|
||||
|
||||
export interface CordisInspect {
|
||||
list(): InspectProviderView[]
|
||||
query(platform: string, provider: string, method: string, input: JsonValue | undefined, agent?: unknown, signal?: AbortSignal): Promise<JsonValue>
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
platform: InspectPlatform
|
||||
id: string
|
||||
description: string
|
||||
methods: InspectMethod[]
|
||||
run(method: string, input: Record<string, JsonValue>, agent: unknown, signal: AbortSignal): Promise<any> | any
|
||||
}
|
||||
|
||||
const catalog = catalogData as any
|
||||
const EMPTY_INPUT: JsonValue = { type: 'object', properties: {}, additionalProperties: false }
|
||||
const ANY_OUTPUT: JsonValue = { description: 'JSON data owned by this inspect provider.' }
|
||||
const NO_CLIENT = 'No client is connected. Plugin screens are drawn by the desktop app; in a terminal nothing is drawn, so every slot reports drawn: false.'
|
||||
|
||||
const exactInput = (field: string, description: string): JsonValue => ({
|
||||
type: 'object',
|
||||
properties: { [field]: { type: 'string', description } },
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const method = (name: string, description: string, inputSchema: JsonValue = EMPTY_INPUT, outputSchema: JsonValue = ANY_OUTPUT): InspectMethod =>
|
||||
({ name, description, inputSchema, outputSchema })
|
||||
|
||||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
function serviceView(service: any): JsonValue {
|
||||
return {
|
||||
key: service.key,
|
||||
group: service.group,
|
||||
summary: service.summary,
|
||||
availability: service.availability,
|
||||
availabilityText: service.availabilityText,
|
||||
methods: service.methods,
|
||||
}
|
||||
}
|
||||
|
||||
function serviceProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Service',
|
||||
description: 'The services this host gives plugins, with their methods, and the services a plugin may not provide. A name that is not listed does not exist here.',
|
||||
methods: [method('listService', 'Without input: the directory of services with one-line summaries and method signatures. With service: that service\'s exact contract, with availability. These are services a plugin injects, not DSH\'s full catalogue.',
|
||||
exactInput('service', 'Exact service key. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.service
|
||||
if (key === undefined) {
|
||||
return {
|
||||
services: catalog.host.services.map((s: any) => ({ key: s.key, group: s.group, summary: s.summary, availability: s.availability, signatures: s.methods.map((m: any) => m.signature) })),
|
||||
refusedServices: catalog.host.refusedServices,
|
||||
note: 'refusedServices are names a plugin may not provide: the Engine owns them. Service methods named by the host Event and Config providers are separate.',
|
||||
targetDshVersion: catalog.targetDshVersion,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.services.find((s: any) => s.key === key)
|
||||
if (!found) throw new Error(`Host service "${String(key)}" is not provided by this host. Call without service for the directory.`)
|
||||
return { service: serviceView(found), targetDshVersion: catalog.targetDshVersion }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventProvider(): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Event',
|
||||
description: 'The events a plugin may listen to in this host, and the ones the host refuses.',
|
||||
methods: [method('listEvents', 'Without input: the directory of events with status supported or refused. With event: its exact contract.',
|
||||
exactInput('event', 'Exact event name. Omit it for the directory.'))],
|
||||
run(_name, input) {
|
||||
const key = input.event
|
||||
if (key === undefined) {
|
||||
return {
|
||||
events: catalog.host.events.map((e: any) => ({ name: e.name, status: e.status, summary: e.summary })),
|
||||
note: catalog.host.cordisInternalEvents,
|
||||
}
|
||||
}
|
||||
const found = catalog.host.events.find((e: any) => e.name === key)
|
||||
if (!found) throw new Error(`Host event "${String(key)}" is not known to this host. Call without event for the directory.`)
|
||||
return { event: found }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG_INPUT: JsonValue = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entry: { type: 'string', description: 'Exact entry id from the directory; returns that entry\'s Config schema.' },
|
||||
name: { type: 'string', description: 'Exact package or plugin name; limits the directory to it.' },
|
||||
offset: { type: 'integer', description: 'Zero-based directory offset; defaults to 0.' },
|
||||
limit: { type: 'integer', description: 'Directory page size, from 1 to 100; defaults to 25.' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
function configProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'host', id: 'Config',
|
||||
description: 'Config a composition row may carry: the packages a row may name, each with the config it accepts, and the plugins installed on this host with their state.',
|
||||
methods: [method('listConfigs', 'Without entry: a page of the directory (row packages first, then installed plugins) with total and nextOffset. With entry: one row package\'s exact JSON Schema, limits and a valid example. Installed plugins are listed with their state; their own config is not described here.', CONFIG_INPUT)],
|
||||
async run(_name, input, _agent, signal) {
|
||||
const rows: any[] = catalog.host.config.map((r: any) => ({ id: r.id, name: r.name, kind: 'row-package', status: 'schema', bridge: r.bridge, summary: r.summary }))
|
||||
const installed = sources.installed ? await sources.installed(signal) : []
|
||||
for (const row of installed) rows.push({ id: `plugin:${row.id}`, name: row.name, kind: 'installed-plugin', status: row.state })
|
||||
if (input.entry !== undefined) {
|
||||
const pkg = catalog.host.config.find((r: any) => r.id === input.entry)
|
||||
if (pkg) return { entry: pkg.id, name: pkg.name, kind: 'row-package', bridge: pkg.bridge, summary: pkg.summary, limits: pkg.limits, example: pkg.example, ...pkg.hookEvents ? { hookEvents: pkg.hookEvents } : {}, schema: pkg.schema }
|
||||
const live = rows.find((r) => r.id === input.entry)
|
||||
if (live) return { entry: live.id, name: live.name, kind: live.kind, status: live.status, schema: null, note: 'An installed plugin\'s own Config is not described by this host.' }
|
||||
throw new Error(`Host Config entry "${String(input.entry)}" is not listed. Call without entry for the directory.`)
|
||||
}
|
||||
const filtered = input.name === undefined ? rows : rows.filter((r) => r.name === input.name)
|
||||
const offset = typeof input.offset === 'number' ? input.offset : 0
|
||||
const limit = typeof input.limit === 'number' ? input.limit : 25
|
||||
if (offset < 0 || limit < 1 || limit > 100) throw new Error('Host Config directory needs offset >= 0 and 1 <= limit <= 100')
|
||||
const page = filtered.slice(offset, offset + limit)
|
||||
const next = offset + page.length
|
||||
return { entries: page, total: filtered.length, ...next < filtered.length ? { nextOffset: next } : {} }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolProvider(sources: InspectSources): Provider | undefined {
|
||||
const source = sources.tools
|
||||
if (!source) return undefined
|
||||
return {
|
||||
platform: 'host', id: 'Tool',
|
||||
description: 'Tools visible to the requesting agent.',
|
||||
methods: [method('listTools', 'Return every tool schema currently callable by this agent.')],
|
||||
async run(_name, _input, agent, signal) {
|
||||
return { tools: [...await source(agent, signal)] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SUBTREE_INPUT = exactInput('root', 'Exact slot name to select. Omit it for every slot.')
|
||||
|
||||
function slotsProvider(sources: InspectSources): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Slots',
|
||||
description: 'The DSH slot names this host accepts, the seat each maps to, and whether the connected client draws that seat.',
|
||||
methods: [method('listSubTree', 'Return every slot with its seat and drawn state; with root, also that slot selected. Slots are static names here, not a live tree, and their props are not described by this host: a plugin registers on a slot, the Engine serves it on the mapped seat, and the client draws only the seats it places.', SUBTREE_INPUT)],
|
||||
run(_name, input) {
|
||||
const state = sources.client?.() ?? { connected: false }
|
||||
const drawn = new Set(state.connected ? state.drawnSeats ?? catalog.client.seats.filter((s: any) => s.drawn).map((s: any) => s.seat) : [])
|
||||
const slots = catalog.client.slots.map((s: any) => ({
|
||||
type: 'slot', name: s.slot, seat: s.seat, supported: s.supported,
|
||||
drawn: s.supported && drawn.has(s.seat),
|
||||
...s.reason ? { reason: s.reason } : {},
|
||||
}))
|
||||
const seats = catalog.client.seats.map((s: any) => ({ seat: s.seat, drawn: drawn.has(s.seat), where: s.where }))
|
||||
const out: Record<string, JsonValue> = {
|
||||
client: state.connected ? { connected: true } : { connected: false, message: NO_CLIENT },
|
||||
seats, trees: slots, referencedTypes: [],
|
||||
}
|
||||
if (input.root !== undefined) {
|
||||
const selected = slots.find((s: any) => s.name === input.root)
|
||||
out.requestedRoot = { name: input.root, available: selected !== undefined }
|
||||
if (selected) out.selected = selected
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function themeProvider(): Provider {
|
||||
return {
|
||||
platform: 'client', id: 'Theme',
|
||||
description: 'What the plugin frame provides for theming.',
|
||||
methods: [method('listTokens', 'Return the named tokens the frame provides (none), the colour schemes, and the theme calls a plugin has.')],
|
||||
run() {
|
||||
return { tokens: catalog.client.theme.tokens, colorSchemes: catalog.client.theme.colorSchemes, scheme: catalog.client.theme.scheme, api: catalog.client.theme.api, notes: catalog.client.theme.notes, referencedTypes: [] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCordisInspect(sources: InspectSources = {}): CordisInspect {
|
||||
const providers: Provider[] = [serviceProvider(), eventProvider(), configProvider(sources), toolProvider(sources), slotsProvider(sources), themeProvider()]
|
||||
.filter((p): p is Provider => p !== undefined)
|
||||
for (const provider of providers) {
|
||||
if (provider.id.trim() === '' || provider.description.trim() === '') throw new Error('Cordis inspect provider needs an id and a description')
|
||||
for (const m of provider.methods) {
|
||||
assertSupportedJsonSchema(m.inputSchema)
|
||||
assertSupportedJsonSchema(m.outputSchema)
|
||||
}
|
||||
}
|
||||
const label = (platform: InspectPlatform) => platform === 'host' ? 'Host' : 'Client'
|
||||
return {
|
||||
list() {
|
||||
return providers.map((p) => ({ platform: p.platform, id: p.id, description: p.description, methods: copy(p.methods) }))
|
||||
},
|
||||
async query(platform, providerId, methodName, input, agent, signal = new AbortController().signal) {
|
||||
if (platform !== 'host' && platform !== 'client') throw new Error(`Cordis inspect platform must be "host" or "client", not "${String(platform)}"`)
|
||||
const provider = providers.find((p) => p.platform === platform && p.id === providerId)
|
||||
if (!provider) throw new Error(`${label(platform)} Cordis inspect provider "${providerId}" is not registered`)
|
||||
const found = provider.methods.find((m) => m.name === methodName)
|
||||
if (!found) throw new Error(`Cordis inspect provider "${provider.id}" has no method "${methodName}"`)
|
||||
const violations = validateJsonSchemaValue(found.inputSchema, input ?? {}, 'input') as string[]
|
||||
if (violations.length > 0) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} rejected input: ${violations.join('; ')}`)
|
||||
signal.throwIfAborted()
|
||||
const data = await provider.run(methodName, (input ?? {}) as Record<string, JsonValue>, agent, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!isJson(data)) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} returned a non-JSON value`)
|
||||
return copy(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { build } from 'esbuild'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { after, test } from 'node:test'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { buildCatalog, parseSeatTable, renderCatalog } from '../scripts/gen-inspect-catalog.mjs'
|
||||
import { createCordisInspect } from '../src/dsh/inspect.ts'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = (path) => join(here, '..', 'src', path)
|
||||
const text = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
|
||||
const compatibility = JSON.parse(text(src('dsh/compatibility.json')))
|
||||
const catalog = JSON.parse(text(src('dsh/inspect-catalog.json')))
|
||||
const seatSource = text(join(here, '..', '..', '..', 'app-server', 'src', 'runtime_api', 'plugins.rs'))
|
||||
const rootSource = text(src('root.ts'))
|
||||
const inspect = createCordisInspect()
|
||||
const ask = (platform, provider, method, input) => inspect.query(platform, provider, method, input)
|
||||
|
||||
test('the committed catalog is what the generator writes now', () => {
|
||||
assert.equal(text(src('dsh/inspect-catalog.json')), renderCatalog(buildCatalog()))
|
||||
})
|
||||
|
||||
test('list names the providers DSH calls and leaves out Tool until a source exists', async () => {
|
||||
const ids = inspect.list().map((p) => `${p.platform}.${p.id}.${p.methods.map((m) => m.name).join(',')}`)
|
||||
assert.deepEqual(ids, ['host.Service.listService', 'host.Event.listEvents', 'host.Config.listConfigs', 'client.Slots.listSubTree', 'client.Theme.listTokens'])
|
||||
const withTools = createCordisInspect({ tools: () => [{ name: 'read_file' }] })
|
||||
assert.ok(withTools.list().some((p) => p.id === 'Tool'))
|
||||
assert.deepEqual(await withTools.query('host', 'Tool', 'listTools', undefined, {}), { tools: [{ name: 'read_file' }] })
|
||||
})
|
||||
|
||||
test('errors use DSH registry wording', async () => {
|
||||
await assert.rejects(ask('host', 'Nope', 'x'), /Host Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('client', 'Nope', 'x'), /Client Cordis inspect provider "Nope" is not registered/)
|
||||
await assert.rejects(ask('host', 'Service', 'nope'), /Cordis inspect provider "Service" has no method "nope"/)
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 3 }), /Host Cordis inspect Service.listService rejected input/)
|
||||
await assert.rejects(ask('host', 'Tool', 'listTools'), /not registered/)
|
||||
await assert.rejects(ask('page', 'Service', 'listService'), /platform must be/)
|
||||
})
|
||||
|
||||
test('every service the catalog names is provided, and no provided service is missing', () => {
|
||||
const provided = Object.values(compatibility.provided_services).flat().sort()
|
||||
assert.deepEqual(catalog.host.services.map((s) => s.key).sort(), provided)
|
||||
assert.deepEqual(catalog.host.refusedServices, compatibility.refused_services)
|
||||
})
|
||||
|
||||
test('methods match the compatibility tables where it has them', () => {
|
||||
const tables = { sessions: 'session_methods', userQuestions: 'user_question_methods', subagents: 'subagent_methods', agents: 'agents_methods', systemPrompt: 'system_prompt_methods' }
|
||||
for (const [service, table] of Object.entries(tables)) {
|
||||
const named = catalog.host.services.find((s) => s.key === service).methods.map((m) => m.name).sort()
|
||||
assert.deepEqual(named, Object.keys(compatibility[table]).sort(), service)
|
||||
}
|
||||
})
|
||||
|
||||
const classBody = (source, name) => {
|
||||
const start = source.indexOf(`class ${name} extends Service`)
|
||||
assert.ok(start >= 0, `class ${name}`)
|
||||
const rest = source.slice(start + 1)
|
||||
const next = rest.search(/\n\s*(export )?(class |function )/)
|
||||
return next < 0 ? rest : rest.slice(0, next)
|
||||
}
|
||||
const defines = (body, name) => new RegExp(`^\\s*(async\\s+)?${name}\\s*\\(`, 'm').test(body)
|
||||
|
||||
test('every curated method is defined on the class that serves it', () => {
|
||||
const served = {
|
||||
tools: ['root.ts', 'ToolsShim'], commands: ['shims/commands.ts', 'CommandsShim'], storage: ['root.ts', 'StorageShim'],
|
||||
skills: ['shims/skills.ts', 'SkillsShim'], prompt: ['shims/prompt.ts', 'PromptShim'], avatars: ['shims/avatars.ts', 'AvatarsShim'],
|
||||
mcp: ['shims/mcp.ts', 'McpShim'], shellHooks: ['shims/shell-hooks.ts', 'ShellHooks'], remote: ['dsh/remote.ts', 'RemoteService'],
|
||||
sessions: ['dsh/session-prompt.ts', 'SessionsService'], userQuestions: ['root.ts', 'UserQuestionsShim'],
|
||||
subagents: ['dsh/agents.ts', 'SubagentsShim'], systemPrompt: ['dsh/session-prompt.ts', 'SystemPromptService'],
|
||||
}
|
||||
const cordis = { events: ['events', ['on', 'once', 'emit']], reflect: ['reflect', ['provide', 'get']], registry: ['registry', ['get', 'resolve']] }
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
for (const service of catalog.host.services) {
|
||||
if (service.key === 'logger' || service.key === 'agents') continue
|
||||
if (cordis[service.key]) {
|
||||
const [file, names] = cordis[service.key]
|
||||
const body = text(join(here, '..', 'node_modules', '@deepseek-ai', 'cordis', 'lib', 'types', `${file}.d.ts`))
|
||||
assert.deepEqual(service.methods.map((m) => m.name).sort(), [...names].sort(), service.key)
|
||||
for (const name of names) assert.ok(defines(body, name), `${service.key}.${name}`)
|
||||
continue
|
||||
}
|
||||
const [file, cls] = served[service.key]
|
||||
const body = classBody(text(src(file)), cls)
|
||||
for (const m of service.methods) {
|
||||
if (service.key === 'subagents' && m.name === 'result') assert.match(agentsSource, /^\s*get result\(\)/m, 'subagents.result is a getter')
|
||||
else if (service.key === 'subagents' && m.name === 'dispose') assert.match(agentsSource, /^\s*dispose\(\)/m, 'subagents.dispose')
|
||||
else assert.ok(defines(body, m.name), `${service.key}.${m.name}`)
|
||||
}
|
||||
}
|
||||
assert.match(agentsSource, /^\s*section\(section: unknown\)/m)
|
||||
assert.match(agentsSource, /^\s*getSectionOrder\(name: unknown\)/m)
|
||||
})
|
||||
|
||||
test('the catalog states the contracts the host actually enforces', () => {
|
||||
const agentsSource = text(src('dsh/agents.ts'))
|
||||
const personaName = text(src('dsh/dsh-message-compat.js')).match(/PERSONA_PREFIX_SECTION = "([^"]+)"/)[1]
|
||||
const section = catalog.host.services.find((s) => s.key === 'systemPrompt').methods[0]
|
||||
assert.ok(section.signature.includes(`name: '${personaName}'`))
|
||||
assert.ok(section.signature.includes("getSectionOrder('DEPLOYMENT_PERSONA_PREFIX')"))
|
||||
assert.match(agentsSource, /PERSONA_ORDER = 0/)
|
||||
const subagents = catalog.host.services.find((s) => s.key === 'subagents').methods
|
||||
const result = subagents.find((m) => m.name === 'result')
|
||||
assert.match(result.signature, /^activation\.result: Promise<\{ stopReason: string, output: \{ type: 'text', text: string \}\[\]/)
|
||||
assert.ok(!result.signature.includes('result()'))
|
||||
assert.match(subagents.find((m) => m.name === 'startActivation').signature, /Promise<\{ childId: string, messageId: string, result: Promise</)
|
||||
})
|
||||
|
||||
test('an output that is not JSON is refused as DSH refuses it', async () => {
|
||||
const bad = createCordisInspect({ tools: () => [{ name: 'x', extra: undefined }] })
|
||||
await assert.rejects(bad.query('host', 'Tool', 'listTools', undefined, {}), /returned a non-JSON value/)
|
||||
})
|
||||
|
||||
test('every slot and its seat equal the Engine seat table', () => {
|
||||
const block = seatSource.slice(seatSource.indexOf('const DSH_SEAT_TABLE'), seatSource.indexOf('];', seatSource.indexOf('const DSH_SEAT_TABLE')))
|
||||
const names = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::/g)].map((m) => m[1])
|
||||
assert.equal(names.length, 30)
|
||||
assert.deepEqual(catalog.client.slots.map((s) => s.slot), names)
|
||||
const unsupported = [...block.matchAll(/\(\s*"([^"]+)",\s*DshSeat::Unsupported/g)].map((m) => m[1])
|
||||
assert.deepEqual(catalog.client.slots.filter((s) => !s.supported).map((s) => s.slot), unsupported)
|
||||
const seats = [...seatSource.slice(seatSource.indexOf('const PLUGIN_UI_SEATS'), seatSource.indexOf('];', seatSource.indexOf('const PLUGIN_UI_SEATS'))).matchAll(/"([^"]+)"/g)].map((m) => m[1])
|
||||
for (const slot of catalog.client.slots.filter((s) => s.supported)) assert.ok(slot.seat === 'customize.plugins.detail' || seats.includes(slot.seat), slot.slot)
|
||||
assert.equal(catalog.client.seats.length, 6)
|
||||
assert.deepEqual(parseSeatTable(seatSource.replace(/\n/g, '\r\n')), parseSeatTable(seatSource))
|
||||
})
|
||||
|
||||
test('the refused events equal the list in root.ts', () => {
|
||||
const body = rootSource.slice(rootSource.indexOf('unsupportedDshLifecycle = new Set(['), rootSource.indexOf('])', rootSource.indexOf('unsupportedDshLifecycle = new Set([')))
|
||||
const refused = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
assert.equal(refused.length, 5)
|
||||
assert.deepEqual(catalog.host.events.filter((e) => e.status === 'refused').map((e) => e.name), refused)
|
||||
for (const name of catalog.host.events.filter((e) => e.status === 'supported').map((e) => e.name)) assert.ok(rootSource.includes(`'${name}'`) || text(src('dsh/agents.ts')).includes(`'${name}'`), name)
|
||||
})
|
||||
|
||||
test('the Service provider answers a directory, one contract, and refuses a name the host lacks', async () => {
|
||||
const directory = await ask('host', 'Service', 'listService')
|
||||
assert.equal(directory.services.length, 18)
|
||||
assert.ok(!directory.services.some((s) => s.key === 'fs' || s.key === 'llm'))
|
||||
const one = await ask('host', 'Service', 'listService', { service: 'prompt' })
|
||||
assert.equal(one.service.methods[0].name, 'registerSection')
|
||||
await assert.rejects(ask('host', 'Service', 'listService', { service: 'llm' }), /not provided by this host/)
|
||||
})
|
||||
|
||||
test('the Event provider separates supported from refused', async () => {
|
||||
const { events } = await ask('host', 'Event', 'listEvents')
|
||||
assert.deepEqual(events.map((e) => e.status), ['supported', 'supported', 'refused', 'refused', 'refused', 'refused', 'refused'])
|
||||
assert.equal((await ask('host', 'Event', 'listEvents', { event: 'tools/pre-execute' })).event.name, 'tools/pre-execute')
|
||||
await assert.rejects(ask('host', 'Event', 'listEvents', { event: 'subagent/start-x' }), /not known/)
|
||||
})
|
||||
|
||||
test('the Config provider lists exactly the row packages, pages, and shows installed rows', async () => {
|
||||
const page = await ask('host', 'Config', 'listConfigs')
|
||||
assert.deepEqual(page.entries.map((e) => e.name).sort(), Object.keys(compatibility.row_bridges).sort())
|
||||
assert.equal(page.total, 5)
|
||||
const paged = await ask('host', 'Config', 'listConfigs', { limit: 2 })
|
||||
assert.equal(paged.entries.length, 2)
|
||||
assert.equal(paged.nextOffset, 2)
|
||||
const schema = await ask('host', 'Config', 'listConfigs', { entry: 'row:@deepseek-ai/dsh-persona' })
|
||||
assert.deepEqual(schema.schema.required, ['prefix'])
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { limit: 500 }), /limit/)
|
||||
await assert.rejects(ask('host', 'Config', 'listConfigs', { entry: 'row:nope' }), /not listed/)
|
||||
const live = createCordisInspect({ installed: () => [{ id: 'abc', name: 'hello', state: 'active' }] })
|
||||
const after = await live.query('host', 'Config', 'listConfigs', { name: 'hello' })
|
||||
assert.deepEqual(after.entries, [{ id: 'plugin:abc', name: 'hello', kind: 'installed-plugin', status: 'active' }])
|
||||
})
|
||||
|
||||
test('each row example passes the bridge that reads it', async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-inspect-'))
|
||||
after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const bridges = join(dir, 'bridges.mjs')
|
||||
await build({
|
||||
stdin: { contents: "export { normalizePersonaConfig } from './src/dsh/persona.ts'\nexport { reviewedMcpModule } from './src/dsh/mcp.ts'\nexport { reviewedSkillModule } from './src/dsh/skill-filesystem.ts'", resolveDir: join(here, '..'), loader: 'ts' },
|
||||
outfile: bridges, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent',
|
||||
})
|
||||
const { normalizePersonaConfig, reviewedMcpModule, reviewedSkillModule } = await import(pathToFileURL(bridges).href)
|
||||
const example = (name) => catalog.host.config.find((r) => r.name === name).example
|
||||
assert.doesNotThrow(() => normalizePersonaConfig(example('@deepseek-ai/dsh-persona')))
|
||||
assert.throws(() => normalizePersonaConfig({ prefix: 'x', complete: true }))
|
||||
const servers = []
|
||||
reviewedMcpModule().apply({ mcp: { registerServer: (d) => servers.push(d) } }, example('@deepseek-ai/dsh-mcp-client'))
|
||||
assert.equal(servers[0].serverName, 'docs')
|
||||
const roots = []
|
||||
reviewedSkillModule().apply({ skills: { registerRoot: (d) => roots.push(d) } }, example('@deepseek-ai/dsh-skill-filesystem'))
|
||||
assert.equal(roots.length, 1)
|
||||
})
|
||||
|
||||
test('Slots says no client is connected in a terminal and marks no slot drawn', async () => {
|
||||
const out = await ask('client', 'Slots', 'listSubTree')
|
||||
assert.equal(out.client.connected, false)
|
||||
assert.match(out.client.message, /No client is connected/)
|
||||
assert.equal(out.trees.length, 30)
|
||||
assert.ok(out.trees.every((s) => s.drawn === false))
|
||||
assert.deepEqual((await ask('client', 'Slots', 'listSubTree', { root: 'conversation.composer.dock' })).selected.seat, 'conversation.composer.dock')
|
||||
assert.equal((await ask('client', 'Slots', 'listSubTree', { root: 'nope' })).requestedRoot.available, false)
|
||||
})
|
||||
|
||||
test('Slots marks only the drawn seats when a client is connected', async () => {
|
||||
const connected = createCordisInspect({ client: () => ({ connected: true }) })
|
||||
const out = await connected.query('client', 'Slots', 'listSubTree')
|
||||
const drawn = out.trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.deepEqual(drawn, ['plugins.bundle.config', 'plugins.row.config', 'plugins.tab', 'plugins.detail.section', 'settings.section', 'settings.plugin.item', 'settings.general.item', 'settings.plugins.tab'])
|
||||
const all = createCordisInspect({ client: () => ({ connected: true, drawnSeats: ['conversation.composer.dock'] }) })
|
||||
const composer = (await all.query('client', 'Slots', 'listSubTree')).trees.filter((s) => s.drawn).map((s) => s.name)
|
||||
assert.ok(composer.includes('conversation.input.dock') && !composer.includes('plugins.tab'))
|
||||
assert.ok((await ask('client', 'Slots', 'listSubTree')).trees.filter((s) => !s.supported).every((s) => s.seat === null && s.reason))
|
||||
})
|
||||
|
||||
test('Theme reports that the frame defines no named tokens', async () => {
|
||||
const out = await ask('client', 'Theme', 'listTokens')
|
||||
assert.deepEqual(out.tokens, [])
|
||||
assert.deepEqual(out.colorSchemes, ['light', 'dark'])
|
||||
assert.ok(out.api.some((a) => a.name === 'ctx.theme.overrideTokens'))
|
||||
})
|
||||
@@ -44,9 +44,8 @@ use super::CodewhaleClient;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ToolNameWire, check_history_tool_names, documented_tool_name_limit, extract_sse_data_value,
|
||||
from_api_tool_name, next_sse_line, over_limit_tool_name_message, push_sse_event_data,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
|
||||
to_api_tool_name,
|
||||
};
|
||||
|
||||
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
|
||||
@@ -104,11 +103,6 @@ impl CodewhaleClient {
|
||||
repair_dangling_tool_uses(&mut messages);
|
||||
body["messages"] = Value::Array(messages);
|
||||
|
||||
let tool_name_limit = tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(self.api_provider, ToolNameWire::AnthropicMessages),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
if let Some(tools) = request.tools.as_ref()
|
||||
@@ -119,11 +113,6 @@ impl CodewhaleClient {
|
||||
};
|
||||
let mut wire_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -166,9 +155,6 @@ impl CodewhaleClient {
|
||||
if let Some(name) = forced_tool_name(tool_choice)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -1319,75 +1305,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_at_128() {
|
||||
let client = anthropic_test_client(None);
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
assert!(to_api_tool_name(mid).len() > 64 && to_api_tool_name(mid).len() <= 128);
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let mut request = request_with("claude-sonnet-4-6", None, None, None);
|
||||
request.tools = Some(vec![tool("read_file"), tool(mid), tool(&long)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file".to_string(), to_api_tool_name(mid)]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(&long)]);
|
||||
|
||||
request.tool_choice = Some(json!({"type": "tool", "name": long}));
|
||||
let error = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect_err("forcing a withheld tool is refused");
|
||||
assert!(error.to_string().contains("at most 128"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_offered_on_an_anthropic_wire_route_without_a_documented_limit()
|
||||
{
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let name = "a".repeat(100);
|
||||
assert_eq!(to_api_tool_name(&name).len(), 100);
|
||||
let client = minimax_test_client();
|
||||
let mut request = request_with("MiniMax-M2", None, None, None);
|
||||
request.tools = Some(vec![tool(&name)]);
|
||||
let (body, omitted) = client
|
||||
.build_anthropic_wire_body(&request, true)
|
||||
.expect("body builds");
|
||||
assert_eq!(body["tools"][0]["name"], name);
|
||||
assert!(omitted.is_empty());
|
||||
}
|
||||
|
||||
fn request_with(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
|
||||
@@ -41,10 +41,8 @@ use super::reasoning_effort::apply_reasoning_effort;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, SSE_BACKPRESSURE_SLEEP_MS,
|
||||
SSE_MAX_LINES_PER_CHUNK, ToolNameWire, acquire_stream_buffer, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, parse_usage, release_stream_buffer, system_to_instructions,
|
||||
to_api_tool_name, tool_name_exceeds_limit, tool_name_limit,
|
||||
SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, bounded_error_text, from_api_tool_name,
|
||||
parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line};
|
||||
use codewhale_config::route::RouteLimits;
|
||||
@@ -1210,20 +1208,11 @@ pub fn build_chat_wire_body_with_window(
|
||||
body["top_p"] = json!(top_p);
|
||||
}
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let tool_name_limit = tool_name_limit(provider, ToolNameWire::OpenAiCompatible);
|
||||
check_history_tool_names(
|
||||
request,
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible),
|
||||
)?;
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let route_projection =
|
||||
chat_route_tool_projection(provider, base_url, strict_tools_disabled);
|
||||
let mut chat_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(route_projection, &tool.input_schema, tool.strict) {
|
||||
ToolSchemaProjection::Exact {
|
||||
parameters,
|
||||
@@ -1258,9 +1247,6 @@ pub fn build_chat_wire_body_with_window(
|
||||
.iter()
|
||||
.any(|omitted| omitted == name || *omitted == to_api_tool_name(name))
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
@@ -3269,25 +3255,11 @@ fn deepseek_base_url_supports_strict_tools(base_url: &str) -> bool {
|
||||
|
||||
fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
|
||||
if let Some(choice_str) = choice.as_str() {
|
||||
return Some(match forced_tool_name(choice) {
|
||||
Some(name) => json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}),
|
||||
None => json!(choice_str),
|
||||
});
|
||||
return Some(json!(choice_str));
|
||||
}
|
||||
let Some(choice_type) = choice.get("type").and_then(Value::as_str) else {
|
||||
return Some(choice.clone());
|
||||
};
|
||||
if choice_type == "function"
|
||||
&& let Some(name) = forced_tool_name(choice)
|
||||
{
|
||||
return Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}));
|
||||
}
|
||||
|
||||
match choice_type {
|
||||
"auto" | "none" => Some(json!(choice_type)),
|
||||
@@ -6692,13 +6664,9 @@ mod image_block_wire_tests {
|
||||
//! most of them at once. The shape is fixed by OpenAI's spec: a `user`
|
||||
//! message whose `content` is an array of parts, with the image as
|
||||
//! `{"type":"image_url","image_url":{"url":…}}`.
|
||||
use super::{
|
||||
ChatWireBody, ProviderKind, build_chat_messages, build_chat_wire_body,
|
||||
map_tool_choice_for_chat, to_api_tool_name,
|
||||
};
|
||||
use super::{ProviderKind, build_chat_messages, build_chat_wire_body};
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest, Tool};
|
||||
use serde_json::{Value, json};
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
|
||||
|
||||
const DATA_URL: &str = "data:image/png;base64,QUJD";
|
||||
|
||||
@@ -7005,220 +6973,6 @@ mod image_block_wire_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn named_tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_object_tool_choice_encodes_the_tool_name() {
|
||||
let encoded = to_api_tool_name("web.run");
|
||||
assert_eq!(encoded, "web-x00002E-run");
|
||||
let expected = json!({"type": "function", "function": {"name": encoded}});
|
||||
for choice in [
|
||||
json!({"type": "function", "function": {"name": "web.run"}}),
|
||||
json!({"type": "function", "name": "web.run"}),
|
||||
json!({"type": "tool", "name": "web.run"}),
|
||||
json!("web.run"),
|
||||
] {
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&choice),
|
||||
Some(expected.clone()),
|
||||
"{choice}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
map_tool_choice_for_chat(&json!("required")),
|
||||
Some(json!("required"))
|
||||
);
|
||||
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool("web.run")]);
|
||||
request.tool_choice = Some(json!({"type": "function", "function": {"name": "web.run"}}));
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.body["tool_choice"], expected);
|
||||
let declared: Vec<&str> = body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(declared.contains(&"web-x00002E-run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_by_provider_limit() {
|
||||
let mid = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let long = format!(
|
||||
"mcp__tracker__{}.v2.issues.list-by-assignee",
|
||||
"a".repeat(70)
|
||||
);
|
||||
let mid_len = to_api_tool_name(mid).len();
|
||||
assert!(mid_len > 64 && mid_len <= 128, "{mid_len}");
|
||||
assert!(to_api_tool_name(&long).len() > 128);
|
||||
|
||||
let build = |provider, base_url: &str, choice: Option<Value>| {
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![
|
||||
named_tool("read_file"),
|
||||
named_tool(mid),
|
||||
named_tool(&long),
|
||||
]);
|
||||
request.tool_choice = choice;
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
};
|
||||
let declared = |body: &ChatWireBody| -> Vec<String> {
|
||||
body.body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let openai = build(ProviderKind::Openai, "https://api.openai.com/v1", None).expect("body");
|
||||
assert_eq!(declared(&openai), vec!["read_file".to_string()]);
|
||||
assert_eq!(
|
||||
openai.omitted_tool_names,
|
||||
vec![to_api_tool_name(mid), to_api_tool_name(&long)]
|
||||
);
|
||||
|
||||
let deepseek =
|
||||
build(ProviderKind::Deepseek, "https://api.deepseek.com", None).expect("body");
|
||||
assert_eq!(
|
||||
declared(&deepseek),
|
||||
vec!["read_file".to_string(), to_api_tool_name(mid)]
|
||||
);
|
||||
assert_eq!(deepseek.omitted_tool_names, vec![to_api_tool_name(&long)]);
|
||||
|
||||
let forced = json!({"type": "function", "function": {"name": mid}});
|
||||
let error = build(
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
Some(forced),
|
||||
)
|
||||
.err()
|
||||
.expect("forcing a withheld tool is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains(&format!("{mid_len} characters")),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("at most 64"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_tool_name_in_history_is_refused_with_the_limit() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("history name over the limit is refused");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(long),
|
||||
"{message}"
|
||||
);
|
||||
build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Deepseek,
|
||||
"https://api.deepseek.com",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("the same history fits a 128-character route");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_name_of_65_to_128_characters_is_sent_unchanged_on_undocumented_routes() {
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = request_with_image();
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "call_1".to_string(),
|
||||
name: long.to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
for (provider, base_url) in [
|
||||
(ProviderKind::Ollama, "http://localhost:11434/v1"),
|
||||
(ProviderKind::Custom, "http://localhost:8000/v1"),
|
||||
(ProviderKind::Minimax, "https://api.minimax.io/v1"),
|
||||
] {
|
||||
build_chat_wire_body(&request, provider, base_url, false, None)
|
||||
.unwrap_or_else(|error| panic!("{provider:?}: {error}"));
|
||||
}
|
||||
let error = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Openai,
|
||||
"https://api.openai.com/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.expect("documented 64-character route refuses the history name");
|
||||
assert!(
|
||||
error.to_string().contains("earlier in this session"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_100_character_encoded_name_is_withheld_on_a_chat_wire_route() {
|
||||
let name = "a".repeat(100);
|
||||
let mut request = request_with_image();
|
||||
request.tools = Some(vec![named_tool("read_file"), named_tool(&name)]);
|
||||
let body = build_chat_wire_body(
|
||||
&request,
|
||||
ProviderKind::Minimax,
|
||||
"https://api.minimax.io/v1",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("wire body");
|
||||
assert_eq!(body.omitted_tool_names, vec![name]);
|
||||
assert_eq!(body.body["tools"].as_array().expect("tools").len(), 1);
|
||||
}
|
||||
|
||||
fn request_with_image() -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
|
||||
@@ -35,10 +35,8 @@ use super::chat::REASONING_REPLAY_PLACEHOLDER;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, TOOL_NAME_LIMIT_64, ToolNameWire, bounded_error_text,
|
||||
check_history_tool_names, documented_tool_name_limit, from_api_tool_name,
|
||||
over_limit_tool_name_message, system_to_instructions, to_api_tool_name,
|
||||
tool_name_exceeds_limit, tool_name_limit,
|
||||
ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, system_to_instructions,
|
||||
to_api_tool_name,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
|
||||
use crate::llm_client::LlmError;
|
||||
@@ -137,7 +135,6 @@ pub(super) fn build_responses_wire_body(
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or_else(|| "You are a helpful assistant.".to_string());
|
||||
|
||||
let provider_kind = ProviderKind::parse(context.provider_id);
|
||||
let mut input = convert_messages_to_responses_input(request, profile, context);
|
||||
if profile.request.instructions == InstructionsPlacement::LeadingSystemInput {
|
||||
input.insert(
|
||||
@@ -153,26 +150,12 @@ pub(super) fn build_responses_wire_body(
|
||||
}
|
||||
body["input"] = json!(input);
|
||||
|
||||
let tool_name_limit = provider_kind.map_or(TOOL_NAME_LIMIT_64, |provider| {
|
||||
tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
});
|
||||
check_history_tool_names(
|
||||
request,
|
||||
provider_kind.and_then(|provider| {
|
||||
documented_tool_name_limit(provider, ToolNameWire::OpenAiCompatible)
|
||||
}),
|
||||
)?;
|
||||
let mut omitted_tool_names = Vec::new();
|
||||
let mut omitted_logical_names = Vec::new();
|
||||
// Convert tools to Responses function tools.
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let mut responses_tools = Vec::new();
|
||||
for tool in tools {
|
||||
if tool_name_exceeds_limit(&tool.name, tool_name_limit) {
|
||||
omitted_logical_names.push(tool.name.clone());
|
||||
omitted_tool_names.push(to_api_tool_name(&tool.name));
|
||||
continue;
|
||||
}
|
||||
match project_tool_schema(
|
||||
RouteToolProjection::RelaxedNonStrict,
|
||||
&tool.input_schema,
|
||||
@@ -202,9 +185,6 @@ pub(super) fn build_responses_wire_body(
|
||||
if let Some(name) = request.tool_choice.as_ref().and_then(forced_tool_name)
|
||||
&& omitted_logical_names.iter().any(|omitted| omitted == name)
|
||||
{
|
||||
if tool_name_exceeds_limit(name, tool_name_limit) {
|
||||
anyhow::bail!(over_limit_tool_name_message(name, tool_name_limit));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"cannot force an omitted tool because its input schema is incompatible with this route"
|
||||
);
|
||||
|
||||
@@ -755,43 +755,6 @@ async fn responses_stream_inserts_boundary_between_reasoning_summary_parts() {
|
||||
assert_eq!(thinking, "partA\n\npartB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_encoded_tool_names_are_withheld_from_responses_at_64() {
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let long = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(to_api_tool_name(long).len() > 64);
|
||||
let mut request = minimal_responses_request();
|
||||
request.tools = Some(vec![tool("read_file"), tool(long)]);
|
||||
let (body, omitted) = build_responses_wire_body(
|
||||
&request,
|
||||
profile_for_provider(ProviderKind::Openai),
|
||||
ResponsesReplayContext {
|
||||
provider_id: ProviderKind::Openai.as_str(),
|
||||
wire_model_id: request.model.as_str(),
|
||||
reasoning_api_id: None,
|
||||
},
|
||||
)
|
||||
.expect("body builds");
|
||||
let sent: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(sent, vec!["read_file"]);
|
||||
assert_eq!(omitted, vec![to_api_tool_name(long)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reasoning_effort_uses_responses_labels() {
|
||||
assert_eq!(codex_responses_reasoning_effort("max"), Some("max"));
|
||||
|
||||
@@ -138,16 +138,6 @@ pub struct SystemOneResponse {
|
||||
pub answers_validated: Option<bool>,
|
||||
}
|
||||
|
||||
impl SystemOneResponse {
|
||||
pub fn abstained_questions(&self, names: &[&str]) -> Vec<String> {
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| self.answers.get(**name).is_some_and(|a| a.abstained))
|
||||
.map(|name| (*name).to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SystemOneResponse {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
@@ -211,8 +201,6 @@ pub struct SystemOneAnswer {
|
||||
pub probabilities: BTreeMap<String, Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub confidence: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub abstained: bool,
|
||||
}
|
||||
|
||||
/// Provider usage. OpenRouter adds `cost`; the token-count casing differs
|
||||
|
||||
@@ -1449,10 +1449,9 @@ fn tool_name_scenario() {
|
||||
}
|
||||
// from tool_name_decode_bare_hex_no_trailing_dash
|
||||
{
|
||||
let exact = "foo_x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(exact), exact);
|
||||
let mangled = "foo_.x00002Ebar";
|
||||
assert_eq!(from_api_tool_name(mangled), "foo_..bar");
|
||||
let mangled = "foo_x00002Ebar";
|
||||
let decoded = from_api_tool_name(mangled);
|
||||
assert_eq!(decoded, "foo_.bar");
|
||||
}
|
||||
// from tool_name_bare_hex_preserves_alnum
|
||||
{
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::{ContentBlock, MessageRequest, ServerToolUsage, SystemPrompt, Usage};
|
||||
use codewhale_models::{ServerToolUsage, SystemPrompt, Usage};
|
||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||
|
||||
pub fn saturating_u32(value: u64) -> u32 {
|
||||
@@ -316,7 +315,7 @@ pub fn to_api_tool_name(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_delimited_tool_name(name: &str) -> String {
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut iter = name.chars().peekable();
|
||||
while let Some(ch) = iter.next() {
|
||||
@@ -358,87 +357,12 @@ fn decode_delimited_tool_name(name: &str) -> String {
|
||||
}
|
||||
out.push('-');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
let exact = decode_delimited_tool_name(name);
|
||||
if to_api_tool_name(&exact) == name {
|
||||
return exact;
|
||||
}
|
||||
decode_bare_hex_escapes(&exact)
|
||||
}
|
||||
|
||||
pub const TOOL_NAME_LIMIT_64: usize = 64;
|
||||
pub const TOOL_NAME_LIMIT_128: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolNameWire {
|
||||
AnthropicMessages,
|
||||
OpenAiCompatible,
|
||||
}
|
||||
|
||||
pub fn documented_tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> Option<usize> {
|
||||
match (provider, wire) {
|
||||
(ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic, _) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Anthropic, ToolNameWire::AnthropicMessages) => Some(TOOL_NAME_LIMIT_128),
|
||||
(ProviderKind::Openai | ProviderKind::OpenaiCodex, ToolNameWire::OpenAiCompatible) => {
|
||||
Some(TOOL_NAME_LIMIT_64)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_name_limit(provider: ProviderKind, wire: ToolNameWire) -> usize {
|
||||
if let Some(limit) = documented_tool_name_limit(provider, wire) {
|
||||
return limit;
|
||||
}
|
||||
match (provider, wire) {
|
||||
(_, ToolNameWire::AnthropicMessages) => TOOL_NAME_LIMIT_128,
|
||||
(
|
||||
ProviderKind::Ollama
|
||||
| ProviderKind::OllamaCloud
|
||||
| ProviderKind::Vllm
|
||||
| ProviderKind::Sglang
|
||||
| ProviderKind::Custom,
|
||||
_,
|
||||
) => TOOL_NAME_LIMIT_128,
|
||||
(_, ToolNameWire::OpenAiCompatible) => TOOL_NAME_LIMIT_64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_history_tool_names(request: &MessageRequest, documented: Option<usize>) -> Result<()> {
|
||||
let Some(limit) = documented else {
|
||||
return Ok(());
|
||||
};
|
||||
for message in &request.messages {
|
||||
for block in &message.content {
|
||||
if let ContentBlock::ToolUse { name, .. } = block
|
||||
&& tool_name_exceeds_limit(name, limit)
|
||||
{
|
||||
anyhow::bail!(over_limit_history_tool_name_message(name, limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tool_name_exceeds_limit(name: &str, limit: usize) -> bool {
|
||||
to_api_tool_name(name).len() > limit
|
||||
}
|
||||
|
||||
pub fn over_limit_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool name `{name}` encodes to {} characters but this provider accepts at most {limit}; rename the tool (shorter name, fewer dots, hyphens or other special characters) or remove it",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn over_limit_history_tool_name_message(name: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"tool `{name}` was called earlier in this session and its encoded name is {} characters, but this provider accepts at most {limit}; switch to a route with a larger limit or start a new session",
|
||||
to_api_tool_name(name).len()
|
||||
)
|
||||
// Second pass: decode bare hex escapes (e.g. `x00002E`) that the model
|
||||
// may produce when it mangles the `-x00002E-` delimiter form. Only
|
||||
// decode when the resulting character is one that `to_api_tool_name`
|
||||
// would have encoded (not alphanumeric, not `_`, not `-`).
|
||||
decode_bare_hex_escapes(&out)
|
||||
}
|
||||
|
||||
pub const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
|
||||
@@ -541,98 +465,6 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_name_encoding_is_collision_free_for_hostile_names() {
|
||||
let names = [
|
||||
"web.run",
|
||||
"web-x00002E-run",
|
||||
"web--x00002E--run",
|
||||
"webx00002Erun",
|
||||
"web_x00002Erun",
|
||||
"web_.run",
|
||||
"a-b",
|
||||
"a--b",
|
||||
"a_b",
|
||||
"a.b",
|
||||
"ax00002Eb",
|
||||
"a-x00002E-b",
|
||||
"mcp__srv__tool.name",
|
||||
"mcp__srv__tool-x00002E-name",
|
||||
];
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for name in names {
|
||||
let encoded = to_api_tool_name(name);
|
||||
assert!(
|
||||
encoded
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
|
||||
"{encoded}"
|
||||
);
|
||||
if let Some(previous) = seen.insert(encoded.clone(), name) {
|
||||
panic!("{previous} and {name} both encode to {encoded}");
|
||||
}
|
||||
assert_eq!(from_api_tool_name(&encoded), name, "round trip of {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_follows_the_wire_unless_the_provider_documents_one() {
|
||||
use ToolNameWire::{AnthropicMessages, OpenAiCompatible};
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Anthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::MinimaxAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic,
|
||||
AnthropicMessages
|
||||
),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::Deepseek, OpenAiCompatible),
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
tool_name_limit(ProviderKind::DeepseekAnthropic, AnthropicMessages),
|
||||
128
|
||||
);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Openai, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Minimax, OpenAiCompatible), 64);
|
||||
assert_eq!(tool_name_limit(ProviderKind::Ollama, OpenAiCompatible), 128);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Ollama, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
documented_tool_name_limit(ProviderKind::Custom, OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_name_limit_counts_the_encoded_form() {
|
||||
let dotted = "mcp__tracker__projects.v2.issues.list-by-assignee";
|
||||
assert!(dotted.len() <= 64);
|
||||
assert!(tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_64));
|
||||
assert!(!tool_name_exceeds_limit(dotted, TOOL_NAME_LIMIT_128));
|
||||
let plain = "a".repeat(64);
|
||||
assert!(!tool_name_exceeds_limit(&plain, TOOL_NAME_LIMIT_64));
|
||||
assert!(tool_name_exceeds_limit(
|
||||
&format!("{plain}a"),
|
||||
TOOL_NAME_LIMIT_64
|
||||
));
|
||||
let message = over_limit_tool_name_message(dotted, TOOL_NAME_LIMIT_64);
|
||||
assert!(
|
||||
message.contains("at most 64") && message.contains(dotted),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_line_limit_rejects_oversized_pending_complete_and_final_lines() {
|
||||
let mut decoder = SseLineDecoder::new();
|
||||
|
||||
@@ -29,8 +29,6 @@ pub struct UserCommand {
|
||||
pub takes_arguments: bool,
|
||||
pub hidden: bool,
|
||||
pub extension: bool,
|
||||
pub plugin_id: Option<String>,
|
||||
pub workspace_sourced: bool,
|
||||
pub builtin_bindings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -2152,7 +2152,10 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
|
||||
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
|
||||
// server, so treat them as publishes.
|
||||
let mut chars = lower.chars();
|
||||
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
|
||||
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
|
||||
|
||||
@@ -456,7 +456,7 @@ async fn captured_identity_cancel_and_task_bounds_refuse_before_any_provider() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_retaining_authority() {
|
||||
struct Pending(Arc<tokio::sync::Notify>);
|
||||
struct Pending;
|
||||
impl Replies for Pending {
|
||||
fn effective_route_envelope(
|
||||
&self,
|
||||
@@ -478,42 +478,24 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
|
||||
&self,
|
||||
_: MessageRequest,
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<MessageResponse>> + Send + '_>> {
|
||||
self.0.notify_one();
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
}
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let caller = Arc::new(caller_for(
|
||||
Arc::new(Pending(entered.clone())),
|
||||
"captured-model",
|
||||
));
|
||||
let caller = Arc::new(caller_for(Arc::new(Pending), "captured-model"));
|
||||
let weak_caller = Arc::downgrade(&caller);
|
||||
let usage = RlmUsageAccumulator::new();
|
||||
let mut dispatch = caller.dispatch(RlmInvocation {
|
||||
prompt: "pending".into(),
|
||||
mode: RlmMode::Completion,
|
||||
max_tokens: None,
|
||||
task_instructions: None,
|
||||
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(600)),
|
||||
gate: None,
|
||||
events: None,
|
||||
usage: usage.clone(),
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = entered.notified() => {}
|
||||
result = &mut dispatch => panic!(
|
||||
"dropped dispatch never reached the client: {:?}",
|
||||
result.error
|
||||
),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the dispatched call must reach the client");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(601)).await;
|
||||
let result = dispatch.await;
|
||||
let result = caller
|
||||
.dispatch(RlmInvocation {
|
||||
prompt: "pending".into(),
|
||||
mode: RlmMode::Completion,
|
||||
max_tokens: None,
|
||||
task_instructions: None,
|
||||
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
|
||||
gate: None,
|
||||
events: None,
|
||||
usage: usage.clone(),
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub fn tool_projection_warning_message(
|
||||
omitted_tool_count: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"Warning: {provider} omitted tools for this request: {}. A tool is omitted when its input schema is incompatible with this route or when its encoded name is longer than the provider's tool-name limit (64 characters on OpenAI-style routes, 128 on Anthropic-style routes and DeepSeek); a name ending in … is cut for display. Shorten the tool's name (dots, hyphens and other special characters each add characters) or remove it.",
|
||||
"Warning: {provider} omitted incompatible tools for this request: {}",
|
||||
tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
|
||||
)
|
||||
}
|
||||
@@ -1251,15 +1251,6 @@ mod tool_projection_warning_tests {
|
||||
bounded_tool_projection_warning_names,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projection_warning_message_names_the_tool_and_the_name_limit() {
|
||||
let names = vec!["web-x00002E-run".to_string()];
|
||||
let message = tool_projection_warning_message("OpenAI", &names, 1);
|
||||
assert!(message.contains("web-x00002E-run"), "{message}");
|
||||
assert!(message.contains("64 characters"), "{message}");
|
||||
assert!(message.contains("128 on Anthropic-style"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_warning_names_are_count_and_length_bounded() {
|
||||
let names: Vec<String> = (0..12)
|
||||
|
||||
@@ -2643,7 +2643,6 @@ pub fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceipt {
|
||||
probabilities_bp: Default::default(),
|
||||
confidence_bp: 0,
|
||||
min_confidence_bp: 5_000,
|
||||
abstained_questions: Vec::new(),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: Some("0.000012054".to_string()),
|
||||
|
||||
@@ -203,13 +203,10 @@ impl Drop for TestOverlay {
|
||||
|
||||
fn overlay_granted() -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
TEST_OVERLAY.with(std::cell::Cell::get)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
false
|
||||
if TEST_OVERLAY.with(std::cell::Cell::get) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
|
||||
@@ -187,19 +187,6 @@ pub const AGENTS: &[ExternalAgent] = &[
|
||||
adapter_binary: None,
|
||||
adapter_cli_env: None,
|
||||
},
|
||||
ExternalAgent {
|
||||
id: "qwen-code",
|
||||
name: "Qwen Code",
|
||||
binary: "qwen",
|
||||
acp_args: Some(&["--acp"]),
|
||||
website: "https://github.com/QwenLM/qwen-code",
|
||||
min_version: Some("0.6.1"),
|
||||
upgrade: Some("npm i -g @qwen-code/qwen-code@latest"),
|
||||
login: None,
|
||||
adapter: None,
|
||||
adapter_binary: None,
|
||||
adapter_cli_env: None,
|
||||
},
|
||||
];
|
||||
|
||||
const FIXTURE_AGENT: ExternalAgent = ExternalAgent {
|
||||
|
||||
@@ -1727,10 +1727,9 @@ fn ssh_client_env() -> BTreeMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
const MANAGED_CONFIG_ENV: [&str; 2] = [
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
];
|
||||
fn process_base_env() -> BTreeMap<String, String> {
|
||||
base_env_from(std::env::vars_os())
|
||||
}
|
||||
|
||||
/// The worker's inherited environment: the repository's scrubbed child-env
|
||||
/// allowlist (`child_env`), so proxy routing, CA bundles, temp directories,
|
||||
@@ -1743,22 +1742,11 @@ where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let parent: Vec<(std::ffi::OsString, std::ffi::OsString)> = parent
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string()))
|
||||
.collect();
|
||||
let mut env: BTreeMap<String, String> =
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent.iter().cloned())
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent)
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
||||
.collect();
|
||||
for (key, value) in &parent {
|
||||
if MANAGED_CONFIG_ENV.iter().any(|name| key == name)
|
||||
&& let (Some(key), Some(value)) = (key.to_str(), value.to_str())
|
||||
{
|
||||
env.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
force_worker_telemetry_off(&mut env);
|
||||
env
|
||||
}
|
||||
@@ -1766,7 +1754,7 @@ where
|
||||
/// Fleet workers are an implementation detail of the parent session, not
|
||||
/// sessions of their own — the parent already accounts for the dispatch.
|
||||
///
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`base_env_from`], so an
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`process_base_env`], so an
|
||||
/// operator's opt-out would otherwise never reach a worker at all. Hard-off
|
||||
/// here means a worker can never emit, can never inherit an ambient "on", and
|
||||
/// can never write telemetry state into the operator's home.
|
||||
@@ -1784,24 +1772,8 @@ fn worker_env(
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>> {
|
||||
worker_env_from(std::env::vars_os(), request_env, allowlist)
|
||||
}
|
||||
|
||||
fn worker_env_from<K, V>(
|
||||
parent: impl IntoIterator<Item = (K, V)>,
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>>
|
||||
where
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut env = base_env_from(parent);
|
||||
let mut requested = filtered_env(request_env, allowlist)?;
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
requested.remove(key);
|
||||
}
|
||||
env.extend(requested);
|
||||
let mut env = process_base_env();
|
||||
env.extend(filtered_env(request_env, allowlist)?);
|
||||
force_worker_telemetry_off(&mut env);
|
||||
Ok(env)
|
||||
}
|
||||
@@ -2798,47 +2770,11 @@ mod tests {
|
||||
assert_eq!(config.codewhale_binary, "/usr/local/bin/codewhale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_keeps_the_parent_managed_config_path_and_a_request_cannot_replace_it() {
|
||||
let parent = [
|
||||
("CODEWHALE_MANAGED_CONFIG_PATH", "/etc/example/managed.toml"),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
"/etc/deepseek/managed_config.toml",
|
||||
),
|
||||
("USER", "fleet"),
|
||||
];
|
||||
let request_env = BTreeMap::from([
|
||||
(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH".to_string(),
|
||||
"/nonexistent/managed.toml".to_string(),
|
||||
),
|
||||
]);
|
||||
let allowlist: BTreeSet<String> = request_env.keys().cloned().collect();
|
||||
let env = worker_env_from(parent, &request_env, &allowlist).expect("worker env");
|
||||
for (key, value) in &parent[..2] {
|
||||
assert_eq!(env.get(*key).map(String::as_str), Some(*value), "{key}");
|
||||
}
|
||||
|
||||
let env =
|
||||
worker_env_from([("USER", "fleet")], &request_env, &allowlist).expect("worker env");
|
||||
for key in MANAGED_CONFIG_ENV {
|
||||
assert!(
|
||||
!env.contains_key(key),
|
||||
"{key} must come only from the parent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_env_forces_telemetry_off_even_when_the_allowlist_says_otherwise() {
|
||||
// The spawn path env_clear()s and rebuilds from this map, so anything
|
||||
// absent here simply does not exist inside the worker.
|
||||
let base = base_env_from(std::env::vars_os());
|
||||
let base = process_base_env();
|
||||
assert_eq!(
|
||||
base.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false")
|
||||
|
||||
@@ -1748,7 +1748,11 @@ impl FleetManager {
|
||||
return Err(start_error);
|
||||
}
|
||||
};
|
||||
Ok(started)
|
||||
if !started {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn start_leased_workers(
|
||||
|
||||
@@ -1669,10 +1669,6 @@ pub trait McpTransport: Send + Sync {
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>>;
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
self.send(msg).await
|
||||
}
|
||||
|
||||
/// Record the protocol revision negotiated at `initialize`. Only the
|
||||
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
|
||||
/// subsequent requests); stdio and legacy SSE have no header channel, so
|
||||
@@ -3129,30 +3125,6 @@ impl McpConnection {
|
||||
result
|
||||
}
|
||||
|
||||
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
|
||||
let result = match serde_json::to_vec(reply) {
|
||||
Ok(bytes) => {
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(anyhow::anyhow!("MCP connection was cancelled"))
|
||||
}
|
||||
result = self.transport.send_reply(bytes) => result,
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::debug!(
|
||||
target: "mcp",
|
||||
server = %self.name,
|
||||
error = %err,
|
||||
"could not answer an MCP server request"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake and discovery receive: each frame wait is bounded by the read
|
||||
/// knob, and a server that stays silent past it is treated as dead.
|
||||
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
|
||||
@@ -3230,22 +3202,6 @@ impl McpConnection {
|
||||
{
|
||||
self.catalog_stale = true;
|
||||
}
|
||||
if let Some(method) = value.get("method") {
|
||||
if let Some(id) = value.get("id") {
|
||||
let method = method.as_str().unwrap_or_default();
|
||||
let reply = if method == "ping" {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("Method not found: {method}")}
|
||||
})
|
||||
};
|
||||
self.answer_server_request(&reply).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if response_id_matches(value.get("id"), &expected_id) {
|
||||
// Marks the connection stale so it is rebuilt, but this is a
|
||||
// reply to the request, so it never qualifies for a replay.
|
||||
|
||||
@@ -177,21 +177,6 @@ impl McpTransport for HttpTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => {
|
||||
transport.send(msg).await.map_err(|err| match err {
|
||||
StreamableSendError::Incompatible(detail)
|
||||
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
|
||||
"MCP Streamable HTTP refused a reply to a server request: {detail}"
|
||||
),
|
||||
StreamableSendError::Other(err) => err,
|
||||
})
|
||||
}
|
||||
HttpTransportMode::Sse(transport) => transport.send(msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
match &mut self.mode {
|
||||
HttpTransportMode::Streamable(transport) => transport.recv().await,
|
||||
|
||||
@@ -3314,80 +3314,6 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ScriptedValueTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert_eq!(
|
||||
sent[1],
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
);
|
||||
assert_eq!(sent[2]["id"], "1");
|
||||
assert_eq!(sent[2]["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
struct ReplyRefusingTransport {
|
||||
sent: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
responses: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for ReplyRefusingTransport {
|
||||
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
|
||||
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
|
||||
self.sent.lock().unwrap().push(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
self.responses
|
||||
.pop_front()
|
||||
.context("scripted transport exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let transport = ReplyRefusingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
responses: VecDeque::from([
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
|
||||
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||
]),
|
||||
};
|
||||
let mut conn = test_connection(Box::new(transport));
|
||||
|
||||
let result = conn
|
||||
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
assert_eq!(conn.state, ConnectionState::Ready);
|
||||
let sent = sent.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_invalid_json_includes_server_output_preview() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -6844,60 +6770,6 @@ async fn custom_headers_applied_to_get_preflight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _lock = lock_mcp_loopback_tests().await;
|
||||
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_srv = Arc::clone(&requests);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request_line = String::from_utf8_lossy(&buf[..n])
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
requests_srv.lock().unwrap().push(request_line);
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://{addr}/mcp");
|
||||
let mut transport = HttpTransport::new(
|
||||
test_mcp_http_client(&url),
|
||||
url,
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
let result = transport
|
||||
.send_reply(json_frame(
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
))
|
||||
.await;
|
||||
|
||||
server.abort();
|
||||
assert!(result.is_err(), "a refused reply reports the refusal");
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
|
||||
assert!(requests[0].starts_with("POST "), "{requests:?}");
|
||||
}
|
||||
|
||||
// === add_runtime_server_config conflict tests ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -282,20 +282,31 @@ impl ModelInventory {
|
||||
// vendor-family heuristic) carries the number *and* the fact
|
||||
// that nobody verified it — the auto-router must not read a
|
||||
// guessed window as a route capability.
|
||||
let mut context_window_unverified = true;
|
||||
let mut context_window_unverified =
|
||||
codewhale_config::catalog::reviewed::intrinsic_model(&model)
|
||||
.and_then(|row| row.context_window)
|
||||
.is_none();
|
||||
if let Ok(route) = crate::route_runtime::resolve_runtime_route_for_identity(
|
||||
config,
|
||||
&identity,
|
||||
Some(&model),
|
||||
) {
|
||||
capability.context_window = route.context_window.tokens;
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
capability.max_output = route
|
||||
if let Some(context_window) = route.candidate.limits().context_tokens {
|
||||
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
}
|
||||
// A concrete offering maximum is a stronger fact than the
|
||||
// static compatibility matrix — and is the only way a
|
||||
// membership route (no static cap) gets a known ceiling.
|
||||
if let Some(max_output) = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|tokens| u32::try_from(tokens).ok())
|
||||
.filter(|tokens| *tokens > 0);
|
||||
.filter(|tokens| *tokens > 0)
|
||||
{
|
||||
capability.max_output = Some(max_output);
|
||||
}
|
||||
user_declared = route
|
||||
.candidate
|
||||
.applied_limit_overrides()
|
||||
@@ -305,6 +316,13 @@ impl ModelInventory {
|
||||
== codewhale_config::route::OverrideSource::UserModelMetadata
|
||||
});
|
||||
if user_declared {
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
capability.context_window = route.context_window.tokens;
|
||||
capability.max_output = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
capability.thinking_supported = route.candidate.capabilities().reasoning
|
||||
== codewhale_config::route::CapabilityState::Supported;
|
||||
}
|
||||
|
||||
@@ -1852,7 +1852,6 @@ pub struct ValidChoice {
|
||||
pub choice: String,
|
||||
pub probabilities_bp: BTreeMap<String, u16>,
|
||||
pub confidence_bp: u16,
|
||||
pub abstained: bool,
|
||||
}
|
||||
|
||||
/// Validate one `choice` answer against the offered options. Any violation
|
||||
@@ -1895,7 +1894,6 @@ pub fn validated_choice(answer: Option<&SystemOneAnswer>, options: &[&str]) -> O
|
||||
choice: choice.to_string(),
|
||||
probabilities_bp,
|
||||
confidence_bp: probability_bp(confidence),
|
||||
abstained: answer.abstained,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1984,7 +1982,6 @@ fn decision_attempt_from_response(
|
||||
probabilities_bp: Default::default(),
|
||||
confidence_bp: 0,
|
||||
min_confidence_bp: inventory.router_min_confidence_bp,
|
||||
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: response
|
||||
@@ -2008,18 +2005,17 @@ fn decision_attempt_from_response(
|
||||
let min_confidence_bp = inventory.router_min_confidence_bp;
|
||||
// An invalid or unsure `thinking` answer drops only the effort.
|
||||
let thinking = validated_choice(response.answers.get("thinking"), &DECISION_THINKING_OPTIONS)
|
||||
.filter(|thinking| !thinking.abstained && thinking.confidence_bp >= min_confidence_bp)
|
||||
.filter(|thinking| thinking.confidence_bp >= min_confidence_bp)
|
||||
.map(|thinking| thinking.choice);
|
||||
let strong_bp = tier.probabilities_bp.get("strong").copied().unwrap_or(0);
|
||||
let cost_saving_kept_fast =
|
||||
cost_saving && tier.choice == "strong" && strong_bp < COST_SAVING_STRONG_MIN_BP;
|
||||
let acted = !tier.abstained && tier.confidence_bp >= min_confidence_bp;
|
||||
let acted = tier.confidence_bp >= min_confidence_bp;
|
||||
attempt.decision = Some(AutoRouteDecisionEvidence {
|
||||
choice: tier.choice.clone(),
|
||||
probabilities_bp: tier.probabilities_bp,
|
||||
confidence_bp: tier.confidence_bp,
|
||||
min_confidence_bp,
|
||||
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
|
||||
cost_saving_kept_fast: acted && cost_saving_kept_fast,
|
||||
thinking: thinking.clone().filter(|_| acted),
|
||||
provider_reported_cost_usd: response
|
||||
@@ -4191,60 +4187,6 @@ mod decision_router_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_abstention_survives_decode_and_never_authorizes_auto_at_floor_zero() {
|
||||
let _env = hermetic_env();
|
||||
let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
|
||||
config
|
||||
.auto
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.router
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.min_confidence = Some(0.0);
|
||||
let inventory = ModelInventory::from_config(&config).unwrap();
|
||||
let attempt = |body: serde_json::Value| {
|
||||
decision_attempt_from_response(false, &inventory, &pair(), None, &parsed(body), 120)
|
||||
};
|
||||
let mut body = answer_body(0.1, 0.9);
|
||||
body["answers"]["thinking"]["abstained"] = true.into();
|
||||
let effort_abstained = attempt(body.clone());
|
||||
let recommendation = effort_abstained
|
||||
.recommendation
|
||||
.expect("valid tier survives effort abstention");
|
||||
assert_eq!(recommendation.model, "deepseek-v4-flash");
|
||||
assert_eq!(recommendation.reasoning_effort, None);
|
||||
assert_eq!(
|
||||
effort_abstained.decision.unwrap().abstained_questions,
|
||||
vec!["thinking"]
|
||||
);
|
||||
body["answers"]["tier"]["abstained"] = true.into();
|
||||
let tier_abstained = attempt(body);
|
||||
assert!(tier_abstained.recommendation.is_none());
|
||||
assert_eq!(
|
||||
tier_abstained.fallback_reason,
|
||||
Some(AutoRouteHeuristicReason::LowConfidence)
|
||||
);
|
||||
assert_eq!(tier_abstained.failure, None);
|
||||
let decision = tier_abstained.decision.unwrap();
|
||||
assert_eq!(decision.abstained_questions, vec!["tier", "thinking"]);
|
||||
assert_eq!(decision.choice, "fast");
|
||||
assert_eq!(
|
||||
decision.provider_reported_cost_usd.as_deref(),
|
||||
Some("0.000019992")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_boolean_abstention_is_refused_not_ignored() {
|
||||
let mut body = answer_body(0.1, 0.9);
|
||||
body["answers"]["tier"]["abstained"] = "yes".into();
|
||||
let attempt = policy(false, &parsed(body));
|
||||
assert!(attempt.recommendation.is_none());
|
||||
assert_eq!(attempt.failure, Some(AutoRouterFailure::InvalidAnswer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_answers_are_rejected_not_repaired() {
|
||||
let mut outside = answer_body(0.8, 0.6);
|
||||
|
||||
@@ -1455,24 +1455,6 @@ pub fn configured_catalog_models_for_route(
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn listed_models_for_route(route_config: &Config, identity: &ProviderIdentity) -> Vec<String> {
|
||||
let mut models = configured_catalog_models_for_route(
|
||||
route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
let default_model = route_config.default_model();
|
||||
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
|
||||
push_unique_model(&mut models, &default_model);
|
||||
}
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
/// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
|
||||
/// ignoring any live rows merged over it.
|
||||
///
|
||||
@@ -2237,8 +2219,23 @@ pub async fn run_models(
|
||||
route_config
|
||||
.scope_to_provider_identity(identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let models = listed_models_for_route(&route_config, identity);
|
||||
let mut models = configured_catalog_models_for_route(
|
||||
&route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
let default_model = route_config.default_model();
|
||||
if identity.provider != ProviderKind::OpenaiCodex
|
||||
&& !default_model.is_empty()
|
||||
&& !default_model.eq_ignore_ascii_case("auto")
|
||||
{
|
||||
push_unique_model(&mut models, &default_model);
|
||||
}
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
if json {
|
||||
// Preserve the existing array + AvailableModel field shape.
|
||||
let chatgpt_roster = (identity.provider == ProviderKind::OpenaiCodex)
|
||||
|
||||
@@ -325,7 +325,7 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::
|
||||
pub fn list_models(
|
||||
store: &codewhale_config::ConfigStore,
|
||||
filter: Option<ProviderKind>,
|
||||
) -> Result<Vec<(String, Vec<String>)>> {
|
||||
) -> Result<Vec<(ProviderKind, Vec<String>)>> {
|
||||
crate::models_dev_live::maybe_load_persisted_cache();
|
||||
let mut config = saved_config(store)?;
|
||||
config.base_url_env_receipt = config
|
||||
@@ -333,33 +333,42 @@ pub fn list_models(
|
||||
.map_or(BaseUrlEnvReceipt::NoOwner, |identity| {
|
||||
BaseUrlEnvReceipt::Route(identity.provider, identity.key.to_string())
|
||||
});
|
||||
let mut custom_routes: Vec<String> = config
|
||||
.providers
|
||||
.as_ref()
|
||||
.map(|providers| providers.custom.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
custom_routes.sort();
|
||||
let providers = filter
|
||||
.as_ref()
|
||||
.map_or(ProviderKind::all(), std::slice::from_ref);
|
||||
let mut catalogs = Vec::with_capacity(providers.len());
|
||||
for provider in providers {
|
||||
let routes = if *provider == ProviderKind::Custom {
|
||||
custom_routes.clone()
|
||||
} else {
|
||||
vec![provider.as_str().to_string()]
|
||||
};
|
||||
for route in routes {
|
||||
let identity = config
|
||||
.legacy_selection_identity(&route)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let models = crate::provider_lake::listed_models_for_route(&route_config, &identity);
|
||||
catalogs.push((route, models));
|
||||
if *provider == ProviderKind::Custom
|
||||
&& config
|
||||
.providers
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.custom_provider_config(provider.as_str()))
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let identity = config
|
||||
.legacy_selection_identity(provider.as_str())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut models = crate::provider_lake::configured_catalog_models_for_route(
|
||||
&route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
let default_model = route_config.default_model();
|
||||
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
|
||||
models.push(default_model);
|
||||
}
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
catalogs.push((*provider, models));
|
||||
}
|
||||
Ok(catalogs)
|
||||
}
|
||||
@@ -409,12 +418,10 @@ pub fn resolve_model(
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !crate::provider_catalog_live::provider_owns_live_catalog(identity.provider, base_url) {
|
||||
codewhale_agent::ModelRegistry::default().resolve(
|
||||
model.filter(|model| *model != "auto"),
|
||||
Some(identity.provider),
|
||||
)?;
|
||||
}
|
||||
codewhale_agent::ModelRegistry::default().resolve(
|
||||
model.filter(|model| *model != "auto"),
|
||||
Some(identity.provider),
|
||||
)?;
|
||||
}
|
||||
Ok(route?.candidate)
|
||||
}
|
||||
|
||||
@@ -451,11 +451,6 @@ async fn evaluate(
|
||||
.map_or_else(Default::default, |v| v.probabilities_bp.clone()),
|
||||
confidence_bp: intent.as_ref().map_or(0, |v| v.confidence_bp),
|
||||
min_confidence_bp: 5_000,
|
||||
abstained_questions: response.abstained_questions(&[
|
||||
"intent",
|
||||
"needs_tool",
|
||||
"answerable_from_context",
|
||||
]),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: response
|
||||
@@ -526,7 +521,7 @@ fn unit_interval(value: Option<f64>) -> Option<f64> {
|
||||
/// fast route.
|
||||
fn read_noul(response: &SystemOneResponse, key: &str) -> Option<f64> {
|
||||
let answer = response.answers.get(key)?;
|
||||
(answer.kind == "noul" && !answer.abstained)
|
||||
(answer.kind == "noul")
|
||||
.then_some(answer.noul)
|
||||
.and_then(unit_interval)
|
||||
}
|
||||
@@ -553,9 +548,7 @@ fn derive_route(response: &SystemOneResponse) -> Route {
|
||||
response.answers.get("intent"),
|
||||
&["code_change", "code_question", "command", "chat", "other"],
|
||||
)
|
||||
.is_some_and(|intent| {
|
||||
!intent.abstained && intent.choice == "chat" && intent.confidence_bp >= 5_000
|
||||
});
|
||||
.is_some_and(|intent| intent.choice == "chat" && intent.confidence_bp >= 5_000);
|
||||
if intent_is_calibrated_chat && needs_tool.is_some_and(|nt| nt <= 0.2) {
|
||||
return Route::PlainChat;
|
||||
}
|
||||
@@ -571,23 +564,6 @@ mod tests {
|
||||
|
||||
const TYPESAFE_TEST_KEY: &str = "sf-typesafe-test-key-0123456789";
|
||||
|
||||
#[test]
|
||||
fn explicit_abstention_never_authorizes_a_shadow_route() {
|
||||
let mut value = json!({"answers":{
|
||||
"intent":{"type":"choice","choice":"chat","confidence":0.9,
|
||||
"probabilities":{"chat":0.9,"code_change":0.1,"code_question":0.0,"command":0.0,"other":0.0}},
|
||||
"needs_tool":{"type":"noul","noul":0.1},
|
||||
"answerable_from_context":{"type":"noul","noul":0.1}}});
|
||||
let decoded =
|
||||
|value: Value| serde_json::from_value::<SystemOneResponse>(value).expect("response");
|
||||
assert_eq!(derive_route(&decoded(value.clone())), Route::PlainChat);
|
||||
value["answers"]["intent"]["abstained"] = true.into();
|
||||
assert_eq!(derive_route(&decoded(value.clone())), Route::Unknown);
|
||||
value["answers"]["needs_tool"]["noul"] = 0.95.into();
|
||||
value["answers"]["needs_tool"]["abstained"] = true.into();
|
||||
assert_eq!(derive_route(&decoded(value)), Route::Unknown);
|
||||
}
|
||||
|
||||
/// Fields drop in declaration order: restore the environment before the
|
||||
/// lock is released, or another test observes our overrides.
|
||||
struct Env {
|
||||
|
||||
@@ -4988,16 +4988,14 @@ mod tests {
|
||||
Arc::new(MockExecutor),
|
||||
)
|
||||
.await?;
|
||||
settle_queue_fixture(&manager).await?;
|
||||
inspect_settled_workers(&manager).await?;
|
||||
sleep(STORE_IDLE_POLL_INTERVAL + Duration::from_millis(300)).await;
|
||||
manager.fingerprint_reads.store(0, Ordering::Relaxed);
|
||||
manager.store_loads.store(0, Ordering::Relaxed);
|
||||
|
||||
let started = Instant::now();
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
let window = Duration::from_secs(3);
|
||||
sleep(window).await;
|
||||
let reads = manager.fingerprint_reads.load(Ordering::Relaxed);
|
||||
let loads = manager.store_loads.load(Ordering::Relaxed);
|
||||
let window = started.elapsed();
|
||||
let ticks = (window.as_millis() / STORE_SETTLED_NAP.as_millis()) as usize + 1;
|
||||
assert!(
|
||||
reads <= workers * ticks,
|
||||
|
||||
@@ -133,9 +133,14 @@ impl WriteScopeClaim {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.contracts
|
||||
if self
|
||||
.contracts
|
||||
.iter()
|
||||
.any(|contract| other.contracts.iter().any(|other| other == contract))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -235,14 +235,14 @@
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
"TelemetryNoticeActionConfirm": "confirm",
|
||||
"TelemetryNoticeActionExit": "exit",
|
||||
"TelemetryNoticeReceiptEnabled": "Usage reporting is on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabled": "Usage reporting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Usage reporting is on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Usage reporting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryPreferenceEnabledNextLaunch": "Usage reporting will be on next launch. Conversations and code are never collected.",
|
||||
"TelemetryPreferenceDisabled": "Usage reporting is off. Local telemetry state was erased.",
|
||||
"TelemetryPreferenceDisabledWithWarning": "Usage reporting is off, but part of the cleanup needs attention: {detail}",
|
||||
"TelemetryPreferenceDisabledForSession": "Usage reporting is off for this session, but the preference was not saved and may return next launch: {detail}",
|
||||
"TelemetryNoticeReceiptEnabled": "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabled": "Anonymous usage counting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Anonymous usage counts are on for this session. Codewhale could not save this notice, so it will appear again. Change this in /settings; schema: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Anonymous usage counting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryPreferenceEnabledNextLaunch": "Anonymous usage counts will be on next launch. Conversations and code are never collected.",
|
||||
"TelemetryPreferenceDisabled": "Anonymous usage counts are off. Local telemetry state was erased.",
|
||||
"TelemetryPreferenceDisabledWithWarning": "Anonymous usage counts are off, but part of the cleanup needs attention: {detail}",
|
||||
"TelemetryPreferenceDisabledForSession": "Anonymous usage counts are off for this session, but the preference was not saved and may return next launch: {detail}",
|
||||
"TelemetryPreferenceSaveFailed": "The telemetry preference could not be changed safely: {detail}",
|
||||
"StatusPickerTitle": " Status line ",
|
||||
"StatusPickerInstruction": "Choose what the footer shows:",
|
||||
@@ -373,7 +373,7 @@
|
||||
"ConfigLabelDefaultMode": "Startup mode",
|
||||
"ConfigLabelAllowShell": "Shell access",
|
||||
"ConfigLabelManagedAllowShell": "Shell access (managed)",
|
||||
"ConfigLabelTelemetry": "Usage reporting",
|
||||
"ConfigLabelTelemetry": "Anonymous usage counts",
|
||||
"ConfigHintTelemetry": "Aggregate version, session, feature and error counts to Codewhale and PostHog; never content or IP. docs/TELEMETRY.md",
|
||||
"ConfigValueTelemetryOn": "On",
|
||||
"ConfigValueTelemetryOff": "Off",
|
||||
|
||||
@@ -255,12 +255,11 @@ impl StdioLspTransport {
|
||||
let child = Arc::new(AsyncMutex::new(Some(child)));
|
||||
let dispatcher_child = child.clone();
|
||||
let dispatcher_pending = pending.clone();
|
||||
let dispatcher_outbound = tx_outbound.clone();
|
||||
let dispatcher_task = spawn_supervised(
|
||||
"lsp-dispatcher",
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
|
||||
// EOF, malformed frames, or an overflowing diagnostics queue
|
||||
// terminate the producer too, so its pipes cannot stay stuck.
|
||||
if let Some(mut child) = dispatcher_child.lock().await.take() {
|
||||
@@ -537,19 +536,15 @@ impl LspTransport for StdioLspTransport {
|
||||
|
||||
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
|
||||
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
|
||||
tx.send(frame_message(value)?)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_message(value: &Value) -> Result<Vec<u8>> {
|
||||
let body = serde_json::to_vec(value).context("serialize LSP message")?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
let mut frame = Vec::with_capacity(header.len() + body.len());
|
||||
frame.extend_from_slice(header.as_bytes());
|
||||
frame.extend_from_slice(&body);
|
||||
Ok(frame)
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
@@ -643,7 +638,6 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
|
||||
async fn dispatcher_task(
|
||||
mut rx: mpsc::Receiver<Value>,
|
||||
tx_diag: mpsc::Sender<DiagnosticMessage>,
|
||||
tx_outbound: mpsc::Sender<Vec<u8>>,
|
||||
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
|
||||
) {
|
||||
while let Some(value) = rx.recv().await {
|
||||
@@ -660,19 +654,6 @@ async fn dispatcher_task(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(method) = method {
|
||||
if let Some(id) = value.get("id") {
|
||||
let reply = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {"code": -32601, "message": format!("unsupported method {method}")},
|
||||
});
|
||||
if let Ok(frame) = frame_message(&reply) {
|
||||
let _ = tx_outbound.try_send(frame);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Replies have an `id` and a `result` or `error`.
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
|
||||
let mut map = pending.lock().await;
|
||||
@@ -799,31 +780,6 @@ pub(super) mod tests {
|
||||
assert_eq!(diags[0].message, "missing semicolon");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
|
||||
let (tx_in, rx_in) = mpsc::channel(4);
|
||||
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
|
||||
let (tx_diag, _rx_diag) = mpsc::channel(4);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
pending.lock().await.insert(2, reply_tx);
|
||||
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx_in
|
||||
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
|
||||
let answer = next_document(&mut rx_out).await;
|
||||
assert_eq!(answer["id"], 2);
|
||||
assert_eq!(answer["error"]["code"], -32601);
|
||||
drop(tx_in);
|
||||
dispatcher.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_uri_path() {
|
||||
let path = fixture_path("example/foo.rs");
|
||||
@@ -1353,12 +1309,11 @@ while True:
|
||||
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
let (diag, _diagnostics) = mpsc::channel(8);
|
||||
let (outbound, _outbound_rx) = mpsc::channel(8);
|
||||
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
pending.lock().await.insert(1, reply);
|
||||
let read_task = tokio::spawn(reader_task(reader, tx));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
|
||||
producer.write_all(&frame).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), receiver)
|
||||
|
||||
@@ -11,8 +11,6 @@ pub struct AutoRouteDecisionEvidence {
|
||||
pub probabilities_bp: BTreeMap<String, u16>,
|
||||
pub confidence_bp: u16,
|
||||
pub min_confidence_bp: u16,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub abstained_questions: Vec<String>,
|
||||
/// `[auto] cost_saving` kept the fast tier because the strong tier's
|
||||
/// probability was below the cost-saving floor.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
@@ -3446,10 +3446,15 @@ impl Config {
|
||||
return Some("project runtime configuration");
|
||||
}
|
||||
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return Some("an unreadable managed runtime configuration"),
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
Ok(managed)
|
||||
if managed.approval_policy.is_some()
|
||||
|| managed.sandbox_mode.is_some()
|
||||
@@ -3459,7 +3464,7 @@ impl Config {
|
||||
}
|
||||
Err(_) => return Some("an unreadable managed runtime configuration"),
|
||||
Ok(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let env_controls_runtime = || {
|
||||
@@ -3545,16 +3550,21 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return ApprovalPolicyControl::Ambiguous,
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
Ok(managed) if managed.approval_policy.is_some() => {
|
||||
return ApprovalPolicyControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ApprovalPolicyControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if approval_policy_env_is_set() {
|
||||
@@ -3620,16 +3630,21 @@ impl Config {
|
||||
return ShellAccessControl::ProjectConfig;
|
||||
}
|
||||
|
||||
match managed_config_file(self.managed_config_path.as_deref()) {
|
||||
Err(_) => return ShellAccessControl::Ambiguous,
|
||||
Ok(None) => {}
|
||||
Ok(Some(path)) => match load_single_config_file(&path) {
|
||||
let managed_path = self
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
if let Some(path) = managed_path
|
||||
&& path.exists()
|
||||
{
|
||||
match load_single_config_file(&path) {
|
||||
Ok(managed) if managed.allow_shell.is_some() => {
|
||||
return ShellAccessControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ShellAccessControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if allow_shell_env_is_set() {
|
||||
@@ -8917,10 +8932,9 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
|
||||
{
|
||||
config.sandbox_api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = codewhale_env_var(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
) {
|
||||
if let Ok(value) = std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
{
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
if policy.permits_secret_bearing_values()
|
||||
@@ -10704,25 +10718,26 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
let Some(path) = managed_config_file(config.managed_config_path.as_deref())? else {
|
||||
let path = managed_path_from_env()
|
||||
.or_else(|| config.managed_config_path.as_deref().map(expand_path))
|
||||
.or_else(default_managed_config_path);
|
||||
let Some(path) = path else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut managed = load_single_config_file(&path).with_context(|| {
|
||||
format!(
|
||||
"the managed configuration {} cannot be loaded, and Codewhale does not start without a managed configuration it was told to use",
|
||||
codewhale_config::quote_os_path(&path)
|
||||
)
|
||||
})?;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut managed = load_single_config_file(&path)?;
|
||||
strip_external_credential_consent(&mut managed);
|
||||
managed.tool_launcher = managed_tool_launcher(&path)?;
|
||||
let prior_route = config
|
||||
.active_provider_identity()
|
||||
.map_err(|error| managed_route_error(config, &path, error))?;
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut merged = merge_config(config.clone(), managed.clone());
|
||||
apply_layer_root_model(&mut merged, &managed);
|
||||
let merged_route = merged
|
||||
.active_provider_identity()
|
||||
.map_err(|error| managed_route_error(&merged, &path, error))?;
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if managed.provider.is_some()
|
||||
|| managed.default_text_model.is_some()
|
||||
|| managed.legacy_model.is_some()
|
||||
@@ -10873,62 +10888,10 @@ fn managed_tool_launcher(path: &Path) -> Result<Option<Vec<String>>> {
|
||||
}
|
||||
|
||||
fn managed_path_from_env() -> Option<PathBuf> {
|
||||
codewhale_env_var(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
)
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
}
|
||||
|
||||
fn managed_config_file(configured: Option<&str>) -> Result<Option<PathBuf>> {
|
||||
if let Some(path) = managed_path_from_env() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
let default = default_managed_config_path().filter(|path| {
|
||||
fs::symlink_metadata(path).map_or_else(
|
||||
|error| error.kind() != std::io::ErrorKind::NotFound,
|
||||
|_| true,
|
||||
)
|
||||
});
|
||||
let Some(configured) = configured
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(expand_path)
|
||||
else {
|
||||
return Ok(default);
|
||||
};
|
||||
if let Some(default) = default
|
||||
&& default != configured
|
||||
{
|
||||
anyhow::bail!(
|
||||
"`managed_config_path` names {}, but the default managed configuration {} exists, and a config file cannot replace it; name another managed file with CODEWHALE_MANAGED_CONFIG_PATH instead",
|
||||
codewhale_config::quote_os_path(&configured),
|
||||
codewhale_config::quote_os_path(&default)
|
||||
);
|
||||
}
|
||||
Ok(Some(configured))
|
||||
}
|
||||
|
||||
fn managed_route_error(config: &Config, path: &Path, error: String) -> anyhow::Error {
|
||||
let selected = config.provider.as_deref().unwrap_or_default();
|
||||
let undeclared = !selected.is_empty()
|
||||
&& ProviderKind::parse(selected).is_none()
|
||||
&& config
|
||||
.providers
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.custom_provider_config(selected))
|
||||
.is_none();
|
||||
if undeclared {
|
||||
anyhow::anyhow!(
|
||||
"provider '{selected}' has no `[providers.{selected}]` table in the configuration, and the managed configuration {} applies: a provider that only a plugin declares cannot be used under a managed configuration. Declare `[providers.{selected}]` in the config file, or select a built-in provider",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!(
|
||||
"{error} (while applying the managed configuration {})",
|
||||
codewhale_config::quote_os_path(path)
|
||||
)
|
||||
}
|
||||
std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
}
|
||||
|
||||
fn managed_path_is_named_by_host(path: &Path) -> bool {
|
||||
|
||||
@@ -588,11 +588,12 @@ pub fn session_custom_provider_config(name: &str, kind: &str, base_url: &str) ->
|
||||
// provider is contacted: the assertions are about which host string a route
|
||||
// resolves to, and about the classifications derived from it.
|
||||
|
||||
/// A managed-config guard pointing at an empty managed file, so an
|
||||
/// A managed-config guard pointing at a path that does not exist, so an
|
||||
/// operator-installed managed file on the developer's machine cannot leak into
|
||||
/// these route assertions.
|
||||
pub fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
|
||||
let path = root.join("empty-managed.toml");
|
||||
std::fs::write(&path, "").expect("write empty managed config");
|
||||
EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", path)
|
||||
EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
root.join("absent-managed.toml"),
|
||||
)
|
||||
}
|
||||
@@ -3108,8 +3108,7 @@ fn policy_control_waits_for_foreign_test_env_overrides_to_restore() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = temp.path().join("config.toml");
|
||||
let workspace = temp.path().join("workspace");
|
||||
let managed_config_path = temp.path().join("empty-managed.toml");
|
||||
fs::write(&managed_config_path, "").expect("empty managed config");
|
||||
let managed_config_path = temp.path().join("missing-managed.toml");
|
||||
let requirements_path = temp.path().join("missing-requirements.toml");
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -6477,7 +6476,10 @@ fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
|
||||
fn retired_deepseek_aliases_from_env_are_migrated_before_runtime() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _managed_config = no_managed_config(temp_root.path());
|
||||
let _managed_config = crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("missing-managed.toml"),
|
||||
);
|
||||
|
||||
for (provider, alias, expected_effort) in [
|
||||
("deepseek", "deepseek-chat", "off"),
|
||||
@@ -12196,7 +12198,10 @@ fn tool_launcher_is_refused_outside_the_managed_layer() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let _managed = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("absent-managed.toml"),
|
||||
);
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
for document in [
|
||||
"tool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\"]\n",
|
||||
@@ -12367,62 +12372,17 @@ fn the_managed_file_named_by_the_host_survives_a_provider_that_resolves_late() -
|
||||
ConfigEnvironmentPolicy::Runtime,
|
||||
&mut late_provider,
|
||||
);
|
||||
let error = loaded.expect_err("a plugin provider does not load under a managed file");
|
||||
assert!(
|
||||
format!("{error:#}").contains("a provider that only a plugin declares"),
|
||||
"{user_file}: {error:#}"
|
||||
);
|
||||
if let Ok(config) = loaded {
|
||||
assert_eq!(
|
||||
config.tool_launcher.as_deref(),
|
||||
Some(["/usr/bin/sudo", "-n"].map(str::to_string).as_slice()),
|
||||
"{user_file}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_managed_file_that_cannot_be_read_is_a_load_error() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _legacy_managed = EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "")?;
|
||||
let missing = temp_root.path().join("missing-managed.toml");
|
||||
for named in [missing.clone(), temp_root.path().to_path_buf()] {
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &named);
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
|
||||
assert!(
|
||||
format!("{error:#}").contains("does not start without a managed configuration"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
let _managed = EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"managed_config_path = {}\n",
|
||||
toml::Value::String(missing.display().to_string())
|
||||
),
|
||||
)?;
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("closed");
|
||||
assert!(
|
||||
format!("{error:#}").contains("does not start without a managed configuration"),
|
||||
"{error:#}"
|
||||
);
|
||||
|
||||
fs::write(&config_path, "")?;
|
||||
let empty = temp_root.path().join("empty-managed.toml");
|
||||
fs::write(&empty, "")?;
|
||||
let _blank = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", "");
|
||||
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &missing);
|
||||
let error = Config::load(Some(config_path.clone()), None).expect_err("blank defers");
|
||||
assert!(
|
||||
format!("{error:#}").contains("missing-managed.toml"),
|
||||
"{error:#}"
|
||||
);
|
||||
let _legacy = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &empty);
|
||||
Config::load(Some(config_path), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
|
||||
#[test]
|
||||
fn tool_launcher_is_refused_from_a_managed_file_the_user_config_names() -> Result<()> {
|
||||
|
||||
@@ -1371,9 +1371,6 @@ fn relativize_candidate(
|
||||
.map(fs::Metadata::is_dir)
|
||||
.unwrap_or_else(|| candidate.ends_with('/'));
|
||||
|
||||
if rel_path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rel_string = path_to_string(&rel_path)?;
|
||||
Some((rel_string, exists, is_dir))
|
||||
}
|
||||
@@ -1658,19 +1655,6 @@ mod tests {
|
||||
assert!(!entry.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_root_is_not_an_active_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let root = tmp.path().canonicalize().expect("canonical root");
|
||||
fs::write(root.join("笔记 📝.md"), "旧").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
let mention = format!("<file path=\"{}/笔记 📝.md\">", root.display());
|
||||
ws.observe_user_message(&mention, &root);
|
||||
|
||||
assert!(!ws.entries.contains_key(""), "{:?}", ws.entries.keys());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_tool_call_extracts_paths_from_input() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"in_model_catalogue": "true when the skill renders as an ambient catalogue line",
|
||||
"shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution"
|
||||
},
|
||||
"generation": "22",
|
||||
"generation": "21",
|
||||
"skills": [
|
||||
{
|
||||
"name": "skill-creator",
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
name: plugin-creator
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
|
||||
Use this skill when a user wants a local Codewhale plugin bundle. Trusted and
|
||||
enabled bundles may add declarative Skills, commands, agents, hooks, and MCP
|
||||
servers (stdio and remote) through the existing engines. LSP, filesystem
|
||||
roots, and lifecycle mutation are inventory-only. Native extensions (host
|
||||
code) are inventory-only unless the user has turned on the experimental
|
||||
`[features] extension_host` flag.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Pick a Codewhale-owned location:
|
||||
- User bundle: `~/.codewhale/plugins/<plugin-name>/`
|
||||
- Workspace bundle: `<workspace>/.codewhale/plugins/<plugin-name>/`
|
||||
2. Normalize the bundle name to lowercase hyphen-case.
|
||||
3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays
|
||||
readable, but new bundles use `plugin.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/plugin.json",
|
||||
"name": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "What this bundle provides"
|
||||
}
|
||||
```
|
||||
|
||||
4. Put each Skill under `skills/<skill-name>/SKILL.md`; Codewhale finds
|
||||
`skills/` automatically and exposes each as `my-plugin:<skill-name>`,
|
||||
never as an unqualified command.
|
||||
5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an
|
||||
existing MCP engine. Keep stdio commands and paths inside the bundle. Map local
|
||||
environment values only as exact `${SOURCE_ENV}` references. For remote MCP,
|
||||
use HTTPS (or loopback HTTP), forbid URL user information/query/fragment,
|
||||
use only environment-backed headers or bearer tokens, and declare the exact
|
||||
normalized endpoint host set in `capabilities.network_hosts` under
|
||||
`extensions["net.codewhale"]`. Never place credentials in the manifest.
|
||||
6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks
|
||||
(`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate
|
||||
under the current policy — workspace bundles win same-name collisions over
|
||||
user and built-in bundles. LSP, filesystem roots, and lifecycle mutation
|
||||
are inventory-only: declare them only when inventorying future work. A
|
||||
`native` entry runs only under the experimental extension host; there it
|
||||
must be one `.mjs`, `.js` or `.mts` ES module file, `/plugin validate` rejects
|
||||
anything else, and its tools always use `Required` approval, never a
|
||||
plugin's read-only hint. Full Access, Bypass, or an exact session grant
|
||||
for the reviewed build can satisfy that gate without a prompt. A bundle
|
||||
that declares only unsupported surfaces cannot be enabled.
|
||||
7. Validate and review without executing bundle content:
|
||||
- `/plugin validate <plugin-name>`
|
||||
- `/plugin show <plugin-name>`
|
||||
- stop and present these results; the person runs `/plugin enable <plugin-name>`
|
||||
to open the content/capability review, reviews it, runs the exact
|
||||
`/plugin trust ...` confirmation shown, then enables the bundle
|
||||
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
|
||||
reports the expected trust and activation state. Trust stages the reviewed
|
||||
content but does not activate it. After enablement, follow the host's
|
||||
reload notice: use `/reload` or a new session to apply changes to a live
|
||||
session's pinned skills and tools.
|
||||
|
||||
Every user and workspace bundle starts untrusted and disabled. Reuse the
|
||||
existing `/plugin marketplace`, install, update, review and reload surfaces;
|
||||
do not add a parallel installer, registry or automatic trust flow. Catalog
|
||||
membership alone never installs, trusts or enables a plugin.
|
||||
|
||||
## Experimental host code
|
||||
|
||||
Only scaffold host code when the person explicitly uses the experimental
|
||||
extension-host feature. Start from the tested `hello-extension` example and
|
||||
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
|
||||
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
|
||||
with a plugin-specific prefix and an object input schema, propagate
|
||||
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
|
||||
current execution context exposes `signal`, `callId` and `args`; it does not
|
||||
expose the calling workspace path. Do not change the shared process cwd.
|
||||
|
||||
Stop after install, validate and show; never automate the trust token. A
|
||||
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
|
||||
owner state, live tools and recent attributed diagnostics. Recovery may create
|
||||
fresh registrations, but never replays an interrupted tool call. Explain the
|
||||
shared-process and current platform sandbox limits without claiming isolation.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: plugin-creator
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and a trust review that goes through the session's approval.
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
@@ -54,24 +54,9 @@ code) are inventory-only unless the user has turned on the experimental
|
||||
7. Validate and review without executing bundle content:
|
||||
- `/plugin validate <plugin-name>`
|
||||
- `/plugin show <plugin-name>`
|
||||
- present these results; the person runs `/plugin enable <plugin-name>`
|
||||
- stop and present these results; the person runs `/plugin enable <plugin-name>`
|
||||
to open the content/capability review, reviews it, runs the exact
|
||||
`/plugin trust ...` confirmation shown, then enables the bundle
|
||||
- exception: in Full Access, for a plugin you wrote yourself, you may
|
||||
trust and enable it without asking the person. Write it in an ordinary
|
||||
folder inside the current project (for example
|
||||
`<workspace>/plugins/<plugin-name>/`), not under `.codewhale/plugins/`
|
||||
as in step 1, because `codewhale install` copies it into place and
|
||||
refuses a name that is already in use. Then run, in this order:
|
||||
`codewhale install <folder>`, `codewhale plugins trust <name>` (it prints
|
||||
the review and a review token), `codewhale plugins trust <name>
|
||||
--review-token <token> --yes` with the token from your own run, and
|
||||
`codewhale plugins enable <name>`. These are shell commands, so the
|
||||
session's approval applies to them like any other (Ask asks the person,
|
||||
Auto-Review's reviewer judges, Full Access runs them). The trust is
|
||||
recorded as approved by the agent, for this project only. A bundle that
|
||||
was not installed this way from inside the project is refused. Never do
|
||||
this for a plugin you did not write.
|
||||
8. Verify `/skills inspect` reports plugin provenance and `/plugin list`
|
||||
reports the expected trust and activation state. Trust stages the reviewed
|
||||
content but does not activate it. After enablement, follow the host's
|
||||
@@ -80,18 +65,22 @@ code) are inventory-only unless the user has turned on the experimental
|
||||
|
||||
Every user and workspace bundle starts untrusted and disabled. Reuse the
|
||||
existing `/plugin marketplace`, install, update, review and reload surfaces;
|
||||
do not add a parallel installer or registry. Catalog membership alone never
|
||||
installs, trusts or enables a plugin.
|
||||
do not add a parallel installer, registry or automatic trust flow. Catalog
|
||||
membership alone never installs, trusts or enables a plugin.
|
||||
|
||||
## Plugins with code or a screen
|
||||
## Experimental host code
|
||||
|
||||
For a plugin that runs code, adds tools of its own, or shows a screen, use the
|
||||
`cordis-plugin-development` skill when it is offered: it teaches the format
|
||||
that also loads in DeepSeek Harness. Host code runs only under the
|
||||
experimental `[features] extension_host` flag; start from the tested
|
||||
`hello-extension` example and `docs/EXTENSIONS.md` in the Codewhale repository,
|
||||
add a `plugin.json` beside the package for Codewhale's own kinds, and never
|
||||
change the shared process cwd. A person reviews and turns on a plugin as step 7
|
||||
says. `/plugin show <name>` reports owner state, live tools and recent
|
||||
attributed diagnostics. Explain the shared-process and current platform sandbox
|
||||
limits without claiming isolation.
|
||||
Only scaffold host code when the person explicitly uses the experimental
|
||||
extension-host feature. Start from the tested `hello-extension` example and
|
||||
`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use
|
||||
Node's erasable TypeScript syntax; bundle dependencies locally. Register tools
|
||||
with a plugin-specific prefix and an object input schema, propagate
|
||||
`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The
|
||||
current execution context exposes `signal`, `callId` and `args`; it does not
|
||||
expose the calling workspace path. Do not change the shared process cwd.
|
||||
|
||||
Stop after install, validate and show; never automate the trust token. A
|
||||
person reviews, trusts and enables the bundle. `/plugin show <name>` reports
|
||||
owner state, live tools and recent attributed diagnostics. Recovery may create
|
||||
fresh registrations, but never replays an interrupted tool call. Explain the
|
||||
shared-process and current platform sandbox limits without claiming isolation.
|
||||
@@ -42,7 +42,7 @@ use sha2::{Digest, Sha256};
|
||||
/// person; the exact generation-16 body allows a safe upgrade.
|
||||
/// Generation 18 recognizes previously shipped bodies by SHA-256 as well as by
|
||||
/// retained bytes, so an untouched copy from any earlier generation upgrades.
|
||||
const BUNDLED_SKILL_VERSION: &str = "22";
|
||||
const BUNDLED_SKILL_VERSION: &str = "21";
|
||||
|
||||
mod dsh_creator;
|
||||
use dsh_creator::{BundledFile, CREATOR_SKILLS};
|
||||
@@ -176,10 +176,6 @@ const SUPERSEDED_BODIES: &[(&str, &str)] = &[
|
||||
"plugin-creator",
|
||||
include_str!("../assets/skills/plugin-creator/SKILL.generation-16.md"),
|
||||
),
|
||||
(
|
||||
"plugin-creator",
|
||||
include_str!("../assets/skills/plugin-creator/SKILL.generation-19.md"),
|
||||
),
|
||||
(
|
||||
"help",
|
||||
include_str!("../assets/skills/help/SKILL.generation-14.md"),
|
||||
|
||||
@@ -70,7 +70,6 @@ plugin-creator 935a64e72137a2420910a3365f8d4000038987fa0753648e9751dd79eae5cc30
|
||||
plugin-creator a22e9b25e4ba8eac43007899547bf4471e79a18d3b55f60df4cf073271529104
|
||||
plugin-creator b150b79446d876a2cd572aacd7be7712fd9914c340b51969a29186f85db4e18a
|
||||
plugin-creator c20a792055a4b5601e58b2fb721cd896a34f572a4acdabd1a706d46ce77eb639
|
||||
plugin-creator c761d6774b751a985ffed2894f595d8f871430b0a05a8e94bb7245399d811de7
|
||||
plugin-creator ce156ea9c6e2896b9c6c0da08f3d78c27bb793f685bcd2afbbffd712a5f64387
|
||||
plugin-creator e91aa6dfde62abd9548dda7a43bc097a82aa19385335117e86cb204edc9e294d
|
||||
presentations 2b6d20a00280f3de7814ebd8870e6e1af08c11bd115603b265fc31b9004f9d35
|
||||
|
||||
@@ -874,8 +874,7 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
|
||||
.find(|skill| skill.name == "plugin-creator")
|
||||
.unwrap();
|
||||
assert!(skill.body.contains("Create `plugin.json`"));
|
||||
assert!(skill.body.contains("cordis-plugin-development"));
|
||||
assert!(!skill.body.contains("never automate the trust token"));
|
||||
assert!(skill.body.contains("## Experimental host code"));
|
||||
for (generation, old) in [
|
||||
(
|
||||
"15",
|
||||
@@ -885,16 +884,9 @@ fn generation_17_refreshes_previous_plugin_creator_bodies() {
|
||||
"16",
|
||||
include_str!("../../assets/skills/plugin-creator/SKILL.generation-16.md"),
|
||||
),
|
||||
(
|
||||
"19",
|
||||
include_str!("../../assets/skills/plugin-creator/SKILL.generation-19.md"),
|
||||
),
|
||||
] {
|
||||
assert!(is_superseded_shipped_body("plugin-creator", old));
|
||||
assert_eq!(
|
||||
old.contains("## Experimental host code"),
|
||||
generation == "19"
|
||||
);
|
||||
assert!(!old.contains("## Experimental host code"));
|
||||
for customized in [false, true] {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(skill_dir(&tmp, "plugin-creator")).unwrap();
|
||||
|
||||
@@ -1457,7 +1457,6 @@ impl SnapshotRepo {
|
||||
.arg(&self.git_dir)
|
||||
.arg("--work-tree")
|
||||
.arg(&self.work_tree)
|
||||
.args(["-c", "core.quotePath=false"])
|
||||
.args(args);
|
||||
let output = run_bounded_git(&mut cmd, subcommand)?;
|
||||
if !output.status.success() {
|
||||
@@ -1851,7 +1850,7 @@ impl SnapshotRepo {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| io_other(format!("clock error: {e}")))?
|
||||
.as_secs() as i64;
|
||||
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
|
||||
let cutoff = now - max_age.as_secs() as i64;
|
||||
|
||||
let snapshots = self.list(usize::MAX)?;
|
||||
if snapshots.is_empty() {
|
||||
@@ -3949,22 +3948,6 @@ mod tests {
|
||||
assert_eq!(list[0].label, "turn:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_with_an_age_beyond_i64_keeps_every_snapshot() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
|
||||
repo.snapshot("turn:0").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
|
||||
let removed = repo
|
||||
.prune_older_than(Duration::from_secs(u64::MAX))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(repo.list(10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The 2026-08-04 regression: with a cut in the MIDDLE of history,
|
||||
/// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
|
||||
/// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
|
||||
@@ -4909,24 +4892,6 @@ mod tests {
|
||||
assert_eq!(none.patch, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_tree_changes_since_names_non_ascii_paths_as_written() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
let ws = repo.work_tree().to_path_buf();
|
||||
std::fs::write(ws.join("笔记 📝.md"), b"old\n").unwrap();
|
||||
let before = repo.snapshot("pre-turn:1").expect("snapshot");
|
||||
git_output(&repo, &["config", "core.quotePath", "true"]);
|
||||
std::fs::write(ws.join("笔记 📝.md"), b"new\n").unwrap();
|
||||
|
||||
let changes = repo
|
||||
.work_tree_changes_since(&before, 1 << 20)
|
||||
.expect("changes");
|
||||
assert_eq!(changes.names, "笔记 📝.md\n");
|
||||
assert!(changes.stat.contains("笔记 📝.md"), "{}", changes.stat);
|
||||
assert!(changes.patch.contains("b/笔记 📝.md"), "{}", changes.patch);
|
||||
}
|
||||
|
||||
/// A tree the repo no longer holds — the receipt outlived the object —
|
||||
/// is reported as absent rather than as a diff failure, so a caller can
|
||||
/// tell pruning from a broken repo without reading git's stderr.
|
||||
|
||||
+2
-37
@@ -11,7 +11,7 @@ mod runtime_aliases;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
|
||||
|
||||
@@ -1453,7 +1453,6 @@ impl StateStore {
|
||||
self.with_session_index_lock(|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
@@ -1462,14 +1461,7 @@ impl StateStore {
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut last = *b"\n";
|
||||
if file.metadata()?.len() > 0 {
|
||||
file.seek(SeekFrom::End(-1))?;
|
||||
file.read_exact(&mut last)?;
|
||||
}
|
||||
let separator = if last[0] == b'\n' { "" } else { "\n" };
|
||||
writeln!(file, "{separator}{encoded}")
|
||||
.context("failed to append session index entry")?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
// Durability: without this a crash mid-write can leave a torn
|
||||
// final line. Reads tolerate one (see `session_index_map`), but
|
||||
// not losing the entry beats recovering from having lost it.
|
||||
@@ -2930,33 +2922,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_index_append_after_an_unterminated_torn_line_is_kept() {
|
||||
let store = temp_state_store("session-index-unterminated");
|
||||
store
|
||||
.append_thread_name("thread-1", Some("first".to_string()), 1, None)
|
||||
.expect("append first entry");
|
||||
{
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&store.session_index_path)
|
||||
.expect("open session index");
|
||||
write!(file, "{{\"thread_id\":\"thread-2\",\"thread_na").expect("write torn line");
|
||||
}
|
||||
|
||||
store
|
||||
.append_thread_name("thread-3", Some("third".to_string()), 3, None)
|
||||
.expect("append third entry");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.find_thread_name_by_id("thread-3")
|
||||
.expect("lookup thread-3")
|
||||
.as_deref(),
|
||||
Some("third"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook fired by compaction between writing the snapshot and renaming it
|
||||
/// over the live index — the window a concurrent append can be lost in.
|
||||
/// Only the store whose path a test registered is affected, so tests
|
||||
|
||||
+1
-23
@@ -7,29 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
|
||||
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
|
||||
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
|
||||
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
|
||||
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
|
||||
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
|
||||
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
|
||||
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
|
||||
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
|
||||
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
|
||||
@@ -44,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
|
||||
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
|
||||
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
|
||||
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
|
||||
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
|
||||
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
|
||||
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
|
||||
|
||||
@@ -157,8 +157,7 @@ fn session_diff(app: &App) -> std::io::Result<Option<DebugDiffObservation>> {
|
||||
/// The workspace repository's own uncommitted changes: what `/diff` shows
|
||||
/// before the session has a restore point to compare against.
|
||||
fn workspace_git_diff(workspace: &std::path::Path) -> DebugDiffObservation {
|
||||
let git =
|
||||
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
|
||||
let git = |args: &[&str]| Git::output(args, workspace);
|
||||
// Outside a repository `git diff` prints nothing on stdout, which would
|
||||
// read as "no changes".
|
||||
match git(&["rev-parse", "--is-inside-work-tree"]) {
|
||||
|
||||
@@ -85,7 +85,6 @@ mod tests {
|
||||
aliases: aliases.iter().map(|s| s.to_string()).collect(),
|
||||
hidden,
|
||||
plugin_authority: None,
|
||||
workspace_sourced: false,
|
||||
extension: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,11 +296,6 @@ impl codewhale_engine::command_catalog::CommandCatalog for CommandTable {
|
||||
takes_arguments: metadata.takes_arguments(),
|
||||
hidden: metadata.hidden,
|
||||
extension: metadata.extension.is_some(),
|
||||
plugin_id: metadata
|
||||
.plugin_authority
|
||||
.as_ref()
|
||||
.map(|authority| authority.plugin_id.as_str().to_string()),
|
||||
workspace_sourced: metadata.workspace_sourced,
|
||||
builtin_bindings: bindings.remove(&metadata.name).unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -80,7 +80,6 @@ pub struct UserCommandMetadata {
|
||||
pub aliases: Vec<String>,
|
||||
pub hidden: bool,
|
||||
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
|
||||
pub workspace_sourced: bool,
|
||||
/// Set for a command contributed by an extension-host plugin: it runs in
|
||||
/// the host (`command/run`) instead of expanding `body`.
|
||||
pub extension: Option<codewhale_engine::extension_host::command::ExtensionCommandRef>,
|
||||
@@ -247,7 +246,6 @@ impl UserCommandRegistry {
|
||||
let (mut metadata, errors) =
|
||||
parse_metadata(entry.name, &entry.content, &entry.path);
|
||||
metadata.plugin_authority = entry.plugin_authority;
|
||||
metadata.workspace_sourced = entry.workspace_sourced;
|
||||
(metadata, errors, entry.path, entry.workspace_sourced)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -397,7 +395,6 @@ impl UserCommandRegistry {
|
||||
aliases: Vec::new(),
|
||||
hidden: false,
|
||||
plugin_authority: Some(entry.authority),
|
||||
workspace_sourced: false,
|
||||
extension: Some(reference),
|
||||
},
|
||||
);
|
||||
@@ -485,7 +482,6 @@ fn parse_metadata(
|
||||
aliases: Vec::new(),
|
||||
hidden: false,
|
||||
plugin_authority: None,
|
||||
workspace_sourced: false,
|
||||
extension: None,
|
||||
};
|
||||
let mut configured_name = None;
|
||||
|
||||
+1
-30
@@ -3216,7 +3216,6 @@ async fn run_async_main_dispatch(
|
||||
workspace,
|
||||
skills_dir,
|
||||
skills_config,
|
||||
config.tool_launcher.is_some(),
|
||||
)
|
||||
.await
|
||||
} else if http_selected {
|
||||
@@ -7868,7 +7867,7 @@ fn doctor_control_socket_posture_line(config: &Config) -> String {
|
||||
/// Report the durable opt-out and environment preference without arming or
|
||||
/// touching telemetry state. Notice presentation is not a permission gate.
|
||||
fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
|
||||
let (on, source) = codewhale_config::dispatched_telemetry_consent(config.telemetry);
|
||||
let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
|
||||
if !on {
|
||||
return (false, source.as_str());
|
||||
}
|
||||
@@ -16559,8 +16558,6 @@ mod doctor_setup_state_tests {
|
||||
let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
|
||||
let _telemetry_floor =
|
||||
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
|
||||
let _telemetry_source =
|
||||
crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_SOURCE");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
|
||||
@@ -16611,32 +16608,6 @@ mod doctor_setup_state_tests {
|
||||
let report = doctor_setup_report_json(&config, &workspace);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
|
||||
|
||||
{
|
||||
let _handed_down =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "false");
|
||||
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "0");
|
||||
let _source =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "config");
|
||||
let line = doctor_runtime_posture_line(&config, &workspace);
|
||||
assert!(
|
||||
line.contains("telemetry=off (config)"),
|
||||
"a saved opt-out the dispatcher handed down is not the environment: {line}"
|
||||
);
|
||||
let report = doctor_setup_report_json(&config, &workspace);
|
||||
assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
|
||||
}
|
||||
{
|
||||
let _by_hand = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY", "0");
|
||||
let _floor = crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_FLOOR", "1");
|
||||
let _source =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_TELEMETRY_SOURCE", "env");
|
||||
let line = doctor_runtime_posture_line(&config, &workspace);
|
||||
assert!(
|
||||
line.contains("telemetry=off (env)"),
|
||||
"a kill switch the person set is the environment: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -284,8 +284,8 @@ impl ContextMenuView {
|
||||
if start > 0 {
|
||||
rows.push(MenuRow::MoreAbove(start));
|
||||
}
|
||||
for (idx, entry) in self.entries.iter().enumerate().take(end).skip(start) {
|
||||
if idx > start && entry.section_start {
|
||||
for idx in start..end {
|
||||
if idx > start && self.entries[idx].section_start {
|
||||
rows.push(MenuRow::Divider);
|
||||
}
|
||||
rows.push(MenuRow::Entry(idx));
|
||||
|
||||
@@ -403,25 +403,6 @@ mod tests {
|
||||
assert!(!payload.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mentions_name_non_ascii_paths_as_written() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
Command::new("git")
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("笔记 📝.md"), "旧\n").unwrap();
|
||||
commit_all(dir.path(), "initial");
|
||||
std::fs::write(dir.path().join("笔记 📝.md"), "新\n").unwrap();
|
||||
|
||||
let status = resolve_git_mention(GitMentionKind::Status, dir.path());
|
||||
let diff = resolve_git_mention(GitMentionKind::Diff, dir.path());
|
||||
assert!(status.block.contains("笔记 📝.md"), "{}", status.block);
|
||||
assert!(diff.block.contains("b/笔记 📝.md"), "{}", diff.block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_covers_staged_and_unstaged_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -416,15 +416,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_notice_sheds_whole_clauses_and_never_dangles() {
|
||||
const NOTICE: &str = "Usage reporting is on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
|
||||
const NOTICE: &str = "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
|
||||
assert_eq!(fit_notice(NOTICE, 200, false).as_deref(), Some(NOTICE));
|
||||
assert_eq!(
|
||||
fit_notice(NOTICE, 80, false).as_deref(),
|
||||
Some("Usage reporting is on. Conversations and code are never collected.")
|
||||
Some("Anonymous usage counts are on. Conversations and code are never collected.")
|
||||
);
|
||||
assert_eq!(
|
||||
fit_notice(NOTICE, 40, false).as_deref(),
|
||||
Some("Usage reporting is on.")
|
||||
Some("Anonymous usage counts are on.")
|
||||
);
|
||||
assert_eq!(fit_notice(" ", 40, false), None);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::tui::views::{
|
||||
};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use codewhale_engine::session_manager::{
|
||||
MAX_SESSION_TITLE_CHARS, SavedSession, SessionListFilter, SessionManager, SessionMetadata,
|
||||
extract_title, extract_user_prompt, strip_thinking_tags,
|
||||
SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title,
|
||||
extract_user_prompt, strip_thinking_tags,
|
||||
};
|
||||
use codewhale_engine::session_projection::{MAX_PROJECTED_SESSIONS, SessionQuery, SessionSortMode};
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
@@ -787,7 +787,7 @@ impl SessionPickerView {
|
||||
self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned());
|
||||
return ViewAction::None;
|
||||
};
|
||||
if new_title.is_empty() || new_title.chars().count() > MAX_SESSION_TITLE_CHARS {
|
||||
if new_title.is_empty() || new_title.len() > 100 {
|
||||
self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned());
|
||||
return ViewAction::None;
|
||||
}
|
||||
@@ -2045,28 +2045,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_selected_limits_title_by_characters() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
|
||||
let manager = SessionManager::default_location().expect("session manager");
|
||||
let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]);
|
||||
saved.metadata.id = "session-cjk".to_string();
|
||||
manager.save_session(&saved).expect("save session");
|
||||
let mut view = picker_with(vec![saved.metadata.clone()], None);
|
||||
|
||||
let title = "中文会话标题".repeat(6);
|
||||
assert!(matches!(
|
||||
view.rename_selected(&title),
|
||||
ViewAction::Emit(ViewEvent::SessionRenamed { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
manager.load_session("session-cjk").unwrap().metadata.title,
|
||||
title
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_toggle_persists_hides_the_row_and_emits_an_archive_receipt() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
|
||||
@@ -1441,8 +1441,8 @@ fn prose_cells_fill_full_width_on_ultrawide_by_default() {
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
|
||||
|
||||
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
|
||||
let width = max_line_width(&cell.lines);
|
||||
for idx in 0..3 {
|
||||
let width = max_line_width(&cache.per_cell[idx].lines);
|
||||
assert!(
|
||||
width > RETIRED_RAIL_MEASURE,
|
||||
"prose cell {idx} wrapped to {width} columns — still on the retired \
|
||||
@@ -1495,8 +1495,8 @@ fn transcript_prose_measure_caps_prose_but_not_tools() {
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
|
||||
|
||||
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
|
||||
let width = max_line_width(&cell.lines);
|
||||
for idx in 0..3 {
|
||||
let width = max_line_width(&cache.per_cell[idx].lines);
|
||||
assert!(
|
||||
width > 105,
|
||||
"prose cell {idx} wrapped to {width} columns — still on the retired \
|
||||
|
||||
@@ -1771,7 +1771,6 @@ mod tests {
|
||||
.collect(),
|
||||
confidence_bp: 6400,
|
||||
min_confidence_bp: 5000,
|
||||
abstained_questions: Vec::new(),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: Some("max".to_string()),
|
||||
provider_reported_cost_usd: Some("0.000019992".to_string()),
|
||||
|
||||
@@ -1110,7 +1110,7 @@ mod screen_mode_tests {
|
||||
viewport: ratatui::Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.map_err(|err| match err {})
|
||||
.map_err(|err| io::Error::other(err.to_string()))
|
||||
},
|
||||
)
|
||||
.expect("a successful probe must switch");
|
||||
|
||||
@@ -8950,9 +8950,10 @@ fn setup_runtime_presets_preserve_environment_authority_and_state() {
|
||||
"DEEPSEEK_SANDBOX_MODE",
|
||||
]
|
||||
.map(EnvVarGuard::remove);
|
||||
let empty_managed = home._tmp.path().join("empty-managed.toml");
|
||||
std::fs::write(&empty_managed, "").expect("empty managed config");
|
||||
let _managed = EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", &empty_managed);
|
||||
let _managed = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
home._tmp.path().join("absent-managed.toml"),
|
||||
);
|
||||
let _requirements = EnvVarGuard::set(
|
||||
"CODEWHALE_REQUIREMENTS_PATH",
|
||||
home._tmp.path().join("absent-requirements.toml"),
|
||||
|
||||
@@ -2693,7 +2693,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
audit logging is enabled. Future PreToolUse/PostToolUse hooks can add
|
||||
observer input around this layer, but the configured auto-review policy is
|
||||
evaluated before a tool call is allowed to proceed.
|
||||
- `managed_config_path` (string, optional): managed config file loaded after user/env config. `CODEWHALE_MANAGED_CONFIG_PATH` outranks it. A named file that is missing or unreadable is a load error, and it cannot replace an existing file at the default managed path.
|
||||
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
|
||||
- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.
|
||||
- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.
|
||||
- `subagents.*` (optional compatibility table): manual per-role model pins
|
||||
|
||||
@@ -44,7 +44,6 @@ changelog or release notes, and this contributor record.
|
||||
|
||||
**Contributions**
|
||||
|
||||
- **[MannXo](https://github.com/MannXo)** — added Qwen Code to the external agent registry so an installed Qwen Code CLI runs as a session over ACP ([#7293](https://github.com/codewhale-hq/Codewhale/pull/7293)).
|
||||
- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — drove Responses request, URL and stream behavior from one checked dialect profile table ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)).
|
||||
- **[gaord](https://github.com/gaord)** — ran the read-only `/system`, `/tokens` and `/cost` builtins over the Runtime API through one gated terminal-host catalog ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)).
|
||||
|
||||
|
||||
@@ -129,19 +129,12 @@ the `agent/created` hook fires only for children the same plugin starts.
|
||||
|
||||
## Plugin UI (`dsh.client`)
|
||||
|
||||
An imported DSH plugin's client UI is served, on the seats the Engine admits.
|
||||
The importer retains the reviewed client closure; at serve time the Engine
|
||||
re-verifies it byte for byte against the reviewed hashes, maps each declared
|
||||
slot key to a seat, and loads one Engine-generated bootstrap built from those
|
||||
bytes. A native client is admitted only on `customize.plugins.detail`. A slot
|
||||
key with no seat answers `plugin_ui_seat_unsupported`; several keys on one seat
|
||||
need the caller to name one (`plugin_ui_dsh_slot_required`). A closure that needs
|
||||
a host module the frame does not provide, uses ES module syntax or is not valid
|
||||
JSON answers `plugin_ui_unsupported`. Code Review 1.0.8's client decorates
|
||||
global command candidates through DSH services that the current detail-slot
|
||||
renderer does not provide, so it is not qualified.
|
||||
The route that forwards a client's requests to a plugin web server answers
|
||||
`plugin_route_not_served` (501): no plugin web server exists yet.
|
||||
Imported DSH client UIs are not supported. The Engine refuses their client
|
||||
descriptor and asset grants when the reviewed conversion report identifies a
|
||||
DSH import; the plugin's supported Native and MCP contributions keep their
|
||||
existing lifecycle. Code Review 1.0.8's client decorates global command
|
||||
candidates through DSH services that the current detail-slot renderer does
|
||||
not provide.
|
||||
|
||||
The selected Electron host's reviewed asset transport and isolated frame do
|
||||
not establish compatibility with those services. Its real network containment
|
||||
|
||||
@@ -161,17 +161,6 @@ separately from the #6953 reporter-observed exports; the x64 profile now allows
|
||||
468 pairs. The candidate still has to pass the import check against its final
|
||||
executable bytes, and neither addition is a 14393 or installer run.
|
||||
|
||||
Release candidate run 43186389246 on source `f5a3dda975` then stopped the same
|
||||
check on three more pairs, `GetSidIdentifierAuthority`, `GetSidSubAuthority` and
|
||||
`GetSidSubAuthorityCount` from advapi32.dll, on both 64-bit targets. Probe run
|
||||
43197611105 (Windows 11 ARM64 build 26200.9725) resolved all 447 imports of
|
||||
that candidate's ARM64 executables, and the 3 pairs were added. For x64 the 3
|
||||
pairs are documented Microsoft API contracts (Windows XP minimum on each
|
||||
function's reference page), labelled apart from the #6953 reporter-observed
|
||||
exports; the x64 profile now allows 471 pairs and the ARM64 profile 473. The
|
||||
same limit applies: the candidate still has to pass the import check against
|
||||
its final executable bytes, and neither addition is a 14393 or installer run.
|
||||
|
||||
At this snapshot, startup/installation of the final release bytes on an actual
|
||||
1607 machine and the candidate's generated uninstaller were still unqualified.
|
||||
The final frozen source, release import dumps, archive/native/npm installation,
|
||||
|
||||
+1
-1
@@ -545,7 +545,7 @@ Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}
|
||||
|
||||
Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call.
|
||||
|
||||
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. When a plugin tool asks the core to run a tool that needs approval (`exec.core`), the editor's permission request is shown on the plugin tool's call and names the nested tool and its input. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
|
||||
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
|
||||
|
||||
## Asking the core to run a tool
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# Hooks
|
||||
> 阅读简体中文版:[zh_hans/HOOKS.md](zh_hans/HOOKS.md)
|
||||
|
||||
Everything you add to Codewhale is a plugin. A hook in `config.toml` or a
|
||||
project `hooks.toml` is a plugin of the kind **Hook**, and a plugin bundle can
|
||||
also carry hooks. Hooks are still managed under Extensions → Hooks in the
|
||||
terminal and Customize › Hooks on the desktop; one Plugins list that shows every
|
||||
kind together is planned for 0.10.3 and is not in this build. Configuration and
|
||||
the `/hooks` commands are unchanged.
|
||||
|
||||
Hooks run a shell command when the Codewhale **TUI** reaches a lifecycle
|
||||
point. They are plain processes: they receive context through environment
|
||||
variables, some receive a JSON payload on stdin, and three of them can steer
|
||||
|
||||
@@ -5,15 +5,6 @@ In the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens
|
||||
|
||||
> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)
|
||||
|
||||
Everything you add to Codewhale is a plugin. An MCP server you configure is a
|
||||
plugin of the kind **Connector**, and a plugin bundle can also carry connectors.
|
||||
Connectors are still managed under Extensions → MCP in the terminal and
|
||||
Customize › Connectors on the desktop; one Plugins list that shows every kind
|
||||
together is planned for 0.10.3 and is not in this build. The configuration
|
||||
files, sign-in and the `/mcp` commands are unchanged. "Connection" on this page
|
||||
means the live link to a server; as a product noun it is the word for a model
|
||||
provider or another agent.
|
||||
|
||||
Codewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that Codewhale starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.
|
||||
|
||||
## Version scope
|
||||
|
||||
+7
-19
@@ -12,25 +12,13 @@ format (`plugin.json`, compatible `kimi.plugin.json` or
|
||||
the trust/enable lifecycle — this document covers how bits get onto disk in
|
||||
the first place.
|
||||
|
||||
Everything you add to Codewhale is a plugin. A skill, an MCP connector and a hook
|
||||
are each a plugin, or part of one, and each has a kind: Skill, Connector, Hook,
|
||||
Command. A skill you set up by hand is a plugin of the kind Skill, a server you
|
||||
configure is a plugin of the kind Connector, and a hook you configure is a plugin
|
||||
of the kind Hook. They are still managed where they are today: Extensions →
|
||||
Skills, MCP and Hooks in the terminal, and Customize › Skills, Connectors and
|
||||
Hooks on the desktop. Their files, trust rules and commands (`/skills`, `/mcp`,
|
||||
`/hooks`) do not change. One Plugins list that shows every kind together is
|
||||
planned for 0.10.3 and is not in this build; today the kind labels appear on the
|
||||
parts inside a plugin bundle's detail. "Connection" is the word for a model
|
||||
provider or another agent; it is not a kind of plugin.
|
||||
|
||||
## Choose what to add
|
||||
## Choose a plugin, skill, or MCP connection
|
||||
|
||||
| What you need | Use | Starting point |
|
||||
| --- | --- | --- |
|
||||
| Reusable instructions that work through your existing tools | A plugin that adds a skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |
|
||||
| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin bundle | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |
|
||||
| Tools or context from a particular local process or remote service | A plugin that adds an MCP connector | `/mcp`; [MCP setup](MCP.md) |
|
||||
| Reusable instructions that work through your existing tools | A skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |
|
||||
| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |
|
||||
| Tools or context from a particular local process or remote service | A direct MCP connection | `/mcp`; [MCP setup](MCP.md) |
|
||||
|
||||
A plugin can combine skills, Markdown commands, Fleet agent profiles, hooks,
|
||||
MCP servers and AI-provider declarations. Tools come from an MCP server or an
|
||||
@@ -38,12 +26,12 @@ experimental native extension; there is no generic application-UI component in
|
||||
the bundle manifest. Computer Use's Mac helper and Chromewhale's browser
|
||||
extension have their own setup after plugin review.
|
||||
|
||||
An MCP connector is one of two kinds: a local process over stdio, or a remote URL over
|
||||
MCP has two connection kinds: a local process over stdio, or a remote URL over
|
||||
Streamable HTTP. Legacy SSE is available when the server still supports it.
|
||||
User-configured remote servers can use environment-backed credentials or the
|
||||
host's OAuth login. Plugin-contributed remote servers require explicit
|
||||
environment-backed credentials when authentication is needed; their interactive
|
||||
OAuth path is not supported. Installing a plugin does not connect an
|
||||
OAuth path is not supported. Installing a skill or plugin does not connect an
|
||||
account or establish that a service works.
|
||||
|
||||
A local stdio server runs with your operating-system user's access. Requested
|
||||
@@ -75,7 +63,7 @@ Native TypeScript extensions require `extension_host = true` under `[features]`
|
||||
and fresh capability review. LSP, requested filesystem roots and lifecycle mutation
|
||||
remain inventory-only. Current DSH adapters include work beyond the published
|
||||
v0.10.2 CLI; third-party DSH plugins' complete workflows are not established by
|
||||
an import succeeding. An imported DSH plugin's UI is served only on the seats the Engine admits ([details](DSH_PLUGIN_COMPAT.md#plugin-ui-dshclient)). Other applications' plugin
|
||||
an import succeeding. DSH plugin UI is unsupported. Other applications' plugin
|
||||
directories and credentials are not borrowed.
|
||||
|
||||
`/plugin suggest <task>` is a local, read-only companion: it ranks already
|
||||
|
||||
+2
-44
@@ -882,17 +882,8 @@ foreground shell tools, plus reviewed plugin tools (`extension_host` feature) an
|
||||
the tools of MCP servers configured in Codewhale (`mcp` feature), under the same
|
||||
permission setting. Shell requires both editor terminal support and operator
|
||||
`allow_shell`; a requested external sandbox that is unavailable removes shell.
|
||||
Plugin tools and configured MCP servers on this transport are on main after
|
||||
0.10.2; 0.10.2 itself does not have them.
|
||||
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
|
||||
interpreter, subagent and RLM lifecycles are unavailable on this transport.
|
||||
ACP has no tool search, so every connected MCP tool and its schema is sent with
|
||||
every request, and a server marked `lazy = true` never starts. A large MCP set
|
||||
therefore costs tokens on each turn and can exceed a provider's tool limit.
|
||||
MCP servers a model starts at run time stay out of an ACP-profile host. When
|
||||
`serve --acp` instead attaches to a running Runtime owner (see below), its turns
|
||||
use that owner's MCP pool, which includes servers started at run time in other
|
||||
sessions of the same workspace.
|
||||
Built-in overrides remove the
|
||||
whole compatibility alias family. Final dispatch rechecks the profile, so a
|
||||
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
|
||||
@@ -913,11 +904,7 @@ produced.
|
||||
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
|
||||
`completed`/`failed` and typed image blocks come from the actual Core result.
|
||||
`session/request_permission` correlates a private JSON-RPC ID to the same
|
||||
Runtime-minted pending approval and Core execution ID. When a plugin tool asks
|
||||
the core to run a tool that needs approval (`exec.core`), the request is shown
|
||||
on the plugin tool's call, names the nested tool and carries its input, and the
|
||||
answer releases the nested call; the nested call is decided under the session's
|
||||
permission setting. Only an exact live
|
||||
Runtime-minted pending approval and Core execution ID. Only an exact live
|
||||
`allow-once` response can release that waiter; wrong IDs are ignored, invalid
|
||||
options deny, and cancellation withdraws it. ACP grants neither remembered
|
||||
permissions nor Native capabilities. Core typed rules, strict hooks, repo law,
|
||||
@@ -2683,35 +2670,6 @@ thread deletion.
|
||||
pure over the snapshot — so a client must not look for one. Anything that
|
||||
mutates the session stays a terminal-side command.
|
||||
|
||||
**Plugin index**
|
||||
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
|
||||
problems: [...]}`: one list across every plugin, skill, connector, hook and
|
||||
hand-written command. It is a projection: each item's `row` is the row its
|
||||
owner's own route returns (`/v1/apps/plugins`, `/v1/apps/plugins/catalog`,
|
||||
`/v1/skills`, `/v1/apps/mcp/servers`, `/v1/hooks`, `/v1/commands`), and
|
||||
`owner` names that route. Per item: `id`, `kind` (`plugin`, `skill`,
|
||||
`connector`, `hook`, `command`), `name`, `description`, `source`
|
||||
(`built_in`, `user`, `project`, `other_app`, `runtime`, `plugin`), `state`
|
||||
(`enabled`, `disabled`, `needs_review`, `needs_sign_in`, `unavailable`) and
|
||||
`labels` (what the item adds: `skill`, `connector`, `hook`, `command`,
|
||||
`tool`, `profile`). A plugin item also has `adds` (`skills`, `connectors`,
|
||||
`hooks`, `commands`, `profiles`: names, not rows), and what a plugin adds is
|
||||
not listed a second time as an item of its own. A built-in switch is a
|
||||
`plugin` item with a `tier` and no labels. `/v1/commands` rows carry
|
||||
`plugin_id` (the same id as `/v1/apps/plugins`, and the `plugin:<id>` item)
|
||||
for a command a plugin adds, and `workspace_sourced` for one read from the
|
||||
project; a command item's `plugin` is that id. An owner that fails is named in
|
||||
`unavailable` and the other kinds still list; `problems` carries the owners'
|
||||
load warnings. Names under `adds` are file names, so a profile's `id` inside its
|
||||
file can differ; a command another plugin registers through the extension host
|
||||
is an item with `source` `plugin`. For an active plugin `adds.commands` comes
|
||||
from the `/v1/commands` rows; for any other plugin, and for `hooks` and
|
||||
`profiles`, it is the file names found in the bundle, at most 512 per kind,
|
||||
with names holding control characters left out; a cut list is reported
|
||||
under `problems`. A hook's row names no plugin, so `adds.hooks` stays file
|
||||
names. `tool` means the plugin declares native or language-server components;
|
||||
a screen a plugin draws is not labelled yet.
|
||||
|
||||
**Hooks**
|
||||
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
|
||||
problems: [...]}`: the hook set Runtime API threads run for that workspace
|
||||
@@ -3310,7 +3268,7 @@ refuses OpenAI Codex. An Engine without this route answers `404`.
|
||||
|
||||
Reports which external agent CLIs this computer already has, without starting a
|
||||
session. It covers Claude, Codex, DeepSeek Harness, Antigravity (`agy acp`),
|
||||
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy, Grok and Qwen Code. `state` is `installed` or `not_installed`; a binary is
|
||||
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy and Grok. `state` is `installed` or `not_installed`; a binary is
|
||||
looked up on `PATH` (absolute entries only; on Windows the spawnable PATHEXT suffixes `.com/.exe/.bat/.cmd` and `%APPDATA%\npm`) and in the usual per-user bin directories. For an installed
|
||||
agent the route runs `--version` and, where the CLI speaks Agent Client Protocol
|
||||
itself, one `initialize` request on stdin, concurrently. `acp` is `native` when
|
||||
|
||||
@@ -54,7 +54,6 @@ registration index.
|
||||
| POST | `/v1/apps/mcp/servers/{name}/enable` |
|
||||
| POST | `/v1/apps/mcp/servers/{name}/reconnect` |
|
||||
| GET | `/v1/apps/mcp/tools` |
|
||||
| GET | `/v1/apps/plugin-index` |
|
||||
| GET | `/v1/apps/plugins` |
|
||||
| DELETE / GET | `/v1/apps/plugins/{selector}` |
|
||||
| POST | `/v1/apps/plugins/{selector}/disable` |
|
||||
@@ -264,4 +263,4 @@ registration index.
|
||||
| GET / POST | `/v1/worktrees` |
|
||||
| POST | `/v1/worktrees/remove` |
|
||||
|
||||
The source contains 244 distinct literal paths in this table.
|
||||
The source contains 243 distinct literal paths in this table.
|
||||
+22
-35
@@ -145,27 +145,20 @@ The key is read only from the managed configuration file named by
|
||||
Linux, and only when neither that file nor any directory above it can be
|
||||
changed by the Engine's own user. A managed file at any other path (for
|
||||
example one a user config points at through `managed_config_path`) is refused.
|
||||
In a user `config.toml` or a profile the key is a load error. The managed
|
||||
file must also set `sandbox_mode` itself; a sandboxed mode from a user config
|
||||
does not count. Without the key, nothing on this page changes.
|
||||
In a user `config.toml` or a profile the key is a load error. The managed file must also set `sandbox_mode` itself; a sandboxed mode
|
||||
from a user config does not count. Without the key, nothing on this page
|
||||
changes.
|
||||
|
||||
A host that relies on the launcher puts the managed file in a root-owned
|
||||
place and sets `CODEWHALE_MANAGED_CONFIG_PATH` to it for the Engine's service.
|
||||
The variable outranks a user config's `managed_config_path`, and a Fleet local
|
||||
worker (a child `codewhale exec`) gets the parent's variable even though its
|
||||
environment is otherwise cleaned; a worker request cannot replace it. Any
|
||||
other `codewhale` process sees a file named only by the variable when it is
|
||||
started with the variable. An MCP server entry that runs `codewhale serve
|
||||
--mcp` is started with a cleaned environment and does not get it. A host that
|
||||
relies on the launcher therefore also keeps the file at the default managed
|
||||
path, or sets the variable in every such entry. A file named by the variable
|
||||
or by `managed_config_path` that is missing or cannot be read is a load error,
|
||||
not "no managed layer", and a user config cannot point `managed_config_path`
|
||||
away from an existing file at the default managed path.
|
||||
Only a missing file at the default path, with nothing naming another, means no
|
||||
managed layer. A file in another ordinary user's home also passes the
|
||||
"Engine's user cannot change it" check, so the file should be root-owned.
|
||||
Check `sandbox.tool_launcher` in `GET /v1/runtime/info`: the route
|
||||
A host that relies on the launcher puts the managed file at the default
|
||||
managed path, owned by root, and also sets `CODEWHALE_MANAGED_CONFIG_PATH` to
|
||||
that same path for the Engine's service. The variable stops a user config from
|
||||
pointing `managed_config_path` at a missing file; the default path is needed
|
||||
because a Fleet local worker (a child `codewhale exec`) is started with a
|
||||
cleaned environment and does not get the variable; it reads the user config's
|
||||
`managed_config_path` and then the default path, so a user config can still
|
||||
point a worker at a missing file. A file in another ordinary user's home also
|
||||
passes the "Engine's user cannot change it" check, so the file should be
|
||||
root-owned. Check `sandbox.tool_launcher` in `GET /v1/runtime/info`: the route
|
||||
reads the live configuration, but a check made once at start does not cover a
|
||||
later reload, so ask again after a reload.
|
||||
|
||||
@@ -200,24 +193,18 @@ With a launcher configured:
|
||||
|
||||
Limits: only commands the sandbox manager prepares (shell commands, terminal
|
||||
sessions, workspace runners) go through the launcher. In-process file tools,
|
||||
MCP servers, hooks, LSP servers, the extension host, external ACP agents and
|
||||
the Engine's own `git`, formatter and diagnostic helpers still run as the
|
||||
Engine's user. A Fleet local worker is its own Engine, so its shell commands
|
||||
go through the launcher only because it loads the same managed file.
|
||||
`codewhale serve --mcp` does not run commands through the launcher. When the
|
||||
managed file it loads sets a launcher, it does not expose `shell` or
|
||||
`exec_shell`, whatever its own `expose_tools` lists; its file tools use the
|
||||
built-in read deny list only. Model-written Python in the session REPL kernel
|
||||
(` ```repl ` blocks and the `rlm` tool) runs in a local interpreter as the
|
||||
Engine's user. With `sandbox_backend = "opensandbox"` a shell command goes to
|
||||
the remote backend before the launcher is consulted, so a launcher host pins
|
||||
MCP servers, hooks, LSP servers, the extension host, Fleet local workers,
|
||||
external ACP agents and the Engine's own `git`, formatter and diagnostic
|
||||
helpers still run as the Engine's user. `codewhale serve --mcp` builds its own
|
||||
shell manager with no launcher and no deny list, so do not expose `exec_shell`
|
||||
there. With `sandbox_backend = "opensandbox"` a shell command goes to the
|
||||
remote backend before the launcher is consulted, so a launcher host pins
|
||||
`sandbox_backend = "none"`, `prefer_bwrap = true` and `sandbox_network_access`
|
||||
in the managed file. A `tool_launcher` in a project config stops an
|
||||
interactive launch with an error; `serve` and `exec` do not read project
|
||||
config, so there the key is ignored. Deny-list exceptions are not applied
|
||||
under a launcher. Search rights come from mode bits and group membership, not
|
||||
ACLs. Processes keep running after the Engine itself dies, until bubblewrap's
|
||||
parent exits.
|
||||
config, so there the key is ignored. Deny-list exceptions are not applied under a launcher. Search
|
||||
rights come from mode bits and group membership, not ACLs. Processes keep
|
||||
running after the Engine itself dies, until bubblewrap's parent exits.
|
||||
|
||||
## Windows: no advertised OS sandbox
|
||||
|
||||
|
||||
@@ -5,13 +5,6 @@ In the terminal, `/skills` opens **Extensions → Skills**. Enter on a skill ope
|
||||
|
||||
> 阅读简体中文版:[zh_hans/SKILLS.md](zh_hans/SKILLS.md)
|
||||
|
||||
Everything you add to Codewhale is a plugin: a skill you set up by hand is a
|
||||
plugin of the kind **Skill**, and a skill inside a plugin bundle belongs to that
|
||||
plugin. Skills are still managed here, under Extensions → Skills and Customize ›
|
||||
Skills; one Plugins list that shows every kind together is planned for 0.10.3 and
|
||||
is not in this build. This page keeps the word "skill" for the kind; files, trust
|
||||
and the `/skills` commands are unchanged.
|
||||
|
||||
Skills are reusable `SKILL.md` instruction packs. Codewhale discovers them from
|
||||
several roots, but **only Codewhale-owned directories are writable**. The unified
|
||||
`/skills manage` manager is the interactive surface for audit and mutation; slash
|
||||
|
||||
+7
-7
@@ -141,8 +141,8 @@ owner = "crates/engine/src/tools/apply_patch.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "mcp-client"
|
||||
name = "MCP connectors"
|
||||
summary = "A plugin kind: connects MCP servers, exposes permitted tools and records connection status for the model."
|
||||
name = "MCP client"
|
||||
summary = "Connects MCP servers, exposes permitted tools and records connection status for the model."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
since = "0.1.0"
|
||||
@@ -532,8 +532,8 @@ owner = "crates/memory"
|
||||
|
||||
[[feature]]
|
||||
id = "skills"
|
||||
name = "Skill plugins"
|
||||
summary = "A plugin kind: trusted instruction packages with bundle policy, invocation controls and configurable index limits."
|
||||
name = "Skills"
|
||||
summary = "Trusted instruction packages with bundle policy, invocation controls and shared, configurable index limits."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
docs = "docs/SKILLS.md"
|
||||
@@ -542,7 +542,7 @@ owner = "crates/skills/src"
|
||||
[[feature]]
|
||||
id = "plugins"
|
||||
name = "Plugins"
|
||||
summary = "Everything you add is a plugin: installs, reviews and trusts bundles, shows status, restores missing reviewed snapshots."
|
||||
summary = "Installs, reviews and trusts plugin bundles, reports their status, and restores missing reviewed runtime snapshots."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
docs = "docs/PLUGINS.md"
|
||||
@@ -585,8 +585,8 @@ owner = "crates/net/src/import_claude.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "hooks"
|
||||
name = "Hook plugins"
|
||||
summary = "A plugin kind: runs user commands on session and tool events, with an execution receipt after shell tools."
|
||||
name = "Hooks"
|
||||
summary = "Runs user commands on session and tool events, with an execution receipt after shell tools."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
docs = "docs/HOOKS.md"
|
||||
|
||||
@@ -97,13 +97,8 @@ Stage 5 of the loop: [cw-orient](../cw-orient/SKILL.md) →
|
||||
|
||||
Co-authored-by: <maintainer or agent identity>
|
||||
```
|
||||
GitHub's contributor count takes authors and `Co-authored-by` trailers in
|
||||
the final trailer block (no blank line between trailers) whose address is
|
||||
linked to the account, and `check-contributor-credit.py` flags a harvested
|
||||
handle who is not credited, a trailer GitHub cannot parse, and a noreply id
|
||||
that belongs to someone else. It accepts credit only from a noreply address
|
||||
whose id matches `.github/AUTHOR_MAP`, or from a raw email listed there, so
|
||||
add the contributor's line to `AUTHOR_MAP` when it is missing.
|
||||
GitHub's contributor list counts authors only, and
|
||||
`check-contributor-credit.py` flags a harvested handle who is not credited.
|
||||
Resolve the address from `.github/AUTHOR_MAP`, else
|
||||
`gh api users/<handle> --jq '"\(.id)+\(.login)@users.noreply.github.com"'`.
|
||||
A clean cherry-pick keeps the author by itself. This step is the single
|
||||
|
||||
@@ -127,11 +127,6 @@ DLL 的身份一致,[该 API 的文档前提是 Vista](https://learn.microsoft
|
||||
x64 没有导出探测,因此这 12 对作为低于 14393 下限的微软文档 API 契约单独记录,与 #6953 报告者实测导出分开标注;x64 允许列表现为 468 对。
|
||||
候选仍须以最终可执行文件通过导入检查,两项新增都不是 14393 或安装器运行。
|
||||
|
||||
源 `f5a3dda975` 的发布候选运行 43186389246 随后在两个 64 位目标上又停止于三对导入:advapi32.dll 的 `GetSidIdentifierAuthority`、`GetSidSubAuthority` 和 `GetSidSubAuthorityCount`。
|
||||
探测运行 43197611105(Windows 11 ARM64 内部版本 26200.9725)解析了该候选 ARM64 程序的全部 447 个导入,新增的 3 对已加入基线。
|
||||
x64 的这 3 对作为微软文档 API 契约记录(各函数参考页的最低客户端为 Windows XP),与 #6953 报告者实测导出分开标注;x64 允许列表现为 471 对,ARM64 为 473 对。
|
||||
限制不变:候选仍须以最终可执行文件通过导入检查,两项新增都不是 14393 或安装器运行。
|
||||
|
||||
此快照中,最终发布文件在真实 1607 上启动/安装及候选程序生成的卸载器仍未验收。
|
||||
最终冻结源码、发布导入记录、各安装途径、打包/原生应用崩溃恢复和最低 OS 回执应
|
||||
进入发布验收记录,见[英文发布检查单](../RELEASE_CHECKLIST.md)。
|
||||
|
||||
@@ -249,15 +249,8 @@ DSH 的 `allow` 被视为不干预,每个所有者提示一次;不能预先
|
||||
和审批检查。理由及上下文沿用现有清理与大小限制。
|
||||
|
||||
该桥接作用于 Engine 会话的模型工具、受 gate 管理的 code-mode 调用及 `exec.core`
|
||||
调用。编辑器通过 ACP(`codewhale serve --acp`)驱动的会话获得同一个扩展宿主,并受相同的
|
||||
`[features]` 开关(`extension_host`、`mcp`)约束:已审查的插件工具(TypeScript 与 DSH)和
|
||||
Codewhale 中配置的 MCP 服务器会作为 ACP 工具调用出现,并通过会话的权限设置询问;Plan 保持只读。
|
||||
插件工具通过 `exec.core` 请求核心执行需要审批的工具时,审批请求显示在该插件
|
||||
工具的调用上,并写明嵌套工具及其输入。
|
||||
ACP 服务器的能力声明会说明这一点(`agentCapabilities._meta.codewhale`)。ACP 客户端在
|
||||
`mcpServers` 中传入的服务器和已配置的 shell hooks 不会挂载。`prepend`/`global` 顺序
|
||||
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。Native
|
||||
`tool_call_after` 仍是只观察的契约。
|
||||
调用。独立 ACP 执行路径没有 TypeScript 宿主 attachment。`prepend`/`global` 顺序
|
||||
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。
|
||||
|
||||
## 提示词片段
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
> 英文原文:[HOOKS.md](../HOOKS.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-29。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件。`config.toml` 或项目 `hooks.toml` 里的 hook 是 **Hook** 类插件;插件包也可以携带 hook。
|
||||
hook 仍在终端的 Extensions → Hooks 和桌面端的 Customize › Hooks 管理;把所有种类放在一个插件列表里是
|
||||
0.10.3 的计划,当前版本尚无。配置和 `/hooks` 命令都不变。
|
||||
|
||||
Hooks 会在 Codewhale **TUI** 到达生命周期节点时运行一条 shell 命令。它们是普通进程:通过环境变量接收上下文,其中一些会在 stdin 上收到 JSON 载荷,还有三个可以引导 Codewhale 接下来做什么。
|
||||
|
||||
本页是当前已实现内容的权威参考。与 `config.toml` 其余部分重叠的配置语法见 [CONFIGURATION.md](CONFIGURATION.md);本文件是逐事件的约定说明。
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
|
||||
在终端中,`/mcp`(也可用 `/mcps`)会打开 **Extensions → MCP**。按 Enter 会对所选服务器执行恢复操作,或打开只读详情。清单为空时会提供服务器建议;浏览这些建议不会安装任何内容。`/mcp status`、`/mcp doctor`、`/mcp login`、`/mcp add` 等显式子命令的行为保持不变。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件。你配置的 MCP 服务器是 **Connector**(连接器)类插件;插件包也可以携带 connector。
|
||||
connector 仍在终端的 Extensions → MCP 和桌面端的 Customize › Connectors 管理;把所有种类放在一个插件列表里是
|
||||
0.10.3 的计划,当前版本尚无。配置文件、登录和 `/mcp` 命令都不变。
|
||||
本页的“连接”(connection)指到服务器的实际链路;作为产品名词,Connection 专指模型提供方或另一个 agent。
|
||||
|
||||
Codewhale 可以通过 MCP(Model Context Protocol,模型上下文协议)加载额外工具。MCP 服务器可以是 Codewhale 启动的本地 stdio 进程,也可以是使用 Streamable HTTP(带传统 SSE 回退)的远程基于 URL 的服务器。
|
||||
|
||||
浏览说明:
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user