mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
41
Commits
No files matched your search
+51
-8
@@ -4,11 +4,12 @@
|
||||
# alias = Display Name <[email protected]>
|
||||
#
|
||||
# The right-hand side must use GitHub's numeric noreply address so a harvested
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
hmbown = Hmbown <[email protected]>
|
||||
[email protected] = Hmbown <[email protected]>
|
||||
@@ -50,6 +51,7 @@ 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]>
|
||||
@@ -79,19 +81,24 @@ Vince = Liu-Vince <[email protected]>
|
||||
[email protected] = Liu-Vince <[email protected]>
|
||||
LI-Jialu = LI-Jialu <[email protected]>
|
||||
JiarenWang = JiarenWang <[email protected]>
|
||||
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
|
||||
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
|
||||
mars-base = kitty <[email protected]>
|
||||
[email protected] = kitty <[email protected]>
|
||||
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+angus-[email protected]>
|
||||
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
|
||||
angus-guo = gus <217034332+im[email protected]>
|
||||
imgusguo = gus <217034332+im[email protected]>
|
||||
[email protected] = gus <[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]>
|
||||
@@ -247,6 +254,7 @@ [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]>
|
||||
@@ -278,3 +286,38 @@ 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,9 +259,16 @@ 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
|
||||
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`).
|
||||
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.
|
||||
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`.
|
||||
|
||||
+17
-1
@@ -7,7 +7,23 @@ 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).
|
||||
- **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).
|
||||
@@ -22,7 +38,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` 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -41,6 +41,34 @@ 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;
|
||||
@@ -228,15 +256,13 @@ mod platform {
|
||||
Ok(())
|
||||
}
|
||||
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
|
||||
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
|
||||
)
|
||||
});
|
||||
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
|
||||
)
|
||||
});
|
||||
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ 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 {
|
||||
@@ -98,12 +110,21 @@ 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 settings = tokio::task::spawn_blocking(McpServerSettings::load)
|
||||
let mut 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.
|
||||
@@ -906,6 +927,21 @@ 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(
|
||||
|
||||
@@ -296,6 +296,53 @@ 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;
|
||||
|
||||
+30
-2
@@ -117,7 +117,7 @@ struct Cli {
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BOOL",
|
||||
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
|
||||
help = "Control usage reporting (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 aggregate usage counting by Codewhale and PostHog (default on).
|
||||
/// Review usage reporting by Codewhale and PostHog (default on).
|
||||
Telemetry {
|
||||
/// Optional compatibility form: enable future sessions under this policy version.
|
||||
#[arg(long, value_name = "VERSION")]
|
||||
@@ -6792,6 +6792,10 @@ 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);
|
||||
}
|
||||
@@ -7226,6 +7230,30 @@ 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,48 +140,17 @@ impl EnvRuntimeOverrides {
|
||||
Self {
|
||||
provider,
|
||||
provider_source,
|
||||
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()),
|
||||
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"]),
|
||||
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"]),
|
||||
@@ -216,9 +185,7 @@ impl EnvRuntimeOverrides {
|
||||
None
|
||||
}
|
||||
}),
|
||||
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
|
||||
.ok()
|
||||
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
|
||||
.and_then(|value| match parse_http_headers(&value) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => {
|
||||
@@ -227,290 +194,85 @@ impl EnvRuntimeOverrides {
|
||||
}
|
||||
})
|
||||
.filter(|headers| !headers.is_empty()),
|
||||
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()),
|
||||
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"]),
|
||||
// `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: 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()),
|
||||
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"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"show_thinking",
|
||||
SettingKind::Bool(SHOW_THINKING),
|
||||
"false",
|
||||
"true",
|
||||
ui(
|
||||
TAB_APPEARANCE,
|
||||
"display",
|
||||
@@ -1546,6 +1546,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"false",
|
||||
None,
|
||||
),
|
||||
def(
|
||||
"account_preferences_notice_shown",
|
||||
SettingKind::String,
|
||||
"",
|
||||
None,
|
||||
),
|
||||
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
|
||||
def(
|
||||
"work_surface_bottom_migrated",
|
||||
|
||||
@@ -27,10 +27,11 @@ 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(
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
crate::env_overrides::env_non_blank(&[
|
||||
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
|
||||
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
|
||||
])
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -50,9 +51,7 @@ 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 {
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
@@ -33,6 +33,8 @@ 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
|
||||
@@ -92,6 +94,32 @@ 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,6 +8763,7 @@ 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, ()>,
|
||||
@@ -8775,6 +8776,7 @@ 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
|
||||
@@ -8788,6 +8790,7 @@ 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");
|
||||
}
|
||||
@@ -8808,6 +8811,13 @@ 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 {
|
||||
@@ -8821,6 +8831,7 @@ impl TelemetryEnvGuard {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
env::remove_var(TELEMETRY_SOURCE_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8841,6 +8852,10 @@ 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"),
|
||||
@@ -8995,6 +9010,57 @@ 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());
|
||||
@@ -10701,6 +10767,60 @@ 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 = 2;
|
||||
let mut count = 3;
|
||||
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 and replace objects disabled,
|
||||
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting 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", "2")
|
||||
.env("GIT_CONFIG_COUNT", "3")
|
||||
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
|
||||
.env("GIT_CONFIG_VALUE_0", "false")
|
||||
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
|
||||
@@ -1265,6 +1265,8 @@ 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)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
{
|
||||
"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."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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,8 +44,9 @@ use super::CodewhaleClient;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
extract_sse_data_value, from_api_tool_name, next_sse_line, push_sse_event_data,
|
||||
to_api_tool_name,
|
||||
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,
|
||||
};
|
||||
|
||||
/// Maximum `cache_control` breakpoints Anthropic accepts per request.
|
||||
@@ -103,6 +104,11 @@ 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()
|
||||
@@ -113,6 +119,11 @@ 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,
|
||||
@@ -155,6 +166,9 @@ 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"
|
||||
);
|
||||
@@ -1305,6 +1319,75 @@ 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,8 +41,10 @@ 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, acquire_stream_buffer, bounded_error_text, from_api_tool_name,
|
||||
parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name,
|
||||
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,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, flush_sse_line, push_sse_event_data, take_sse_line};
|
||||
use codewhale_config::route::RouteLimits;
|
||||
@@ -1208,11 +1210,20 @@ 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,
|
||||
@@ -1247,6 +1258,9 @@ 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"
|
||||
);
|
||||
@@ -3255,11 +3269,25 @@ 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(json!(choice_str));
|
||||
return Some(match forced_tool_name(choice) {
|
||||
Some(name) => json!({
|
||||
"type": "function",
|
||||
"function": { "name": to_api_tool_name(name) }
|
||||
}),
|
||||
None => 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)),
|
||||
@@ -6664,9 +6692,13 @@ 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::{ProviderKind, build_chat_messages, build_chat_wire_body};
|
||||
use super::{
|
||||
ChatWireBody, ProviderKind, build_chat_messages, build_chat_wire_body,
|
||||
map_tool_choice_for_chat, to_api_tool_name,
|
||||
};
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest};
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent, Message, MessageRequest, Tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DATA_URL: &str = "data:image/png;base64,QUJD";
|
||||
|
||||
@@ -6973,6 +7005,220 @@ 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,8 +35,10 @@ use super::chat::REASONING_REPLAY_PLACEHOLDER;
|
||||
use super::prepared::WireDialect;
|
||||
use super::role_placement::{RolePlacement, role_placement};
|
||||
use super::wire::{
|
||||
ERROR_BODY_MAX_BYTES, bounded_error_text, from_api_tool_name, system_to_instructions,
|
||||
to_api_tool_name,
|
||||
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,
|
||||
};
|
||||
use super::wire::{extract_sse_data_value, next_sse_line, push_sse_event_data};
|
||||
use crate::llm_client::LlmError;
|
||||
@@ -135,6 +137,7 @@ 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(
|
||||
@@ -150,12 +153,26 @@ 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,
|
||||
@@ -185,6 +202,9 @@ 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,6 +755,43 @@ 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"));
|
||||
|
||||
@@ -1449,9 +1449,10 @@ fn tool_name_scenario() {
|
||||
}
|
||||
// from tool_name_decode_bare_hex_no_trailing_dash
|
||||
{
|
||||
let mangled = "foo_x00002Ebar";
|
||||
let decoded = from_api_tool_name(mangled);
|
||||
assert_eq!(decoded, "foo_.bar");
|
||||
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");
|
||||
}
|
||||
// from tool_name_bare_hex_preserves_alnum
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
use codewhale_models::{ServerToolUsage, SystemPrompt, Usage};
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::{ContentBlock, MessageRequest, ServerToolUsage, SystemPrompt, Usage};
|
||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||
|
||||
pub fn saturating_u32(value: u64) -> u32 {
|
||||
@@ -315,7 +316,7 @@ pub fn to_api_tool_name(name: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_api_tool_name(name: &str) -> String {
|
||||
fn decode_delimited_tool_name(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut iter = name.chars().peekable();
|
||||
while let Some(ch) = iter.next() {
|
||||
@@ -357,12 +358,87 @@ pub fn from_api_tool_name(name: &str) -> String {
|
||||
}
|
||||
out.push('-');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// 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 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()
|
||||
)
|
||||
}
|
||||
|
||||
pub const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
|
||||
@@ -465,6 +541,98 @@ 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();
|
||||
|
||||
@@ -2152,10 +2152,7 @@ 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();
|
||||
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub fn tool_projection_warning_message(
|
||||
omitted_tool_count: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"Warning: {provider} omitted incompatible tools for this request: {}",
|
||||
"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.",
|
||||
tool_projection_warning_tool_list(omitted_tool_names, omitted_tool_count)
|
||||
)
|
||||
}
|
||||
@@ -1251,6 +1251,15 @@ 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)
|
||||
|
||||
@@ -203,10 +203,13 @@ impl Drop for TestOverlay {
|
||||
|
||||
fn overlay_granted() -> bool {
|
||||
#[cfg(test)]
|
||||
if TEST_OVERLAY.with(std::cell::Cell::get) {
|
||||
return true;
|
||||
{
|
||||
TEST_OVERLAY.with(std::cell::Cell::get)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
|
||||
@@ -187,6 +187,19 @@ 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,9 +1727,10 @@ fn ssh_client_env() -> BTreeMap<String, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn process_base_env() -> BTreeMap<String, String> {
|
||||
base_env_from(std::env::vars_os())
|
||||
}
|
||||
const MANAGED_CONFIG_ENV: [&str; 2] = [
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
];
|
||||
|
||||
/// The worker's inherited environment: the repository's scrubbed child-env
|
||||
/// allowlist (`child_env`), so proxy routing, CA bundles, temp directories,
|
||||
@@ -1742,11 +1743,22 @@ 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)
|
||||
codewhale_runtime::child_env::sanitized_runtime_env_from(parent.iter().cloned())
|
||||
.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
|
||||
}
|
||||
@@ -1754,7 +1766,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 [`process_base_env`], so an
|
||||
/// The spawn path `env_clear()`s and rebuilds from [`base_env_from`], 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.
|
||||
@@ -1772,8 +1784,24 @@ fn worker_env(
|
||||
request_env: &BTreeMap<String, String>,
|
||||
allowlist: &BTreeSet<String>,
|
||||
) -> FleetHostResult<BTreeMap<String, String>> {
|
||||
let mut env = process_base_env();
|
||||
env.extend(filtered_env(request_env, allowlist)?);
|
||||
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);
|
||||
force_worker_telemetry_off(&mut env);
|
||||
Ok(env)
|
||||
}
|
||||
@@ -2770,11 +2798,47 @@ 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 = process_base_env();
|
||||
let base = base_env_from(std::env::vars_os());
|
||||
assert_eq!(
|
||||
base.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false")
|
||||
|
||||
@@ -1748,11 +1748,7 @@ impl FleetManager {
|
||||
return Err(start_error);
|
||||
}
|
||||
};
|
||||
if !started {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
fn start_leased_workers(
|
||||
|
||||
@@ -1669,6 +1669,10 @@ 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
|
||||
@@ -3125,6 +3129,30 @@ 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> {
|
||||
@@ -3202,6 +3230,22 @@ 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,6 +177,21 @@ 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,6 +3314,80 @@ 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()));
|
||||
@@ -6770,6 +6844,60 @@ 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]
|
||||
|
||||
@@ -244,22 +244,38 @@ pub async fn capture(
|
||||
if let Some(snapshot) = supplied {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
// Unit tests never read an operator's account credentials. Transport tests
|
||||
// exercise load_from with an explicitly isolated store instead.
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
let _ = profile;
|
||||
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
if !account_api_base_is_loopback() {
|
||||
return Ok(crate::prompts::load_user_constitution_block());
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
Ok(crate::prompts::load_user_constitution_block())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn account_api_base_is_loopback() -> bool {
|
||||
std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.and_then(|value| normalize_runtime_account_api_base(&value))
|
||||
.and_then(|base| reqwest::Url::parse(&base).ok())
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_ACCOUNT_LOAD_FAILURE: std::cell::Cell<Option<&'static str>> =
|
||||
|
||||
@@ -4988,14 +4988,16 @@ mod tests {
|
||||
Arc::new(MockExecutor),
|
||||
)
|
||||
.await?;
|
||||
sleep(STORE_IDLE_POLL_INTERVAL + Duration::from_millis(300)).await;
|
||||
settle_queue_fixture(&manager).await?;
|
||||
inspect_settled_workers(&manager).await?;
|
||||
manager.fingerprint_reads.store(0, Ordering::Relaxed);
|
||||
manager.store_loads.store(0, Ordering::Relaxed);
|
||||
|
||||
let window = Duration::from_secs(3);
|
||||
sleep(window).await;
|
||||
let started = Instant::now();
|
||||
sleep(Duration::from_secs(3)).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,14 +133,9 @@ impl WriteScopeClaim {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.contracts
|
||||
self.contracts
|
||||
.iter()
|
||||
.any(|contract| other.contracts.iter().any(|other| other == contract))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "actiu",
|
||||
"ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte no carregat. Executa {command}. La clau del proveïdor continua funcionant.",
|
||||
"ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No s’ha enviat: supera els {limit} caràcters; no s’ha desat el fitxer d’enganxat.",
|
||||
"CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}",
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"PhaseIdle": "inactiu",
|
||||
"PhaseDraft": "esborrany",
|
||||
"PhaseWorking": "treballant",
|
||||
"PhaseReasoning": "raonant",
|
||||
"PhaseReasoning": "pensant",
|
||||
"PhaseReading": "llegint",
|
||||
"PhaseUsingTool": "usant una eina",
|
||||
"PhaseSubagents": "subagents en execució",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "aktiv",
|
||||
"ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.",
|
||||
"AccountPreferencesNotLoaded": "Konto nicht geladen. Führe {command} aus. Dein Anbieter-Schlüssel funktioniert weiter.",
|
||||
"ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Nicht gesendet: über {limit} Zeichen; Einfügedatei nicht gespeichert.",
|
||||
"CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}",
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"PhaseIdle": "ruhend",
|
||||
"PhaseDraft": "entwurf",
|
||||
"PhaseWorking": "arbeitet",
|
||||
"PhaseReasoning": "überlegt",
|
||||
"PhaseReasoning": "denkt nach",
|
||||
"PhaseReading": "liest",
|
||||
"PhaseUsingTool": "nutzt Tool",
|
||||
"PhaseSubagents": "Subagenten laufen",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "live",
|
||||
"ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.",
|
||||
"AccountPreferencesNotLoaded": "Account not loaded. Run {command}. Your provider key still works.",
|
||||
"ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.",
|
||||
"ComposerOversizedSubmitHeldShort": "Not sent: over {limit} characters; paste file not saved.",
|
||||
"CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}",
|
||||
@@ -235,14 +236,14 @@
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
"TelemetryNoticeActionConfirm": "confirm",
|
||||
"TelemetryNoticeActionExit": "exit",
|
||||
"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}",
|
||||
"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}",
|
||||
"TelemetryPreferenceSaveFailed": "The telemetry preference could not be changed safely: {detail}",
|
||||
"StatusPickerTitle": " Status line ",
|
||||
"StatusPickerInstruction": "Choose what the footer shows:",
|
||||
@@ -373,7 +374,7 @@
|
||||
"ConfigLabelDefaultMode": "Startup mode",
|
||||
"ConfigLabelAllowShell": "Shell access",
|
||||
"ConfigLabelManagedAllowShell": "Shell access (managed)",
|
||||
"ConfigLabelTelemetry": "Anonymous usage counts",
|
||||
"ConfigLabelTelemetry": "Usage reporting",
|
||||
"ConfigHintTelemetry": "Aggregate version, session, feature and error counts to Codewhale and PostHog; never content or IP. docs/TELEMETRY.md",
|
||||
"ConfigValueTelemetryOn": "On",
|
||||
"ConfigValueTelemetryOff": "Off",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "idle",
|
||||
"PhaseDraft": "draft",
|
||||
"PhaseWorking": "working",
|
||||
"PhaseReasoning": "reasoning",
|
||||
"PhaseReasoning": "thinking",
|
||||
"PhaseReading": "reading",
|
||||
"PhaseUsingTool": "using tool",
|
||||
"PhaseSubagents": "agents underway",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "activo",
|
||||
"ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.",
|
||||
"AccountPreferencesNotLoaded": "Cuenta no cargada. Ejecuta {command}. Tu clave del proveedor sigue funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "No se envió: supera los {limit} caracteres; no se guardó el archivo pegado.",
|
||||
"CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "inactivo",
|
||||
"PhaseDraft": "borrador",
|
||||
"PhaseWorking": "trabajando",
|
||||
"PhaseReasoning": "razonando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReading": "leyendo",
|
||||
"PhaseUsingTool": "usando herramienta",
|
||||
"PhaseSubagents": "subagentes en curso",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "actif",
|
||||
"ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.",
|
||||
"AccountPreferencesNotLoaded": "Compte non chargé. Lancez {command}. Votre clé de fournisseur fonctionne toujours.",
|
||||
"ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Non envoyé : plus de {limit} caractères ; fichier collé non enregistré.",
|
||||
"CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}",
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"PhaseIdle": "inactif",
|
||||
"PhaseDraft": "brouillon",
|
||||
"PhaseWorking": "travail en cours",
|
||||
"PhaseReasoning": "raisonnement",
|
||||
"PhaseReasoning": "réflexion",
|
||||
"PhaseReading": "lecture",
|
||||
"PhaseUsingTool": "utilisation d'outil",
|
||||
"PhaseSubagents": "sous-agents en cours",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "सक्रिय",
|
||||
"ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।",
|
||||
"AccountPreferencesNotLoaded": "खाता लोड नहीं हुआ। {command} चलाएँ। आपकी प्रदाता कुंजी काम करती रहेगी।",
|
||||
"ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।",
|
||||
"ComposerOversizedSubmitHeldShort": "नहीं भेजा गया: {limit} से अधिक वर्ण; पेस्ट फ़ाइल सहेजी नहीं गई।",
|
||||
"CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}",
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"PhaseIdle": "निष्क्रिय",
|
||||
"PhaseDraft": "मसौदा",
|
||||
"PhaseWorking": "काम चल रहा",
|
||||
"PhaseReasoning": "तर्क चल रहा",
|
||||
"PhaseReasoning": "सोच रहा",
|
||||
"PhaseReading": "पढ़ रहा",
|
||||
"PhaseUsingTool": "टूल इस्तेमाल हो रहा",
|
||||
"PhaseSubagents": "सबएजेंट चल रहे हैं",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "aktif",
|
||||
"ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.",
|
||||
"AccountPreferencesNotLoaded": "Akun tidak dimuat. Jalankan {command}. Kunci penyedia tetap berfungsi.",
|
||||
"ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.",
|
||||
"ComposerOversizedSubmitHeldShort": "Belum terkirim: melebihi {limit} karakter; berkas tempelan tidak tersimpan.",
|
||||
"CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}",
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"PhaseIdle": "diam",
|
||||
"PhaseDraft": "draf",
|
||||
"PhaseWorking": "bekerja",
|
||||
"PhaseReasoning": "menalar",
|
||||
"PhaseReasoning": "berpikir",
|
||||
"PhaseReading": "membaca",
|
||||
"PhaseUsingTool": "memakai tool",
|
||||
"PhaseSubagents": "subagen berjalan",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "稼働中",
|
||||
"ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。",
|
||||
"AccountPreferencesNotLoaded": "アカウントを読み込めませんでした。{command} を実行してください。プロバイダーキーはそのまま使えます。",
|
||||
"ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。",
|
||||
"ComposerOversizedSubmitHeldShort": "未送信:{limit} 文字超。貼り付けファイルは未保存です。",
|
||||
"CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "실행 중",
|
||||
"ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.",
|
||||
"ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.",
|
||||
"AccountPreferencesNotLoaded": "계정을 불러오지 못했습니다. {command}을 실행하세요. 공급자 키는 계속 사용할 수 있습니다.",
|
||||
"ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.",
|
||||
"ComposerOversizedSubmitHeldShort": "전송 안 됨: {limit}자 초과, 붙여넣기 파일 저장 실패.",
|
||||
"CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "유휴",
|
||||
"PhaseDraft": "초안",
|
||||
"PhaseWorking": "작업 중",
|
||||
"PhaseReasoning": "추론 중",
|
||||
"PhaseReasoning": "생각 중",
|
||||
"PhaseReading": "읽는 중",
|
||||
"PhaseUsingTool": "도구 사용 중",
|
||||
"PhaseSubagents": "서브에이전트 실행 중",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "ativo",
|
||||
"ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.",
|
||||
"AccountPreferencesNotLoaded": "Conta não carregada. Execute {command}. Sua chave do provedor continua funcionando.",
|
||||
"ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.",
|
||||
"ComposerOversizedSubmitHeldShort": "Não enviado: acima de {limit} caracteres; arquivo colado não salvo.",
|
||||
"CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "ocioso",
|
||||
"PhaseDraft": "rascunho",
|
||||
"PhaseWorking": "trabalhando",
|
||||
"PhaseReasoning": "raciocinando",
|
||||
"PhaseReasoning": "pensando",
|
||||
"PhaseReading": "lendo",
|
||||
"PhaseUsingTool": "usando ferramenta",
|
||||
"PhaseSubagents": "subagentes em andamento",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "активен",
|
||||
"ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.",
|
||||
"AccountPreferencesNotLoaded": "Аккаунт не загружен. Выполните {command}. Ключ провайдера продолжает работать.",
|
||||
"ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не отправлено: больше {limit} символов; файл вставки не сохранён.",
|
||||
"CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "активний",
|
||||
"ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.",
|
||||
"AccountPreferencesNotLoaded": "Обліковий запис не завантажено. Виконайте {command}. Ключ провайдера й далі працює.",
|
||||
"ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.",
|
||||
"ComposerOversizedSubmitHeldShort": "Не надіслано: понад {limit} символів; файл вставки не збережено.",
|
||||
"CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "đang chạy",
|
||||
"ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.",
|
||||
"ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.",
|
||||
"AccountPreferencesNotLoaded": "Chưa tải được tài khoản. Chạy {command}. Khóa nhà cung cấp của bạn vẫn hoạt động.",
|
||||
"ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.",
|
||||
"ComposerOversizedSubmitHeldShort": "Chưa gửi: vượt quá {limit} ký tự; chưa lưu tệp dán.",
|
||||
"CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "rảnh",
|
||||
"PhaseDraft": "nháp",
|
||||
"PhaseWorking": "đang xử lý",
|
||||
"PhaseReasoning": "đang suy luận",
|
||||
"PhaseReasoning": "đang suy nghĩ",
|
||||
"PhaseReading": "đang đọc",
|
||||
"PhaseUsingTool": "đang dùng công cụ",
|
||||
"PhaseSubagents": "đang chạy tác tử con",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "运行中",
|
||||
"ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的提供商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。",
|
||||
"AccountPreferencesNotLoaded": "账号未加载。请运行 {command}。你的提供商密钥仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未发送:超过 {limit} 个字符;粘贴文件未保存。",
|
||||
"CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}",
|
||||
@@ -1690,7 +1691,7 @@
|
||||
"PhaseIdle": "空闲",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReading": "读取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "智能体工作中",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"TerminalDockLive": "執行中",
|
||||
"ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。",
|
||||
"ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。",
|
||||
"AccountPreferencesNotLoaded": "帳號未載入。請執行 {command}。你的服務商金鑰仍可正常使用。",
|
||||
"ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。",
|
||||
"ComposerOversizedSubmitHeldShort": "未傳送:超過 {limit} 個字元;貼上檔案未儲存。",
|
||||
"CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}",
|
||||
@@ -1688,7 +1689,7 @@
|
||||
"PhaseIdle": "閒置",
|
||||
"PhaseDraft": "草稿",
|
||||
"PhaseWorking": "工作中",
|
||||
"PhaseReasoning": "推理中",
|
||||
"PhaseReasoning": "思考中",
|
||||
"PhaseReading": "讀取中",
|
||||
"PhaseUsingTool": "使用工具中",
|
||||
"PhaseSubagents": "代理程式工作中",
|
||||
|
||||
@@ -714,6 +714,7 @@ pub enum MessageId {
|
||||
ExtensionsInventorySkills,
|
||||
ExtensionPromptUnavailable,
|
||||
ProfileConstitutionUnavailableLocal,
|
||||
AccountPreferencesNotLoaded,
|
||||
ExtensionsMarketplaceDetail,
|
||||
ExtensionsMarketplaceUnavailable,
|
||||
ExtensionsMcpEmpty,
|
||||
@@ -3651,6 +3652,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsInventorySkills,
|
||||
MessageId::ExtensionPromptUnavailable,
|
||||
MessageId::ProfileConstitutionUnavailableLocal,
|
||||
MessageId::AccountPreferencesNotLoaded,
|
||||
MessageId::ExtensionsMarketplaceDetail,
|
||||
MessageId::ExtensionsMarketplaceUnavailable,
|
||||
MessageId::ExtensionsMcpEmpty,
|
||||
|
||||
@@ -255,11 +255,12 @@ 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_pending).await;
|
||||
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, 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() {
|
||||
@@ -536,15 +537,19 @@ 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);
|
||||
tx.send(frame)
|
||||
.await
|
||||
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
|
||||
Ok(())
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Background task that drains the outbound queue and writes each frame to
|
||||
@@ -638,6 +643,7 @@ 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 {
|
||||
@@ -654,6 +660,19 @@ 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;
|
||||
@@ -780,6 +799,31 @@ 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");
|
||||
@@ -1309,11 +1353,12 @@ 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, pending.clone()));
|
||||
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
|
||||
producer.write_all(&frame).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), receiver)
|
||||
|
||||
@@ -3446,15 +3446,10 @@ impl Config {
|
||||
return Some("project runtime configuration");
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
Ok(managed)
|
||||
if managed.approval_policy.is_some()
|
||||
|| managed.sandbox_mode.is_some()
|
||||
@@ -3464,7 +3459,7 @@ impl Config {
|
||||
}
|
||||
Err(_) => return Some("an unreadable managed runtime configuration"),
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let env_controls_runtime = || {
|
||||
@@ -3550,21 +3545,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
Ok(managed) if managed.approval_policy.is_some() => {
|
||||
return ApprovalPolicyControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ApprovalPolicyControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if approval_policy_env_is_set() {
|
||||
@@ -3630,21 +3620,16 @@ impl Config {
|
||||
return ShellAccessControl::ProjectConfig;
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
Ok(managed) if managed.allow_shell.is_some() => {
|
||||
return ShellAccessControl::ManagedConfig;
|
||||
}
|
||||
Err(_) => return ShellAccessControl::Ambiguous,
|
||||
Ok(_) => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if allow_shell_env_is_set() {
|
||||
@@ -8932,9 +8917,10 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
|
||||
{
|
||||
config.sandbox_api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
{
|
||||
if let Ok(value) = codewhale_env_var(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
) {
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
if policy.permits_secret_bearing_values()
|
||||
@@ -10718,26 +10704,25 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
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 {
|
||||
let Some(path) = managed_config_file(config.managed_config_path.as_deref())? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut managed = load_single_config_file(&path)?;
|
||||
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)
|
||||
)
|
||||
})?;
|
||||
strip_external_credential_consent(&mut managed);
|
||||
managed.tool_launcher = managed_tool_launcher(&path)?;
|
||||
let prior_route = config
|
||||
.active_provider_identity()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
.map_err(|error| managed_route_error(config, &path, error))?;
|
||||
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(anyhow::Error::msg)?;
|
||||
.map_err(|error| managed_route_error(&merged, &path, error))?;
|
||||
if managed.provider.is_some()
|
||||
|| managed.default_text_model.is_some()
|
||||
|| managed.legacy_model.is_some()
|
||||
@@ -10888,10 +10873,62 @@ fn managed_tool_launcher(path: &Path) -> Result<Option<Vec<String>>> {
|
||||
}
|
||||
|
||||
fn managed_path_from_env() -> Option<PathBuf> {
|
||||
std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
|
||||
.ok()
|
||||
.map(|value| expand_path(&value))
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_path_is_named_by_host(path: &Path) -> bool {
|
||||
|
||||
@@ -588,12 +588,11 @@ 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 a path that does not exist, so an
|
||||
/// A managed-config guard pointing at an empty managed file, 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 {
|
||||
EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
root.join("absent-managed.toml"),
|
||||
)
|
||||
let path = root.join("empty-managed.toml");
|
||||
std::fs::write(&path, "").expect("write empty managed config");
|
||||
EnvVarGuard::set("CODEWHALE_MANAGED_CONFIG_PATH", path)
|
||||
}
|
||||
@@ -3108,7 +3108,8 @@ 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("missing-managed.toml");
|
||||
let managed_config_path = temp.path().join("empty-managed.toml");
|
||||
fs::write(&managed_config_path, "").expect("empty managed config");
|
||||
let requirements_path = temp.path().join("missing-requirements.toml");
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -6476,10 +6477,7 @@ 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 = crate::test_support::EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("missing-managed.toml"),
|
||||
);
|
||||
let _managed_config = no_managed_config(temp_root.path());
|
||||
|
||||
for (provider, alias, expected_effort) in [
|
||||
("deepseek", "deepseek-chat", "off"),
|
||||
@@ -12198,10 +12196,7 @@ 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 = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
temp_root.path().join("absent-managed.toml"),
|
||||
);
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
for document in [
|
||||
"tool_launcher = [\"/usr/bin/sudo\", \"-n\", \"-u\", \"agent\", \"--\"]\n",
|
||||
@@ -12372,17 +12367,62 @@ fn the_managed_file_named_by_the_host_survives_a_provider_that_resolves_late() -
|
||||
ConfigEnvironmentPolicy::Runtime,
|
||||
&mut late_provider,
|
||||
);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
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:#}"
|
||||
);
|
||||
}
|
||||
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<()> {
|
||||
|
||||
@@ -492,6 +492,8 @@ pub struct Settings {
|
||||
/// One-time Operate-retirement toast has been shown. Same suppression
|
||||
/// contract as `yolo_deprecation_shown`.
|
||||
pub operate_deprecation_shown: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_preferences_notice_shown: Option<String>,
|
||||
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
|
||||
/// settings.toml saved before that carries `work_surface_placement =
|
||||
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
|
||||
@@ -650,9 +652,7 @@ impl Default for Settings {
|
||||
mention_menu_limit: 128,
|
||||
mention_walk_depth: 10,
|
||||
mention_menu_behavior: "fuzzy".to_string(),
|
||||
// Reasoning is useful when explicitly requested, but it should
|
||||
// never displace the actual conversation in the default TUI.
|
||||
show_thinking: false,
|
||||
show_thinking: true,
|
||||
thinking_default_expanded: false,
|
||||
thinking_preview_lines: default_thinking_preview_lines(),
|
||||
thinking_highlight: true,
|
||||
@@ -694,6 +694,7 @@ impl Default for Settings {
|
||||
feature_intro_shown: false,
|
||||
yolo_deprecation_shown: false,
|
||||
operate_deprecation_shown: false,
|
||||
account_preferences_notice_shown: None,
|
||||
legacy_operate_default: false,
|
||||
work_surface_bottom_migrated: false,
|
||||
behavioral_tip_impressions: std::collections::BTreeMap::new(),
|
||||
@@ -3747,6 +3748,36 @@ mod tests {
|
||||
assert_eq!(reloaded.permission_posture.as_deref(), Some("ask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_shows_by_default_and_a_saved_choice_survives_reload_and_other_saves() {
|
||||
let _g = config_path_test_guard();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let settings_path = tmp.path().join("settings.toml");
|
||||
let _config_override =
|
||||
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
|
||||
|
||||
let absent = Settings::load().expect("load without a settings file");
|
||||
assert!(absent.show_thinking);
|
||||
assert!(!absent.thinking_default_expanded);
|
||||
assert!(!absent.is_set("show_thinking"));
|
||||
|
||||
std::fs::write(&settings_path, "theme = \"light\"\n").expect("settings without the key");
|
||||
let unnamed = Settings::load().expect("load settings without the key");
|
||||
assert!(unnamed.show_thinking);
|
||||
assert!(!unnamed.is_set("show_thinking"));
|
||||
|
||||
for saved in [false, true] {
|
||||
std::fs::write(&settings_path, format!("show_thinking = {saved}\n"))
|
||||
.expect("saved choice");
|
||||
let loaded = Settings::load().expect("load the saved choice");
|
||||
assert_eq!(loaded.show_thinking, saved);
|
||||
assert!(loaded.is_set("show_thinking"));
|
||||
Settings::transact(|settings| settings.set("locale", "en")).expect("another save");
|
||||
let reloaded = Settings::load().expect("reload after another save");
|
||||
assert_eq!(reloaded.show_thinking, saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
|
||||
let _g = config_path_test_guard();
|
||||
@@ -3886,7 +3917,7 @@ mod tests {
|
||||
let mut settings = Settings::default();
|
||||
// Density is calm by default; motion is an independent axis.
|
||||
assert!(settings.calm_mode);
|
||||
assert!(!settings.show_thinking);
|
||||
settings.show_thinking = false;
|
||||
|
||||
let changed = settings.apply_preset("CALM").expect("calm preset applies");
|
||||
assert_eq!(
|
||||
@@ -3916,8 +3947,8 @@ mod tests {
|
||||
assert!(settings.fancy_animations);
|
||||
assert_eq!(settings.transcript_spacing, "spacious");
|
||||
assert_eq!(settings.tool_collapse_mode, "compact");
|
||||
// Thinking is opt-in so the transcript stays focused on the chat.
|
||||
assert!(!settings.show_thinking);
|
||||
assert!(settings.show_thinking);
|
||||
assert!(!settings.thinking_default_expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1371,6 +1371,9 @@ 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))
|
||||
}
|
||||
@@ -1655,6 +1658,19 @@ 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": "21",
|
||||
"generation": "22",
|
||||
"skills": [
|
||||
{
|
||||
"name": "skill-creator",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
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 an explicit trust review.
|
||||
description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and a trust review that goes through the session's approval.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
@@ -54,9 +54,24 @@ 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>`
|
||||
- stop and present these results; the person runs `/plugin enable <plugin-name>`
|
||||
- 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
|
||||
@@ -65,22 +80,18 @@ 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, registry or automatic trust flow. Catalog
|
||||
membership alone never installs, trusts or enables a plugin.
|
||||
do not add a parallel installer or registry. Catalog membership alone never
|
||||
installs, trusts or enables a plugin.
|
||||
|
||||
## Experimental host code
|
||||
## Plugins with code or a screen
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -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 = "21";
|
||||
const BUNDLED_SKILL_VERSION: &str = "22";
|
||||
|
||||
mod dsh_creator;
|
||||
use dsh_creator::{BundledFile, CREATOR_SKILLS};
|
||||
@@ -176,6 +176,10 @@ 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,6 +70,7 @@ plugin-creator 935a64e72137a2420910a3365f8d4000038987fa0753648e9751dd79eae5cc30
|
||||
plugin-creator a22e9b25e4ba8eac43007899547bf4471e79a18d3b55f60df4cf073271529104
|
||||
plugin-creator b150b79446d876a2cd572aacd7be7712fd9914c340b51969a29186f85db4e18a
|
||||
plugin-creator c20a792055a4b5601e58b2fb721cd896a34f572a4acdabd1a706d46ce77eb639
|
||||
plugin-creator c761d6774b751a985ffed2894f595d8f871430b0a05a8e94bb7245399d811de7
|
||||
plugin-creator ce156ea9c6e2896b9c6c0da08f3d78c27bb793f685bcd2afbbffd712a5f64387
|
||||
plugin-creator e91aa6dfde62abd9548dda7a43bc097a82aa19385335117e86cb204edc9e294d
|
||||
presentations 2b6d20a00280f3de7814ebd8870e6e1af08c11bd115603b265fc31b9004f9d35
|
||||
|
||||
@@ -874,7 +874,8 @@ 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("## Experimental host code"));
|
||||
assert!(skill.body.contains("cordis-plugin-development"));
|
||||
assert!(!skill.body.contains("never automate the trust token"));
|
||||
for (generation, old) in [
|
||||
(
|
||||
"15",
|
||||
@@ -884,9 +885,16 @@ 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!(!old.contains("## Experimental host code"));
|
||||
assert_eq!(
|
||||
old.contains("## Experimental host code"),
|
||||
generation == "19"
|
||||
);
|
||||
for customized in [false, true] {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(skill_dir(&tmp, "plugin-creator")).unwrap();
|
||||
|
||||
@@ -1457,6 +1457,7 @@ 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() {
|
||||
@@ -1850,7 +1851,7 @@ impl SnapshotRepo {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| io_other(format!("clock error: {e}")))?
|
||||
.as_secs() as i64;
|
||||
let cutoff = now - max_age.as_secs() as i64;
|
||||
let cutoff = now.saturating_sub(i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX));
|
||||
|
||||
let snapshots = self.list(usize::MAX)?;
|
||||
if snapshots.is_empty() {
|
||||
@@ -3948,6 +3949,22 @@ 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
|
||||
@@ -4892,6 +4909,24 @@ 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.
|
||||
|
||||
+37
-2
@@ -11,7 +11,7 @@ mod runtime_aliases;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
|
||||
|
||||
@@ -1453,6 +1453,7 @@ 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(|| {
|
||||
@@ -1461,7 +1462,14 @@ impl StateStore {
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
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")?;
|
||||
// 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.
|
||||
@@ -2922,6 +2930,33 @@ 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
|
||||
|
||||
+17
-1
@@ -7,7 +7,23 @@ 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).
|
||||
- **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).
|
||||
@@ -22,7 +38,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` 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.
|
||||
- **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.
|
||||
- **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,7 +157,8 @@ 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(args, workspace);
|
||||
let git =
|
||||
|args: &[&str]| Git::output(&[&["-c", "core.quotePath=false"], args].concat(), workspace);
|
||||
// Outside a repository `git diff` prints nothing on stdout, which would
|
||||
// read as "no changes".
|
||||
match git(&["rev-parse", "--is-inside-work-tree"]) {
|
||||
|
||||
+30
-1
@@ -3216,6 +3216,7 @@ async fn run_async_main_dispatch(
|
||||
workspace,
|
||||
skills_dir,
|
||||
skills_config,
|
||||
config.tool_launcher.is_some(),
|
||||
)
|
||||
.await
|
||||
} else if http_selected {
|
||||
@@ -7867,7 +7868,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::resolved_telemetry_consent(config.telemetry);
|
||||
let (on, source) = codewhale_config::dispatched_telemetry_consent(config.telemetry);
|
||||
if !on {
|
||||
return (false, source.as_str());
|
||||
}
|
||||
@@ -16558,6 +16559,8 @@ 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");
|
||||
|
||||
@@ -16608,6 +16611,32 @@ 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]
|
||||
|
||||
@@ -87,6 +87,27 @@ pub(crate) use types::{
|
||||
WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
|
||||
};
|
||||
|
||||
const ACCOUNT_PREFERENCES_NOTICE: &str = "account-preferences-notice";
|
||||
|
||||
fn account_sign_in_marker(profile: Option<&str>) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let api_base = codewhale_engine::profile_constitution::runtime_account_api_base();
|
||||
let session = codewhale_secrets::account::AccountSessionStore::new(
|
||||
codewhale_secrets::Secrets::file_backed_read_only(),
|
||||
profile,
|
||||
&api_base,
|
||||
)
|
||||
.runtime_info_at(Utc::now())
|
||||
.ok()
|
||||
.and_then(|info| info.session_id)
|
||||
.unwrap_or_default();
|
||||
Sha256::digest(session.as_bytes())
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// One login owns one mailbox. A cancelled task can only write its abandoned
|
||||
@@ -2448,6 +2469,7 @@ pub struct App {
|
||||
/// re-render per character. `None` means "no bump since the last
|
||||
/// finalize" so the first chunk of a block always renders immediately.
|
||||
pub thinking_revision_last_bump_at: Option<Instant>,
|
||||
pub(crate) thinking_revision_owed: bool,
|
||||
/// Newline-gated streaming collector state.
|
||||
pub streaming_state: StreamingState,
|
||||
/// Live approximate output tokens for the current assistant stream.
|
||||
@@ -3791,6 +3813,38 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn notify_account_preferences_not_loaded(&mut self) {
|
||||
let sign_in = account_sign_in_marker(self.config_profile.as_deref());
|
||||
self.notify_account_preferences_not_loaded_for(&sign_in);
|
||||
}
|
||||
|
||||
fn notify_account_preferences_not_loaded_for(&mut self, sign_in: &str) {
|
||||
let first_for_sign_in = Settings::transact_opt(|settings| {
|
||||
if settings.account_preferences_notice_shown.as_deref() == Some(sign_in) {
|
||||
return Ok(None);
|
||||
}
|
||||
settings.account_preferences_notice_shown = Some(sign_in.to_string());
|
||||
Ok(Some(()))
|
||||
});
|
||||
if matches!(first_for_sign_in, Ok(None)) {
|
||||
return;
|
||||
}
|
||||
let text = self
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
self.push_status_toast_record(StatusToast::standing(
|
||||
text,
|
||||
StatusToastLevel::Warning,
|
||||
ACCOUNT_PREFERENCES_NOTICE,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_account_preferences_notice(&mut self) -> bool {
|
||||
let before = self.status_toasts.len();
|
||||
self.retire_event_notices(ACCOUNT_PREFERENCES_NOTICE);
|
||||
self.status_toasts.len() != before
|
||||
}
|
||||
|
||||
/// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
|
||||
/// pressing Shift+Tab expecting the old thinking cycle land here first.
|
||||
fn notify_keybinding_migration_once(&mut self) {
|
||||
|
||||
@@ -1142,6 +1142,7 @@ impl App {
|
||||
streaming_thinking_active_entry: None,
|
||||
pending_thinking_translation_cells: HashMap::new(),
|
||||
thinking_revision_last_bump_at: None,
|
||||
thinking_revision_owed: false,
|
||||
streaming_state: StreamingState::new(),
|
||||
streaming_output_token_estimate: 0,
|
||||
reasoning_buffer: String::new(),
|
||||
|
||||
@@ -184,6 +184,46 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_shows_once_per_sign_in_and_can_be_dismissed() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let _env = EnvVarGuard::set(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
config_path.to_string_lossy().as_ref(),
|
||||
);
|
||||
let shown = |app: &App| {
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.filter(|toast| toast.text == notice && toast.kind == StatusToastKind::Standing)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
app.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&app), 1);
|
||||
assert_eq!(
|
||||
Settings::load()
|
||||
.expect("settings should load")
|
||||
.account_preferences_notice_shown
|
||||
.as_deref(),
|
||||
Some("first-sign-in")
|
||||
);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert_eq!(shown(&app), 0);
|
||||
|
||||
let mut later_session = App::new(test_options(false), &Config::default());
|
||||
later_session.notify_account_preferences_not_loaded_for("first-sign-in");
|
||||
assert_eq!(shown(&later_session), 0, "the same sign-in is told once");
|
||||
later_session.notify_account_preferences_not_loaded_for("second-sign-in");
|
||||
assert_eq!(shown(&later_session), 1, "a later sign-in is told again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_input_scenario() {
|
||||
// Scenario consolidation of: initial_input_prefill_waits_for_manual_submit, initial_input_submit_marks_startup_dispatch
|
||||
|
||||
@@ -284,8 +284,8 @@ impl ContextMenuView {
|
||||
if start > 0 {
|
||||
rows.push(MenuRow::MoreAbove(start));
|
||||
}
|
||||
for idx in start..end {
|
||||
if idx > start && self.entries[idx].section_start {
|
||||
for (idx, entry) in self.entries.iter().enumerate().take(end).skip(start) {
|
||||
if idx > start && entry.section_start {
|
||||
rows.push(MenuRow::Divider);
|
||||
}
|
||||
rows.push(MenuRow::Entry(idx));
|
||||
|
||||
@@ -403,6 +403,25 @@ 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();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
│ Background (default) ✎ │current terminal
|
||||
│ Pet mode Off [ ] │saved terminal
|
||||
│ Quiet transcript On [x] │startup terminal
|
||||
│ Model reasoning in chat Off [ ] │source settings.toml
|
||||
│ Model reasoning in chat On [x] │source settings.toml
|
||||
│ Thinking Default Expanded Off [ ] │scope SAVED
|
||||
│ Thinking Preview Lines 2 ✎ │apply applies on save
|
||||
│ Thinking background highlight On [x] │kind choice
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Background (default) ✎ SAVED █
|
||||
Pet mode Off [ ] SAVED █
|
||||
Quiet transcript On [x] SAVED █
|
||||
Model reasoning in chat Off [ ] SAVED █
|
||||
Model reasoning in chat On [x] SAVED █
|
||||
Thinking Default Expanded Off [ ] SAVED █
|
||||
Thinking Preview Lines 2 ✎ SAVED │
|
||||
Thinking background highlight On [x] SAVED │
|
||||
|
||||
@@ -416,15 +416,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_notice_sheds_whole_clauses_and_never_dangles() {
|
||||
const NOTICE: &str = "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
|
||||
const NOTICE: &str = "Usage reporting is 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("Anonymous usage counts are on. Conversations and code are never collected.")
|
||||
Some("Usage reporting is on. Conversations and code are never collected.")
|
||||
);
|
||||
assert_eq!(
|
||||
fit_notice(NOTICE, 40, false).as_deref(),
|
||||
Some("Anonymous usage counts are on.")
|
||||
Some("Usage reporting is 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::{
|
||||
SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title,
|
||||
extract_user_prompt, strip_thinking_tags,
|
||||
MAX_SESSION_TITLE_CHARS, 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.len() > 100 {
|
||||
if new_title.is_empty() || new_title.chars().count() > MAX_SESSION_TITLE_CHARS {
|
||||
self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned());
|
||||
return ViewAction::None;
|
||||
}
|
||||
@@ -2045,6 +2045,28 @@ 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();
|
||||
|
||||
@@ -30,15 +30,17 @@ use crate::tui::history::HistoryCell;
|
||||
/// frame cap (see `frame_rate_limiter`) yet imperceptible as lag.
|
||||
///
|
||||
/// Correctness: this only skips *intermediate* repaints. Appended content is
|
||||
/// never dropped — it lands in the cell immediately — and finalize always
|
||||
/// forces a bump so the final reasoning text is fully rendered.
|
||||
/// never dropped — it lands in the cell immediately — the display beat renders
|
||||
/// a skipped bump once the window has passed, and finalize always forces a
|
||||
/// bump so the final reasoning text is fully rendered.
|
||||
const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Bump the active-cell revision for a streaming thinking mutation, but at
|
||||
/// most once per [`THINKING_REVISION_THROTTLE`] window. Returns whether a bump
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one (or into the
|
||||
/// forced finalize bump), so no content is ever lost — only redundant
|
||||
/// intermediate re-wraps are dropped.
|
||||
/// was actually emitted. Skipped bumps coalesce into the next one, into
|
||||
/// [`render_owed_revision`] on a later display beat, or into the forced
|
||||
/// finalize bump, so no content is ever lost — only redundant intermediate
|
||||
/// re-wraps are dropped.
|
||||
fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
let due = app
|
||||
.thinking_revision_last_bump_at
|
||||
@@ -47,9 +49,17 @@ fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
|
||||
app.thinking_revision_last_bump_at = Some(now);
|
||||
app.bump_active_cell_revision();
|
||||
}
|
||||
app.thinking_revision_owed = !due;
|
||||
due
|
||||
}
|
||||
|
||||
pub(super) fn render_owed_revision(app: &mut App, now: Instant) -> bool {
|
||||
if app.streaming_thinking_active_entry.is_none() {
|
||||
app.thinking_revision_owed = false;
|
||||
}
|
||||
app.thinking_revision_owed && bump_thinking_revision_throttled(app, now)
|
||||
}
|
||||
|
||||
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
|
||||
/// entry index. If no thinking entry is currently streaming, push a fresh one.
|
||||
/// P2.3: thinking shares the active cell with subsequent tool calls so the
|
||||
@@ -76,7 +86,8 @@ pub(super) fn ensure_active_entry(app: &mut App) -> usize {
|
||||
/// committed to the cell immediately; the active-cell revision bump that
|
||||
/// triggers a re-wrap of the live tail is debounced to at most one per
|
||||
/// [`THINKING_REVISION_THROTTLE`] window (#1620). Skipped bumps coalesce into
|
||||
/// the next append or the forced finalize bump, so no content is ever lost.
|
||||
/// the next append, a later display beat or the forced finalize bump, so no
|
||||
/// content is ever lost.
|
||||
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
|
||||
append_at(app, entry_idx, text, Instant::now());
|
||||
}
|
||||
@@ -308,6 +319,7 @@ pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remain
|
||||
// the debounce window so the next thinking block's first chunk renders
|
||||
// immediately rather than being coalesced into a stale window.
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
app.thinking_revision_owed = false;
|
||||
app.bump_active_cell_revision();
|
||||
true
|
||||
}
|
||||
@@ -426,6 +438,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_stream_renders_the_reasoning_it_already_received() {
|
||||
let mut app = test_app();
|
||||
let entry = ensure_active_entry(&mut app);
|
||||
app.thinking_revision_last_bump_at = None;
|
||||
let t0 = Instant::now();
|
||||
append_at(&mut app, entry, "first slice ", t0);
|
||||
let shown = app.active_cell_revision;
|
||||
append_at(&mut app, entry, "held back", t0 + Duration::from_millis(16));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + Duration::from_millis(32)
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown);
|
||||
assert!(render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
assert!(!render_owed_revision(
|
||||
&mut app,
|
||||
t0 + THINKING_REVISION_THROTTLE * 3
|
||||
));
|
||||
assert_eq!(app.active_cell_revision, shown.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// #7123: a reasoning block that opens while the display beat still holds
|
||||
/// an uncommitted slice of prose must not drop that slice. `start_block`
|
||||
/// resets `StreamingState`, so the tail has to be delivered first.
|
||||
|
||||
@@ -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 in 0..3 {
|
||||
let width = max_line_width(&cache.per_cell[idx].lines);
|
||||
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
|
||||
let width = max_line_width(&cell.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 in 0..3 {
|
||||
let width = max_line_width(&cache.per_cell[idx].lines);
|
||||
for (idx, cell) in cache.per_cell.iter().enumerate().take(3) {
|
||||
let width = max_line_width(&cell.lines);
|
||||
assert!(
|
||||
width > 105,
|
||||
"prose cell {idx} wrapped to {width} columns — still on the retired \
|
||||
|
||||
@@ -7232,14 +7232,16 @@ pub(crate) async fn run_event_loop(
|
||||
continue;
|
||||
}
|
||||
// An idle operator can dismiss the persistent context warning
|
||||
// without affecting vim or attachment handling. While a turn
|
||||
// is active Esc retains its cancellation meaning.
|
||||
// or the account preferences notice without affecting vim or
|
||||
// attachment handling. While a turn is active Esc retains its
|
||||
// cancellation meaning.
|
||||
KeyCode::Esc
|
||||
if !app.is_loading
|
||||
&& app.input.is_empty()
|
||||
&& !slash_menu_open
|
||||
&& !mention_menu_open
|
||||
&& app.dismiss_context_pressure_warning() =>
|
||||
&& (app.dismiss_context_pressure_warning()
|
||||
|| app.dismiss_account_preferences_notice()) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -9326,14 +9328,10 @@ pub(super) fn apply_engine_status(app: &mut App, message: String) -> bool {
|
||||
app.add_message(HistoryCell::System { content: message });
|
||||
return true;
|
||||
}
|
||||
// The account-profile fallback notice is sent once per engine; keep it in
|
||||
// the transcript so the next turn's status line cannot erase it.
|
||||
let retain = app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str();
|
||||
if retain {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
if app.tr(MessageId::ProfileConstitutionUnavailableLocal) == message.as_str() {
|
||||
app.notify_account_preferences_not_loaded();
|
||||
return false;
|
||||
}
|
||||
app.status_message = Some(message);
|
||||
retain
|
||||
false
|
||||
}
|
||||
@@ -1555,7 +1555,10 @@ pub(crate) fn commit_streaming_display_tick(
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if app.streaming_state.has_pending_stream_text(0) {
|
||||
if streaming_thinking::render_owed_revision(app, now) {
|
||||
updated = true;
|
||||
}
|
||||
if app.streaming_state.has_pending_stream_text(0) || app.thinking_revision_owed {
|
||||
stream_display_clock.note_delta(now);
|
||||
}
|
||||
|
||||
|
||||
@@ -1110,7 +1110,7 @@ mod screen_mode_tests {
|
||||
viewport: ratatui::Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.map_err(|err| io::Error::other(err.to_string()))
|
||||
.map_err(|err| match err {})
|
||||
},
|
||||
)
|
||||
.expect("a successful probe must switch");
|
||||
|
||||
@@ -8950,10 +8950,9 @@ fn setup_runtime_presets_preserve_environment_authority_and_state() {
|
||||
"DEEPSEEK_SANDBOX_MODE",
|
||||
]
|
||||
.map(EnvVarGuard::remove);
|
||||
let _managed = EnvVarGuard::set(
|
||||
"CODEWHALE_MANAGED_CONFIG_PATH",
|
||||
home._tmp.path().join("absent-managed.toml"),
|
||||
);
|
||||
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 _requirements = EnvVarGuard::set(
|
||||
"CODEWHALE_REQUIREMENTS_PATH",
|
||||
home._tmp.path().join("absent-requirements.toml"),
|
||||
@@ -27344,7 +27343,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
|
||||
let latest = pager.body_text();
|
||||
assert!(latest.contains("SECOND-PROMPT"), "{latest}");
|
||||
assert!(latest.contains("[∿ reasoning 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("[∿ thinking 1/1 · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-THOUGHT"), "{latest}");
|
||||
assert!(latest.contains("[◆ · running]"), "{latest}");
|
||||
assert!(latest.contains("SECOND-ANSWER"), "{latest}");
|
||||
@@ -27356,7 +27355,7 @@ fn turn_inspector_switches_isolated_full_turn_pages_with_tagged_reasoning_and_ou
|
||||
for expected in [
|
||||
"FIRST-PROMPT",
|
||||
"keep this input line",
|
||||
"[∿ reasoning 1/1 · Done]",
|
||||
"[∿ thinking 1/1 · Done]",
|
||||
"FIRST-THOUGHT",
|
||||
"full reasoning tail",
|
||||
"[⚙ using tool]",
|
||||
@@ -35489,23 +35488,41 @@ fn extension_prompt_origin_keeps_denials_and_other_policy_holds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_profile_fallback_notice_stays_in_the_transcript() {
|
||||
fn account_profile_fallback_notice_is_a_standing_notice_outside_the_transcript() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = App::new(create_test_options(), &Config::default());
|
||||
let notice = app
|
||||
let engine_notice = app
|
||||
.tr(MessageId::ProfileConstitutionUnavailableLocal)
|
||||
.to_string();
|
||||
let before = app.history.len();
|
||||
assert!(super::event_loop::apply_engine_status(
|
||||
let status_before = app.status_message.clone();
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
notice.clone()
|
||||
engine_notice
|
||||
));
|
||||
assert_eq!(app.history.len(), before);
|
||||
assert_eq!(app.status_message, status_before);
|
||||
assert!(!super::event_loop::apply_engine_status(
|
||||
&mut app,
|
||||
"Executing tools sequentially".into()
|
||||
));
|
||||
assert_eq!(app.history.len(), before + 1);
|
||||
assert!(matches!(&app.history[before], HistoryCell::System { content } if *content == notice));
|
||||
assert_eq!(app.history.len(), before);
|
||||
let notice = app
|
||||
.tr(MessageId::AccountPreferencesNotLoaded)
|
||||
.replace("{command}", "codewhale login");
|
||||
let standing = |app: &App| {
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.find(|toast| toast.text == notice)
|
||||
.cloned()
|
||||
};
|
||||
app.sync_status_message_to_toasts();
|
||||
let shown = standing(&app).expect("a later routine status does not erase the notice");
|
||||
assert_eq!(shown.level, StatusToastLevel::Warning);
|
||||
assert_eq!(shown.ttl_ms, None);
|
||||
assert!(app.dismiss_account_preferences_notice());
|
||||
assert!(standing(&app).is_none());
|
||||
assert!(!app.dismiss_account_preferences_notice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,6 +32,8 @@ mod plugin_e2e_acceptance;
|
||||
mod screen_mode_inline_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod search_text_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod thinking_stream_pty;
|
||||
mod tool_lifecycle_acceptance;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod work_bar_keys_pty;
|
||||
@@ -0,0 +1,585 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::qa_harness::{
|
||||
harness::{Harness, SealedWorkspace, make_sealed_workspace},
|
||||
keys,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
const MODEL: &str = "deepseek-v4-pro";
|
||||
const PROMPTS: [&str; 2] = ["first slow fixture prompt", "second slow fixture prompt"];
|
||||
const ANSWER: &str = "Fixture answer after thinking.";
|
||||
const REASONING_LINES: usize = 6;
|
||||
const NOTICE: &str = "Account not loaded. Run codewhale login.";
|
||||
const RETIRED_TRANSCRIPT_NOTICE: &str = "could not be loaded";
|
||||
|
||||
fn reasoning_line(index: usize) -> String {
|
||||
format!("thinking fixture line {index} of {REASONING_LINES}")
|
||||
}
|
||||
|
||||
struct PacedProvider {
|
||||
address: SocketAddr,
|
||||
gate: mpsc::Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
acceptor: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct Served {
|
||||
gates: Mutex<mpsc::Receiver<()>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
turns: Arc<AtomicUsize>,
|
||||
profile_requests: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl PacedProvider {
|
||||
fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("loopback provider");
|
||||
let address = listener.local_addr().expect("loopback address");
|
||||
let (gate, gates) = mpsc::channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let turns = Arc::new(AtomicUsize::new(0));
|
||||
let profile_requests = Arc::new(AtomicUsize::new(0));
|
||||
let served = Arc::new(Served {
|
||||
gates: Mutex::new(gates),
|
||||
stop: Arc::clone(&stop),
|
||||
turns: Arc::clone(&turns),
|
||||
profile_requests: Arc::clone(&profile_requests),
|
||||
});
|
||||
let acceptor = std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let Ok(stream) = stream else { continue };
|
||||
let served = Arc::clone(&served);
|
||||
std::thread::spawn(move || {
|
||||
let _ = serve(stream, &served);
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
address,
|
||||
gate,
|
||||
stop,
|
||||
turns,
|
||||
profile_requests,
|
||||
acceptor: Some(acceptor),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_base(&self) -> String {
|
||||
format!("http://{}/v1", self.address)
|
||||
}
|
||||
|
||||
fn account_base(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
|
||||
fn release(&self, gates: usize) {
|
||||
for _ in 0..gates {
|
||||
self.gate.send(()).expect("provider gate");
|
||||
}
|
||||
}
|
||||
|
||||
fn turns(&self) -> usize {
|
||||
self.turns.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PacedProvider {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = TcpStream::connect(self.address);
|
||||
if let Some(acceptor) = self.acceptor.take() {
|
||||
let _ = acceptor.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, served: &Served) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut stream = stream;
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line)? == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut header = String::new();
|
||||
if reader.read_line(&mut header)? == 0 {
|
||||
break;
|
||||
}
|
||||
let header = header.trim_end();
|
||||
if header.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = header.split_once(':')
|
||||
&& name.eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let mut body = vec![0u8; content_length.min(8 * 1024 * 1024)];
|
||||
reader.read_exact(&mut body)?;
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or_default();
|
||||
let path = parts.next().unwrap_or_default();
|
||||
if method == "GET" && path.ends_with("/models") {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({"object":"list","data":[{"id":MODEL,"object":"model"}]}),
|
||||
);
|
||||
}
|
||||
if path.starts_with("/api/") {
|
||||
served.profile_requests.fetch_add(1, Ordering::AcqRel);
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"401 Unauthorized",
|
||||
&serde_json::json!({"error":"loopback fixture account is signed out"}),
|
||||
);
|
||||
}
|
||||
if method != "POST" {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"404 Not Found",
|
||||
&serde_json::json!({"error":"not served by the loopback fixture"}),
|
||||
);
|
||||
}
|
||||
let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let streaming = request["stream"].as_bool().unwrap_or(false);
|
||||
let with_tools = request["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|tools| !tools.is_empty());
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
let turn = PROMPTS
|
||||
.iter()
|
||||
.rposition(|prompt| text.contains(prompt))
|
||||
.map(|index| index + 1);
|
||||
let paced = streaming
|
||||
&& with_tools
|
||||
&& turn.is_some_and(|turn| {
|
||||
served
|
||||
.turns
|
||||
.compare_exchange(turn - 1, turn, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
});
|
||||
if !streaming {
|
||||
return respond_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion","model":MODEL,
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}
|
||||
}),
|
||||
);
|
||||
}
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n",
|
||||
)?;
|
||||
stream.flush()?;
|
||||
if paced {
|
||||
wait_for_gate(served)?;
|
||||
for index in 1..=REASONING_LINES {
|
||||
send_delta(
|
||||
&mut stream,
|
||||
serde_json::json!({"reasoning_content": format!("{}\n", reasoning_line(index))}),
|
||||
)?;
|
||||
}
|
||||
wait_for_gate(served)?;
|
||||
send_delta(&mut stream, serde_json::json!({"content": ANSWER}))?;
|
||||
wait_for_gate(served)?;
|
||||
} else {
|
||||
send_delta(&mut stream, serde_json::json!({"content": "ok"}))?;
|
||||
}
|
||||
send_chunk(
|
||||
&mut stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}
|
||||
}),
|
||||
)?;
|
||||
stream.write_all(b"data: [DONE]\n\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn wait_for_gate(served: &Served) -> std::io::Result<()> {
|
||||
let gates = served.gates.lock().expect("provider gates");
|
||||
loop {
|
||||
if served.stop.load(Ordering::Acquire) {
|
||||
return Err(std::io::ErrorKind::Interrupted.into());
|
||||
}
|
||||
match gates.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(std::io::ErrorKind::BrokenPipe.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_delta(stream: &mut TcpStream, delta: serde_json::Value) -> std::io::Result<()> {
|
||||
send_chunk(
|
||||
stream,
|
||||
&serde_json::json!({
|
||||
"id":"loopback","object":"chat.completion.chunk","model":MODEL,
|
||||
"choices":[{"index":0,"delta":delta,"finish_reason":null}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_chunk(stream: &mut TcpStream, chunk: &serde_json::Value) -> std::io::Result<()> {
|
||||
stream.write_all(format!("data: {chunk}\n\n").as_bytes())?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn respond_json(
|
||||
stream: &mut TcpStream,
|
||||
status: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> std::io::Result<()> {
|
||||
let body = body.to_string();
|
||||
stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn prepare(workspace: &SealedWorkspace, provider: &PacedProvider, settings: Option<&str>) {
|
||||
let state = workspace.home().join(".codewhale");
|
||||
std::fs::write(state.join(".onboarded"), "").unwrap();
|
||||
std::fs::write(
|
||||
state.join("config.toml"),
|
||||
format!(
|
||||
"provider = \"deepseek\"\n[providers.deepseek]\nbase_url = {:?}\napi_key = \"loopback-only\"\nmodel = {MODEL:?}\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
provider.model_base()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(settings) = settings {
|
||||
std::fs::write(state.join("settings.toml"), settings).unwrap();
|
||||
}
|
||||
let trust = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust).unwrap();
|
||||
std::fs::write(trust.join("trusted"), "").unwrap();
|
||||
}
|
||||
|
||||
fn launch(
|
||||
workspace: &SealedWorkspace,
|
||||
provider: &PacedProvider,
|
||||
(rows, cols): (u16, u16),
|
||||
session: &str,
|
||||
animated: bool,
|
||||
) -> Harness {
|
||||
let mut tui = Harness::builder(Harness::codewhale_binary())
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.env("SHELL", "/bin/sh")
|
||||
.env("CODEWHALE_CLOUD_API_BASE", provider.account_base())
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("NO_ANIMATIONS", if animated { "0" } else { "1" })
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().unwrap(),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
session,
|
||||
])
|
||||
.size(rows, cols)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
tui.wait_for_text("accept edits", WAIT)
|
||||
.unwrap_or_else(|error| panic!("the dial chip never painted: {error}"));
|
||||
tui
|
||||
}
|
||||
|
||||
fn expect(tui: &mut Harness, what: &str, predicate: impl FnMut(&super::qa_harness::Frame) -> bool) {
|
||||
if let Err(error) = tui.wait_for(predicate, WAIT) {
|
||||
panic!("waiting for {what}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(tui: &mut Harness) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut quiet_since = Instant::now();
|
||||
while Instant::now() < deadline && quiet_since.elapsed() < Duration::from_millis(300) {
|
||||
if tui.pump() {
|
||||
quiet_since = Instant::now();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn report(tui: &mut Harness, label: &str) {
|
||||
let frame = tui.frame();
|
||||
println!(
|
||||
"--- {label} at {}x{} ---\n{}",
|
||||
frame.cols(),
|
||||
frame.rows(),
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
|
||||
fn footer_row(tui: &mut Harness) -> (u16, String) {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.rev()
|
||||
.map(|row| (row, frame.row(row)))
|
||||
.find(|(_, text)| text.contains("accept edits"))
|
||||
.unwrap_or_else(|| panic!("no dial chip on screen:\n{}", frame.debug_dump()))
|
||||
}
|
||||
|
||||
fn assert_status_and_water(tui: &mut Harness, label: &str) {
|
||||
let (footer, status) = footer_row(tui);
|
||||
let frame = tui.frame();
|
||||
for retired in ["operate", "Operate", "Work bar"] {
|
||||
assert!(
|
||||
!frame.contains(retired),
|
||||
"{label}: `{retired}` is on screen:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
assert!(status.contains("accept edits"), "{label}: {status}");
|
||||
for row in 0..footer.saturating_sub(3) {
|
||||
let text = frame.row(row);
|
||||
let cells: Vec<char> = text.chars().collect();
|
||||
let school = cells.windows(2).any(|pair| {
|
||||
pair.iter()
|
||||
.all(|cell| ('\u{2801}'..='\u{28FF}').contains(cell))
|
||||
});
|
||||
assert!(
|
||||
!school && !text.contains(['˚', '°', '≈', '~']),
|
||||
"{label}: ambient art on transcript row {row} `{text}`:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_rows(tui: &mut Harness) -> usize {
|
||||
let frame = tui.frame();
|
||||
(0..frame.rows())
|
||||
.filter(|&row| frame.row(row).contains("Thinking…"))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn stream_first_turn(tui: &mut Harness, provider: &PacedProvider, thinking_shown: bool) {
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the first request and its interrupt hint", |frame| {
|
||||
provider.turns() == 1 && frame.contains("Esc to interrupt")
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
report(tui, "loading");
|
||||
assert_status_and_water(tui, "loading");
|
||||
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
if thinking_shown {
|
||||
expect(tui, "the live thinking block", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
} else {
|
||||
expect(tui, "the thinking activity line", |frame| {
|
||||
frame.contains("Thinking…")
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1300));
|
||||
report(tui, "thinking");
|
||||
assert_status_and_water(tui, "thinking");
|
||||
assert_eq!(thinking_rows(tui), 1, "one Thinking line");
|
||||
let (_, status) = footer_row(tui);
|
||||
assert!(
|
||||
status.contains("thinking ") && !status.contains("reasoning"),
|
||||
"the clock names the phase: {status}"
|
||||
);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("reasoning..."), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains(&tail), thinking_shown);
|
||||
assert!(
|
||||
!frame.contains(&reasoning_line(1)),
|
||||
"a long trace stays inside its window:\n{}",
|
||||
frame.debug_dump()
|
||||
);
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the streamed answer", |frame| frame.contains(ANSWER));
|
||||
report(tui, "answer");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
assert_eq!(frame.contains("Thought for "), thinking_shown);
|
||||
assert!(!frame.contains(&tail), "{}", frame.debug_dump());
|
||||
|
||||
provider.release(1);
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
settle(tui);
|
||||
report(tui, "settled");
|
||||
assert_status_and_water(tui, "settled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_streams_stops_and_restores_at_the_shipped_widths() {
|
||||
for size in [(24, 80), (32, 100), (32, 120), (40, 140)] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
let mut tui = launch(&workspace, &provider, size, "--fresh", true);
|
||||
stream_first_turn(&mut tui, &provider, true);
|
||||
|
||||
tui.type_line(PROMPTS[1]).unwrap();
|
||||
expect(&mut tui, "the second request", |_| provider.turns() == 2);
|
||||
provider.release(1);
|
||||
let tail = reasoning_line(REASONING_LINES);
|
||||
expect(&mut tui, "thinking before the stop", |frame| {
|
||||
frame.contains("Thinking…") && frame.contains(&tail)
|
||||
});
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the stopped turn", |frame| {
|
||||
!frame.contains("Esc to interrupt") && !frame.contains("Thinking…")
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "stopped");
|
||||
assert_status_and_water(&mut tui, "stopped");
|
||||
provider.release(2);
|
||||
|
||||
tui.send(keys::key::ctrl('u')).unwrap();
|
||||
settle(&mut tui);
|
||||
tui.send(keys::key::ctrl_d()).unwrap();
|
||||
tui.wait_for_exit(Duration::from_secs(10));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, size, "--continue", false);
|
||||
expect(&mut tui, "the restored conversation", |frame| {
|
||||
frame.contains(ANSWER)
|
||||
});
|
||||
settle(&mut tui);
|
||||
report(&mut tui, "restored");
|
||||
assert_status_and_water(&mut tui, "restored");
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thinking…"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_choice_to_hide_thinking_survives_the_new_default() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, Some("show_thinking = false\n"));
|
||||
let mut tui = launch(&workspace, &provider, (24, 80), "--fresh", false);
|
||||
stream_first_turn(&mut tui, &provider, false);
|
||||
let frame = tui.frame();
|
||||
assert!(!frame.contains("Thought"), "{}", frame.debug_dump());
|
||||
tui.shutdown();
|
||||
let saved: toml::Value = toml::from_str(
|
||||
&std::fs::read_to_string(workspace.home().join(".codewhale/settings.toml")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved["show_thinking"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
fn sign_in(workspace: &SealedWorkspace, provider: &PacedProvider, session: &str) {
|
||||
let path = workspace.home().join(".codewhale/secrets/secrets.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let secrets =
|
||||
codewhale_secrets::Secrets::new(Arc::new(codewhale_secrets::FileKeyringStore::new(path)));
|
||||
let bundle = serde_json::from_value(serde_json::json!({
|
||||
"tokenType": "Bearer",
|
||||
"accessToken": "loopback-access",
|
||||
"refreshToken": "loopback-refresh",
|
||||
"session": {"id": session},
|
||||
"user": {"id": "loopback-account"}
|
||||
}))
|
||||
.unwrap();
|
||||
codewhale_secrets::account::AccountSessionStore::new(secrets, None, &provider.account_base())
|
||||
.save(bundle)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn run_ungated_turn(tui: &mut Harness, provider: &PacedProvider) {
|
||||
provider.release(3);
|
||||
tui.type_line(PROMPTS[0]).unwrap();
|
||||
expect(tui, "the answer", |frame| frame.contains(ANSWER));
|
||||
expect(tui, "the settled turn", |frame| {
|
||||
!frame.contains("Esc to interrupt")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_preferences_notice_is_shown_once_per_sign_in_and_esc_dismisses_it() {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
|
||||
for cols in [80, 120] {
|
||||
let workspace = make_sealed_workspace().unwrap();
|
||||
let provider = PacedProvider::start();
|
||||
prepare(&workspace, &provider, None);
|
||||
sign_in(&workspace, &provider, "first-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, cols), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the standing notice", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice");
|
||||
assert!(provider.profile_requests.load(Ordering::Acquire) > 0);
|
||||
let (_, status) = footer_row(&mut tui);
|
||||
assert!(
|
||||
status.contains(NOTICE),
|
||||
"the notice is footer chrome: {status}"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
expect(&mut tui, "the dismissed notice", |frame| {
|
||||
!frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "account notice dismissed");
|
||||
tui.shutdown();
|
||||
}
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the first notice", |frame| frame.contains(NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
tui.wait_for_idle(Duration::from_secs(6), Duration::from_secs(30))
|
||||
.unwrap();
|
||||
report(&mut tui, "same sign-in, later session");
|
||||
assert!(
|
||||
!tui.frame().contains(NOTICE),
|
||||
"the same sign-in is told once"
|
||||
);
|
||||
assert!(!tui.frame().contains(RETIRED_TRANSCRIPT_NOTICE));
|
||||
tui.shutdown();
|
||||
|
||||
sign_in(&workspace, &provider, "second-sign-in");
|
||||
let mut tui = launch(&workspace, &provider, (32, 120), "--fresh", false);
|
||||
provider.turns.store(0, Ordering::Release);
|
||||
run_ungated_turn(&mut tui, &provider);
|
||||
expect(&mut tui, "the notice for a later sign-in", |frame| {
|
||||
frame.contains(NOTICE)
|
||||
});
|
||||
report(&mut tui, "later sign-in");
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -2230,7 +2230,10 @@ Common settings keys:
|
||||
`@`-mention completions are populated. `fuzzy` searches the workspace and
|
||||
applies mention frecency. `browser` lists only the immediate children of the
|
||||
currently typed directory segment in deterministic alphabetical order.
|
||||
- `show_thinking` (on/off)
|
||||
- `show_thinking` (on/off, default on): shows model thinking in the transcript,
|
||||
live while it streams and collapsed under a `Thought for …` header once the
|
||||
answer starts. Off hides it; answers and tools stay visible. A value already
|
||||
saved in `settings.toml` is kept.
|
||||
- `thinking_default_expanded` (on/off, default off): renders thinking blocks
|
||||
expanded initially when `show_thinking` is enabled. Space still toggles the
|
||||
selected block, and it decides only the blocks you have not touched: a block
|
||||
@@ -2693,7 +2696,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.
|
||||
- `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.
|
||||
- `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,6 +44,7 @@ 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,12 +129,19 @@ the `agent/created` hook fires only for children the same plugin starts.
|
||||
|
||||
## Plugin UI (`dsh.client`)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The selected Electron host's reviewed asset transport and isolated frame do
|
||||
not establish compatibility with those services. Its real network containment
|
||||
|
||||
@@ -161,6 +161,17 @@ 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,6 +1,13 @@
|
||||
# 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,6 +5,15 @@ 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
|
||||
|
||||
+19
-7
@@ -12,13 +12,25 @@ format (`plugin.json`, compatible `kimi.plugin.json` or
|
||||
the trust/enable lifecycle — this document covers how bits get onto disk in
|
||||
the first place.
|
||||
|
||||
## Choose a plugin, skill, or MCP connection
|
||||
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
|
||||
|
||||
| What you need | Use | Starting point |
|
||||
| --- | --- | --- |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
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
|
||||
@@ -26,12 +38,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.
|
||||
|
||||
MCP has two connection kinds: a local process over stdio, or a remote URL over
|
||||
An MCP connector is one of two 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 skill or plugin does not connect an
|
||||
OAuth path is not supported. Installing a 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
|
||||
@@ -63,7 +75,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. DSH plugin UI is unsupported. Other applications' plugin
|
||||
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
|
||||
directories and credentials are not borrowed.
|
||||
|
||||
`/plugin suggest <task>` is a local, read-only companion: it ranks already
|
||||
|
||||
+3
-1
@@ -882,6 +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.
|
||||
Built-in overrides remove the
|
||||
@@ -3268,7 +3270,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 and Grok. `state` is `installed` or `not_installed`; a binary is
|
||||
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy, Grok and Qwen Code. `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
|
||||
|
||||
+35
-22
@@ -145,20 +145,27 @@ 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 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
|
||||
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
|
||||
reads the live configuration, but a check made once at start does not cover a
|
||||
later reload, so ask again after a reload.
|
||||
|
||||
@@ -193,18 +200,24 @@ 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, 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
|
||||
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
|
||||
`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,6 +5,13 @@ 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
|
||||
|
||||
+8
-8
@@ -141,8 +141,8 @@ owner = "crates/engine/src/tools/apply_patch.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "mcp-client"
|
||||
name = "MCP client"
|
||||
summary = "Connects MCP servers, exposes permitted tools and records connection status for the model."
|
||||
name = "MCP connectors"
|
||||
summary = "A plugin kind: 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 = "Skills"
|
||||
summary = "Trusted instruction packages with bundle policy, invocation controls and shared, configurable index limits."
|
||||
name = "Skill plugins"
|
||||
summary = "A plugin kind: trusted instruction packages with bundle policy, invocation controls and 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 = "Installs, reviews and trusts plugin bundles, reports their status, and restores missing reviewed runtime snapshots."
|
||||
summary = "Everything you add is a plugin: installs, reviews and trusts bundles, shows status, restores missing reviewed 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 = "Hooks"
|
||||
summary = "Runs user commands on session and tool events, with an execution receipt after shell tools."
|
||||
name = "Hook plugins"
|
||||
summary = "A plugin kind: 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"
|
||||
@@ -805,7 +805,7 @@ owner = "crates/tui/src/commands/groups/session/export.rs"
|
||||
[[feature]]
|
||||
id = "live-thinking"
|
||||
name = "Live thinking"
|
||||
summary = "/verbose toggles live model thinking in the transcript."
|
||||
summary = "Model thinking shows live in the transcript, then collapses under a Thought header; /verbose shows it in full."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable" }
|
||||
owner = "crates/tui/src/commands/groups/config/mod.rs"
|
||||
|
||||
@@ -97,8 +97,13 @@ Stage 5 of the loop: [cw-orient](../cw-orient/SKILL.md) →
|
||||
|
||||
Co-authored-by: <maintainer or agent identity>
|
||||
```
|
||||
GitHub's contributor list counts authors only, and
|
||||
`check-contributor-credit.py` flags a harvested handle who is not credited.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -1152,7 +1152,7 @@ codewhale 还把用户偏好存储在:
|
||||
- `mention_menu_limit`(整数,默认 `128`):输入区渲染可见窗口之前保留的 `@`-提及弹出候选最大数。可见行仍取决于终端高度。
|
||||
- `mention_walk_depth`(整数,默认 `10`):`@`-提及补全遍历的最大工作区深度。在深度嵌套的工作区设为 `0` 表示无限深度;在非常大的仓库保持默认,除非需要。
|
||||
- `mention_menu_behavior`(`fuzzy`、`browser`;默认 `fuzzy`):控制 `@`-提及补全如何填充。`fuzzy` 搜索工作区并应用提及 frecency。`browser` 只按确定性字母顺序列出当前输入目录段的直接子项。
|
||||
- `show_thinking`(on/off)
|
||||
- `show_thinking`(on/off,默认 on):在对话记录中显示模型思考,流式输出时实时显示,回答开始后折叠到“思考用时 …”标题下。设为 off 则隐藏思考;回答和工具仍然可见。`settings.toml` 中已保存的值保持不变。
|
||||
- `thinking_default_expanded`(on/off,默认 off):启用 `show_thinking` 时,让思考块初始以展开状态渲染。Space 仍可切换所选块,并且它只决定你尚未动过的块:你自己展开或折叠过的块,即使之后更改这个设置,也会保持该状态。这在 Space 绑定可能被拦截的 SSH/tmux 环境中很有用。
|
||||
- `thinking_preview_lines`(整数,默认 `2`):**折叠**的已完成思考仍显示多少正文行。`0` 仅头部;`10` 是较旧的完整显示。实时流式预览不变。用 Space 展开块,或设置 `thinking_default_expanded` 打开每个块。
|
||||
- `help_expand_groups`(on/off,默认 off):让帮助/快捷键从每个组展开开始。默认折叠长尾(Grok 风格);输入过滤仍展开匹配。
|
||||
|
||||
@@ -127,6 +127,11 @@ 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)。
|
||||
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user