mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
40
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;
|
||||
|
||||
+31
-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")]
|
||||
@@ -3049,6 +3049,7 @@ fn start_cli_telemetry(
|
||||
telemetry::init(consent);
|
||||
telemetry::record(Event::SessionStart {
|
||||
source: SessionSource::Unknown,
|
||||
install_age: telemetry::install_age(),
|
||||
});
|
||||
Some(CliTelemetrySession {
|
||||
started: std::time::Instant::now(),
|
||||
@@ -6792,6 +6793,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 +7231,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();
|
||||
|
||||
@@ -484,6 +484,22 @@ pub fn public_model_identifier(model: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Case-insensitive form of [`public_model_identifier`] for ids that were
|
||||
/// lowercased before the lookup.
|
||||
#[must_use]
|
||||
pub fn public_model_identifier_ignore_case(model: &str) -> bool {
|
||||
let catalog = super::bundled_models_dev_catalog();
|
||||
let matches = |id: &str| id.eq_ignore_ascii_case(model);
|
||||
catalog.reviewed.intrinsic.keys().any(|id| matches(id))
|
||||
|| catalog.models.keys().any(|id| matches(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.iter()
|
||||
.any(|(id, row)| matches(id) || matches(&row.id))
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
bundled_reviewed()
|
||||
|
||||
@@ -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"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
|
||||
/// when the collection policy, schema, or disclosure materially changes. Prior
|
||||
/// declines remain off. Keying it to the app version would re-prompt every
|
||||
/// release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "6";
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "8";
|
||||
|
||||
/// Canonical setup step ids. The ordering matches the first-run spine so a
|
||||
/// `BTreeMap<SetupStep, _>` renders in wizard order.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11793,6 +11793,7 @@ fn record_model_turn_telemetry(
|
||||
{
|
||||
return;
|
||||
}
|
||||
let model = turn.last_parent_model.as_deref().unwrap_or(model);
|
||||
let outcome = match status {
|
||||
TurnOutcomeStatus::Completed => codewhale_telemetry::TurnOutcome::Ok,
|
||||
TurnOutcomeStatus::Failed => codewhale_telemetry::TurnOutcome::Failed,
|
||||
|
||||
@@ -73,6 +73,7 @@ include!("tests/test_cases_18.rs");
|
||||
include!("tests/test_cases_19.rs");
|
||||
|
||||
mod admission_gates;
|
||||
mod model_turn_telemetry;
|
||||
mod redacted_thinking;
|
||||
mod runtime_state;
|
||||
mod sse_turn_recovery;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_turn_telemetry_names_the_model_the_last_request_used() {
|
||||
let mut turn = crate::core::turn::TurnContext::new(4);
|
||||
turn.add_parent_usage(&Usage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Usage::default()
|
||||
});
|
||||
turn.note_parent_model("deepseek-v4pro");
|
||||
|
||||
record_model_turn_telemetry(
|
||||
codewhale_config::ProviderKind::Deepseek,
|
||||
"auto",
|
||||
&turn,
|
||||
TurnOutcomeStatus::Completed,
|
||||
);
|
||||
|
||||
let reported = codewhale_telemetry::session_counters()
|
||||
.usage_events()
|
||||
.into_iter()
|
||||
.any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
codewhale_telemetry::Event::ModelUsage { provider, model, .. }
|
||||
if provider == "deepseek" && model == "deepseek-v4-pro"
|
||||
)
|
||||
});
|
||||
assert!(reported, "the resolved wire model, not `auto`, is reported");
|
||||
}
|
||||
@@ -641,6 +641,7 @@ impl Engine {
|
||||
job.response_settled(!response_stream_failed);
|
||||
}
|
||||
turn.add_parent_usage(&usage);
|
||||
turn.note_parent_model(&stream_request.model);
|
||||
turn.note_parent_prompt_len(
|
||||
self.session.messages.len(),
|
||||
crate::compaction::estimate_request_input_tokens(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -93,6 +93,9 @@ pub struct TurnContext {
|
||||
/// their own routed receipts.
|
||||
pub parent_route_usage: Usage,
|
||||
|
||||
/// Model the most recent parent-route request was dispatched with.
|
||||
pub last_parent_model: Option<String>,
|
||||
|
||||
/// Provider calls whose usage became ambiguous after dispatch (for
|
||||
/// example an RLM timeout). A non-zero value makes cost coverage
|
||||
/// explicitly incomplete instead of inventing a zero-usage receipt.
|
||||
@@ -199,6 +202,7 @@ impl TurnContext {
|
||||
..Usage::default()
|
||||
},
|
||||
parent_route_usage: Usage::default(),
|
||||
last_parent_model: None,
|
||||
routed_usage_dropped_records: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
messages_len_at_last_parent_prompt: None,
|
||||
@@ -275,6 +279,10 @@ impl TurnContext {
|
||||
add_usage_to(&mut self.parent_route_usage, usage);
|
||||
}
|
||||
|
||||
pub fn note_parent_model(&mut self, model: &str) {
|
||||
self.last_parent_model = Some(model.to_string());
|
||||
}
|
||||
|
||||
pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) {
|
||||
self.routed_usage_dropped_records = self
|
||||
.routed_usage_dropped_records
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -226,23 +226,23 @@
|
||||
"TranscriptReasoningExpand": "expand",
|
||||
"TranscriptThought": "Thought",
|
||||
"TranscriptThoughtFor": "Thought for {duration}",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals and built-in tool use. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals, request counts, model wait time, prompt-size ranges, built-in tool use, how an exec session started and a coarse install-age range. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeHeadline": "Codewhale usage reporting",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, and how often each built-in tool runs.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, request counts, time spent waiting on the model and\nprompt-size ranges, how often each built-in tool runs, how an exec\nsession was started, and a coarse install-age range.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools,\nrequests, wait time, prompt-size and\ninstall-age ranges, exec start.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeChoiceKeep": "Yes, keep anonymous counts",
|
||||
"TelemetryNoticeChoiceDisable": "No, turn off tracking",
|
||||
"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 +373,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",
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -344,6 +344,7 @@ mod tests {
|
||||
};
|
||||
handle.record(Event::SessionStart {
|
||||
source: crate::SessionSource::Unknown,
|
||||
install_age: None,
|
||||
});
|
||||
let waiter = std::thread::spawn(move || handle.persist_local());
|
||||
assert!(matches!(rx.try_recv(), Ok(Message::Event(_))));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::event::{Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
use crate::event::{ContextSize, Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
|
||||
/// One session's accumulated counts.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -48,6 +48,7 @@ struct ModelSlot {
|
||||
model: String,
|
||||
counters: ModelCounters,
|
||||
turn_wall: TurnWall,
|
||||
context: ContextSize,
|
||||
}
|
||||
|
||||
/// At most this many distinct provider and model pairs per session; later
|
||||
@@ -186,6 +187,7 @@ impl SessionCounters {
|
||||
model,
|
||||
counters: ModelCounters::default(),
|
||||
turn_wall: TurnWall::default(),
|
||||
context: ContextSize::default(),
|
||||
});
|
||||
models.len() - 1
|
||||
}
|
||||
@@ -238,6 +240,7 @@ impl SessionCounters {
|
||||
model: slot.model.clone(),
|
||||
counters: slot.counters,
|
||||
turn_wall: slot.turn_wall,
|
||||
context: slot.context,
|
||||
}));
|
||||
}
|
||||
if let Ok(tools) = self.tools.lock() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::buffer;
|
||||
use crate::event::{Arch, Libc, Os};
|
||||
use crate::event::{Arch, InstallAge, Libc, Os};
|
||||
|
||||
/// How long an install id may live before it is replaced.
|
||||
///
|
||||
@@ -47,6 +47,77 @@ pub struct TelemetryState {
|
||||
/// a permanently offline machine tries at most once per interval.
|
||||
#[serde(default)]
|
||||
pub last_flush: Option<String>,
|
||||
/// The UTC day (`YYYY-MM-DD`) this state was first created. Local only: it
|
||||
/// is read to pick an [`InstallAge`] bucket and is never sent.
|
||||
#[serde(default)]
|
||||
pub first_seen_day: Option<String>,
|
||||
/// Set when this file predates `first_seen_day`, so its age is unknown and
|
||||
/// is reported as the oldest bucket rather than as new.
|
||||
#[serde(default)]
|
||||
pub first_seen_unknown: bool,
|
||||
}
|
||||
|
||||
enum StateRead {
|
||||
Missing,
|
||||
Unreadable,
|
||||
Present(TelemetryState),
|
||||
}
|
||||
|
||||
fn read_state_checked(root: &Path) -> StateRead {
|
||||
match std::fs::read_to_string(buffer::state_path(root)) {
|
||||
Ok(body) => serde_json::from_str::<TelemetryState>(&body)
|
||||
.map_or(StateRead::Unreadable, StateRead::Present),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StateRead::Missing,
|
||||
Err(_) => StateRead::Unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's date in UTC.
|
||||
#[must_use]
|
||||
pub fn utc_today() -> chrono::NaiveDate {
|
||||
chrono::Utc::now().date_naive()
|
||||
}
|
||||
|
||||
/// Record the first-seen day if this state has none. A missing state file is a
|
||||
/// new install; a file that has a recorded version but no day predates this
|
||||
/// field and is marked unknown. An unreadable file is left untouched.
|
||||
pub(crate) fn stamp_first_seen(root: &Path, today: chrono::NaiveDate) {
|
||||
let mut state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return,
|
||||
StateRead::Missing => TelemetryState::default(),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_day.is_some() || state.first_seen_unknown {
|
||||
return;
|
||||
}
|
||||
if state.last_version.is_some() {
|
||||
state.first_seen_unknown = true;
|
||||
} else {
|
||||
state.first_seen_day = Some(today.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
let _ = write_state(root, &state);
|
||||
}
|
||||
|
||||
/// The coarse age bucket for this installation, or `None` when the local state
|
||||
/// cannot be read. A missing state is new.
|
||||
pub(crate) fn install_age_at(root: &Path, today: chrono::NaiveDate) -> Option<InstallAge> {
|
||||
let state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return None,
|
||||
StateRead::Missing => return Some(InstallAge::NewToday),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_unknown {
|
||||
return Some(InstallAge::Older);
|
||||
}
|
||||
let Some(day) = state.first_seen_day else {
|
||||
return Some(InstallAge::NewToday);
|
||||
};
|
||||
let first = chrono::NaiveDate::parse_from_str(&day, "%Y-%m-%d").ok()?;
|
||||
Some(match today.signed_duration_since(first).num_days() {
|
||||
..=0 => InstallAge::NewToday,
|
||||
1..=6 => InstallAge::UnderWeek,
|
||||
_ => InstallAge::Older,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the install id, minting a fresh one if it is missing, unreadable,
|
||||
|
||||
+122
-26
@@ -33,10 +33,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire schema version. Bumped on any field add, remove, or retype; never
|
||||
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
|
||||
/// Default-on policy disclosure version. This is not human consent.
|
||||
pub const NOTICE_VERSION: u32 = 6;
|
||||
pub const NOTICE_VERSION: u32 = 8;
|
||||
|
||||
/// Which product surface produced a batch.
|
||||
///
|
||||
@@ -277,6 +277,12 @@ pub enum SessionSource {
|
||||
/// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub
|
||||
/// Action, so automation is never counted as a person.
|
||||
Ci,
|
||||
/// `exec` started by Codewhale's own fleet dispatcher.
|
||||
FleetWorker,
|
||||
/// `exec` started from inside an agent's shell command.
|
||||
Agent,
|
||||
/// `exec` whose stdin or stdout is not a terminal.
|
||||
Scripted,
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
@@ -288,6 +294,9 @@ impl SessionSource {
|
||||
Self::Api,
|
||||
Self::Unknown,
|
||||
Self::Ci,
|
||||
Self::FleetWorker,
|
||||
Self::Agent,
|
||||
Self::Scripted,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
@@ -300,6 +309,37 @@ impl SessionSource {
|
||||
Self::Api => "api",
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ci => "ci",
|
||||
Self::FleetWorker => "fleet_worker",
|
||||
Self::Agent => "agent",
|
||||
Self::Scripted => "scripted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long this install has existed, as a coarse bucket. The first-seen date
|
||||
/// itself never leaves the device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallAge {
|
||||
/// First seen on the current UTC day.
|
||||
NewToday,
|
||||
/// First seen 1 to 6 UTC days ago.
|
||||
UnderWeek,
|
||||
/// First seen 7 or more UTC days ago, or before the date was recorded.
|
||||
Older,
|
||||
}
|
||||
|
||||
impl InstallAge {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::NewToday, Self::UnderWeek, Self::Older];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NewToday => "new_today",
|
||||
Self::UnderWeek => "under_week",
|
||||
Self::Older => "older",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +406,20 @@ pub enum ExitClass {
|
||||
Panic,
|
||||
/// Exited non-successfully without a signal or a panic.
|
||||
Error,
|
||||
/// The process ended without its shutdown report; its aggregates were
|
||||
/// recovered from a local checkpoint at a later start.
|
||||
Unclean,
|
||||
}
|
||||
|
||||
impl ExitClass {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Clean,
|
||||
Self::Signal,
|
||||
Self::Panic,
|
||||
Self::Error,
|
||||
Self::Unclean,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
@@ -380,6 +429,7 @@ impl ExitClass {
|
||||
Self::Signal => "signal",
|
||||
Self::Panic => "panic",
|
||||
Self::Error => "error",
|
||||
Self::Unclean => "unclean",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +441,7 @@ impl ExitClass {
|
||||
Self::Signal => 1,
|
||||
Self::Panic => 2,
|
||||
Self::Error => 3,
|
||||
Self::Unclean => 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +452,7 @@ impl ExitClass {
|
||||
1 => Self::Signal,
|
||||
2 => Self::Panic,
|
||||
3 => Self::Error,
|
||||
4 => Self::Unclean,
|
||||
_ => Self::Clean,
|
||||
}
|
||||
}
|
||||
@@ -639,6 +691,9 @@ pub enum Event {
|
||||
SessionStart {
|
||||
/// How it was started.
|
||||
source: SessionSource,
|
||||
/// Coarse install age. `null` when the local state cannot be read.
|
||||
#[serde(default)]
|
||||
install_age: Option<InstallAge>,
|
||||
},
|
||||
/// A session ended. Everything the session accumulated ships here, once.
|
||||
SessionEnd {
|
||||
@@ -696,6 +751,9 @@ pub enum Event {
|
||||
counters: ModelCounters,
|
||||
/// Turn wall-clock buckets for these turns.
|
||||
turn_wall: TurnWall,
|
||||
/// Requests by input size.
|
||||
#[serde(default)]
|
||||
context: ContextSize,
|
||||
},
|
||||
/// One session's calls of one tool.
|
||||
ToolUsage {
|
||||
@@ -725,6 +783,16 @@ pub struct ModelCounters {
|
||||
pub cache_read_tokens: u64,
|
||||
/// Input tokens written to the provider's prompt cache.
|
||||
pub cache_write_tokens: u64,
|
||||
/// Provider requests on the session's own route for this model.
|
||||
#[serde(default)]
|
||||
pub requests: u32,
|
||||
/// Reasoning tokens as the provider reports them; a subset of
|
||||
/// `output_tokens`, which is not reduced.
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u64,
|
||||
/// Whole seconds spent waiting on the model, summed over the session.
|
||||
#[serde(default)]
|
||||
pub generation_secs: u32,
|
||||
}
|
||||
|
||||
impl ModelCounters {
|
||||
@@ -737,9 +805,44 @@ impl ModelCounters {
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"requests",
|
||||
"reasoning_tokens",
|
||||
"generation_secs",
|
||||
];
|
||||
}
|
||||
|
||||
/// Per-session count of model requests by input size, in tokens including
|
||||
/// cache reads. Half-open intervals; K is 1024. A histogram, never a
|
||||
/// per-request series.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContextSize {
|
||||
/// Requests with `[0, 32768)` input tokens.
|
||||
pub lt_32k: u32,
|
||||
/// Requests with `[32768, 131072)` input tokens.
|
||||
#[serde(rename = "32k_128k")]
|
||||
pub k32_to_k128: u32,
|
||||
/// Requests with `[131072, 262144)` input tokens.
|
||||
#[serde(rename = "128k_256k")]
|
||||
pub k128_to_k256: u32,
|
||||
/// Requests with 262144 or more input tokens.
|
||||
pub gte_256k: u32,
|
||||
}
|
||||
|
||||
impl ContextSize {
|
||||
/// Field names in wire spelling, in declaration order.
|
||||
pub const FIELDS: &'static [&'static str] = &["lt_32k", "32k_128k", "128k_256k", "gte_256k"];
|
||||
|
||||
/// Record one request with `tokens` input tokens.
|
||||
pub fn observe(&mut self, tokens: u64) {
|
||||
match tokens {
|
||||
0..32_768 => self.lt_32k = self.lt_32k.saturating_add(1),
|
||||
32_768..131_072 => self.k32_to_k128 = self.k32_to_k128.saturating_add(1),
|
||||
131_072..262_144 => self.k128_to_k256 = self.k128_to_k256.saturating_add(1),
|
||||
_ => self.gte_256k = self.gte_256k.saturating_add(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First-party tool names reported by `tool_usage`. Any other name is
|
||||
/// reported as `mcp` or `other`, so a plugin's or server's tool name never
|
||||
/// leaves the machine.
|
||||
@@ -830,17 +933,22 @@ pub fn is_reported_tool_name(value: &str) -> bool {
|
||||
value == "mcp" || value == "other" || TOOL_NAMES.contains(&value)
|
||||
}
|
||||
|
||||
/// The `model` value for a model id: the lowercased id when the bundled model
|
||||
/// catalog lists it, otherwise `other`. Custom, local and fine-tuned model
|
||||
/// names therefore never leave the machine.
|
||||
/// The `model` value for a model id: the lowercased canonical id when the
|
||||
/// bundled model catalog lists it, otherwise `other`. Official aliases and
|
||||
/// compatibility spellings are resolved first. Custom, local and fine-tuned
|
||||
/// model names therefore never leave the machine.
|
||||
#[must_use]
|
||||
pub fn reported_model_id(model: &str) -> String {
|
||||
use codewhale_config::catalog::reviewed::{compatibility_alias, official_deepseek_model_id};
|
||||
let id = model.trim().to_ascii_lowercase();
|
||||
if is_catalogued_model(&id) {
|
||||
id
|
||||
} else {
|
||||
"other".to_string()
|
||||
}
|
||||
let canonical = official_deepseek_model_id(&id)
|
||||
.or_else(|| compatibility_alias("canonical_model_name", &id))
|
||||
.map(str::to_ascii_lowercase);
|
||||
canonical
|
||||
.into_iter()
|
||||
.chain(std::iter::once(id))
|
||||
.find(|candidate| is_catalogued_model(candidate))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
}
|
||||
|
||||
/// Whether `value` may appear as `model_usage.model`.
|
||||
@@ -850,21 +958,9 @@ pub fn is_reported_model_id(value: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_catalogued_model(id: &str) -> bool {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return false;
|
||||
}
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
catalog.reviewed.intrinsic.contains_key(id)
|
||||
|| catalog
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
})
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& codewhale_config::catalog::reviewed::public_model_identifier_ignore_case(id)
|
||||
}
|
||||
|
||||
impl Event {
|
||||
|
||||
@@ -57,9 +57,9 @@ pub use decision::{
|
||||
};
|
||||
pub use envelope::reduce_panic_site;
|
||||
pub use event::{
|
||||
Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
|
||||
Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters, SCHEMA_VERSION, SessionSource,
|
||||
Surface, TurnWall,
|
||||
Arch, Batch, ColdStartBucket, ContextSize, Counters, DurationBucket, Errors, Event, ExitClass,
|
||||
InstallAge, InstallKind, Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters,
|
||||
SCHEMA_VERSION, SessionSource, Surface, TurnWall,
|
||||
};
|
||||
|
||||
/// How long the shutdown flush may hold the process.
|
||||
@@ -123,9 +123,18 @@ pub fn init(consent: TelemetryConsent) {
|
||||
exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
|
||||
});
|
||||
|
||||
envelope::stamp_first_seen(&root, envelope::utc_today());
|
||||
record_install_or_upgrade(&root);
|
||||
}
|
||||
|
||||
/// The coarse install-age bucket for this process's `session_start`, or `None`
|
||||
/// when unarmed or when the local state cannot be read.
|
||||
#[must_use]
|
||||
pub fn install_age() -> Option<InstallAge> {
|
||||
let armed = ARMED.get()?;
|
||||
envelope::install_age_at(&armed.root, envelope::utc_today())
|
||||
}
|
||||
|
||||
/// Note that this binary's version differs from the one last seen on this
|
||||
/// machine, at most once per version.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,9 @@ Codewhale counts: which version you run, OS and CPU family, session
|
||||
duration and outcome, and aggregate feature and error counters.
|
||||
|
||||
It also counts the providers and public catalog models you use, their
|
||||
token totals, and how often each built-in tool runs.
|
||||
token totals, request counts, time spent waiting on the model and
|
||||
prompt-size ranges, how often each built-in tool runs, how an exec
|
||||
session was started, and a coarse install-age range.
|
||||
|
||||
It never collects your conversations, code, prompts, files, repo or
|
||||
branch names, model content, or credentials — and it never sends a
|
||||
@@ -41,7 +43,7 @@ Usage reporting is on by default. Codewhale and PostHog process these
|
||||
counts when delivery is configured. No IP is collected.";
|
||||
|
||||
/// Concise disclosure for every armed runtime surface, including headless CLI.
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token/request/wait-time/prompt-size, exec-start, install-age range and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
|
||||
/// Present the policy once per revision without recording human acceptance.
|
||||
/// Failure to save the display marker only causes a later repeat disclosure.
|
||||
|
||||
+322
-11
@@ -121,11 +121,12 @@ fn every_event() -> Vec<Event> {
|
||||
previous_version: Some("0.9.3-rc.1".to_string()),
|
||||
},
|
||||
Event::SessionStart {
|
||||
source: SessionSource::Resume,
|
||||
source: SessionSource::FleetWorker,
|
||||
install_age: Some(InstallAge::UnderWeek),
|
||||
},
|
||||
Event::SessionEnd {
|
||||
duration_bucket: DurationBucket::OneToTen,
|
||||
exit_class: ExitClass::Panic,
|
||||
exit_class: ExitClass::Unclean,
|
||||
cold_start_bucket: Some(ColdStartBucket::Mid),
|
||||
providers: vec!["custom".to_string(), "deepseek".to_string()],
|
||||
counters: Counters {
|
||||
@@ -183,6 +184,9 @@ fn every_event() -> Vec<Event> {
|
||||
output_tokens: 9_120,
|
||||
cache_read_tokens: 380_001,
|
||||
cache_write_tokens: 0,
|
||||
requests: 27,
|
||||
reasoning_tokens: 2_310,
|
||||
generation_secs: 412,
|
||||
},
|
||||
turn_wall: TurnWall {
|
||||
lt_5s: 3,
|
||||
@@ -190,6 +194,12 @@ fn every_event() -> Vec<Event> {
|
||||
thirty_to_onetwenty: 2,
|
||||
gte_120s: 0,
|
||||
},
|
||||
context: ContextSize {
|
||||
lt_32k: 9,
|
||||
k32_to_k128: 11,
|
||||
k128_to_k256: 5,
|
||||
gte_256k: 2,
|
||||
},
|
||||
},
|
||||
Event::ToolUsage {
|
||||
tool: "read_file".to_string(),
|
||||
@@ -269,6 +279,7 @@ fn closed_enum_values() -> Vec<String> {
|
||||
values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallAge::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
@@ -1226,6 +1237,7 @@ fn probe_threshold_cannot_hide_an_over_cap_buffer() {
|
||||
// only safe if `MAX_EVENTS` lines cannot fit under it.
|
||||
let shortest = serde_json::to_string(&Event::SessionStart {
|
||||
source: SessionSource::Api,
|
||||
install_age: None,
|
||||
})
|
||||
.expect("serialize");
|
||||
let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
|
||||
@@ -1446,6 +1458,7 @@ fn record_blocking_is_a_noop_when_unarmed() {
|
||||
});
|
||||
crate::record(Event::SessionStart {
|
||||
source: SessionSource::Interactive,
|
||||
install_age: None,
|
||||
});
|
||||
crate::set_exit_class(ExitClass::Panic);
|
||||
assert_eq!(crate::exit_class(), ExitClass::Clean);
|
||||
@@ -1659,6 +1672,7 @@ fn no_public_api_accepts_a_bare_bool() {
|
||||
|
||||
const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
|
||||
const GOLDEN_V4: &str = include_str!("../tests/golden/v4.json");
|
||||
const GOLDEN_V6: &str = include_str!("../tests/golden/v6.json");
|
||||
|
||||
/// Extract the fenced ```jsonc blocks from the schema doc, in order.
|
||||
fn jsonc_blocks(doc: &str) -> Vec<String> {
|
||||
@@ -1864,13 +1878,12 @@ fn event_field_names_match_documented_schema() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_payload_v4() {
|
||||
fn golden_payload_v6() {
|
||||
assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
|
||||
// `crates/telemetry/tests/golden/v4.json` is one fully-populated instance of
|
||||
// the envelope and every event. Any field add, remove, or retype fails here
|
||||
// until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
|
||||
// is also the artifact a future receiver author reads to know exactly what
|
||||
// v1 was.
|
||||
// `crates/telemetry/tests/golden/v6.json` is one fully-populated instance of
|
||||
// the envelope and every event, and the batch the collector's own tests
|
||||
// post. Any field add, remove, or retype fails here until the developer
|
||||
// re-blesses it under a bumped `SCHEMA_VERSION`.
|
||||
//
|
||||
// Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
|
||||
let batch = every_field_batch();
|
||||
@@ -1878,22 +1891,81 @@ fn golden_payload_v4() {
|
||||
batch.schema_version, SCHEMA_VERSION,
|
||||
"the fixture must be built at the current schema version"
|
||||
);
|
||||
assert_eq!(SCHEMA_VERSION, 6);
|
||||
let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
|
||||
rendered.push('\n');
|
||||
|
||||
if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v4.json");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v6.json");
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
|
||||
std::fs::write(&path, &rendered).expect("write golden");
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rendered, GOLDEN_V4,
|
||||
"the v4 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
rendered, GOLDEN_V6,
|
||||
"the v6 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_4_golden_still_reads_back_with_the_new_fields_defaulted() {
|
||||
let legacy: Batch = serde_json::from_str(GOLDEN_V4).expect("v4 golden parses");
|
||||
assert_eq!(legacy.schema_version, 4);
|
||||
for event in &legacy.events {
|
||||
match event {
|
||||
Event::SessionStart { install_age, .. } => assert_eq!(*install_age, None),
|
||||
Event::ModelUsage {
|
||||
counters, context, ..
|
||||
} => {
|
||||
assert_eq!(counters.requests, 0);
|
||||
assert_eq!(counters.reasoning_tokens, 0);
|
||||
assert_eq!(counters.generation_secs, 0);
|
||||
assert_eq!(*context, ContextSize::default());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_buckets_are_half_open_at_32k_128k_and_256k() {
|
||||
let mut size = ContextSize::default();
|
||||
for tokens in [
|
||||
0,
|
||||
32_767,
|
||||
32_768,
|
||||
131_071,
|
||||
131_072,
|
||||
262_143,
|
||||
262_144,
|
||||
u64::MAX,
|
||||
] {
|
||||
size.observe(tokens);
|
||||
}
|
||||
assert_eq!(
|
||||
size,
|
||||
ContextSize {
|
||||
lt_32k: 2,
|
||||
k32_to_k128: 2,
|
||||
k128_to_k256: 2,
|
||||
gte_256k: 2,
|
||||
}
|
||||
);
|
||||
let json = serde_json::to_value(size).expect("serialize");
|
||||
let keys: Vec<&str> = json
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut expected: Vec<&str> = ContextSize::FIELDS.to_vec();
|
||||
expected.sort_unstable();
|
||||
let mut got = keys;
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_comparison_names_install_upgrade_and_downgrade() {
|
||||
assert!(crate::version_is_older("0.9.3", "0.9.4"));
|
||||
@@ -1994,3 +2066,242 @@ fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
|
||||
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(body.contains("docs/TELEMETRY.md"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- model naming --
|
||||
|
||||
fn model_id_is_collector_shaped(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
(1..=128).contains(&bytes.len())
|
||||
&& bytes[0].is_ascii_lowercase() | bytes[0].is_ascii_digit()
|
||||
&& bytes[1..].iter().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| matches!(b, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_resolves_aliases_and_spellings() {
|
||||
assert_eq!(reported_model_id("deepseek-v4-pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id(" DeepSeek-V4-Pro "), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("deepseek-v4pro"), "deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek-ai/deepseek-v4-pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek/deepseek-v4pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(reported_model_id("pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("flash"), "deepseek-flash");
|
||||
assert_eq!(reported_model_id("auto"), "other");
|
||||
assert_eq!(reported_model_id("my-private-finetune"), "other");
|
||||
assert_eq!(reported_model_id(""), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_never_leaves_the_catalog() {
|
||||
use codewhale_config::catalog::reviewed::{
|
||||
bundled_reviewed, public_model_identifier_ignore_case,
|
||||
};
|
||||
let reviewed = bundled_reviewed();
|
||||
let mut spellings: Vec<&str> = reviewed
|
||||
.official_deepseek_aliases
|
||||
.keys()
|
||||
.chain(reviewed.go_aliases.keys())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if let Some(group) = reviewed.compatibility_aliases.get("canonical_model_name") {
|
||||
spellings.extend(group.keys().map(String::as_str));
|
||||
}
|
||||
assert!(spellings.len() > 20, "alias tables look empty");
|
||||
for spelling in spellings {
|
||||
let reported = reported_model_id(spelling);
|
||||
assert!(is_reported_model_id(&reported), "{spelling} -> {reported}");
|
||||
assert!(
|
||||
model_id_is_collector_shaped(&reported),
|
||||
"{spelling} -> {reported}"
|
||||
);
|
||||
}
|
||||
for hostile in ["", "x/../y", "deepseek-v4-pro\n", "Ünï", &"a".repeat(300)] {
|
||||
let reported = reported_model_id(hostile);
|
||||
assert!(
|
||||
reported == "other" || public_model_identifier_ignore_case(&reported),
|
||||
"{hostile:?} -> {reported}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_model_and_provider_default_maps_to_a_catalogued_id() {
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
let mut missing = Vec::new();
|
||||
let mut check = |origin: &str, id: &str| {
|
||||
let reported = reported_model_id(id);
|
||||
if reported == "other" || !model_id_is_collector_shaped(&reported) {
|
||||
missing.push(format!("{origin}: {id}"));
|
||||
}
|
||||
};
|
||||
for provider in catalog.providers.values() {
|
||||
for (id, row) in &provider.models {
|
||||
check("catalog", id);
|
||||
check("catalog row", &row.id);
|
||||
}
|
||||
}
|
||||
for id in catalog.models.keys() {
|
||||
check("canonical", id);
|
||||
}
|
||||
|
||||
let descriptors: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../config/assets/provider_descriptors.json"),
|
||||
)
|
||||
.expect("provider descriptors"),
|
||||
)
|
||||
.expect("descriptors json");
|
||||
let exceptions: &[&str] = &["antigravity", "custom", "openai-compatible", "ollama"];
|
||||
for provider in descriptors["providers"].as_array().expect("providers") {
|
||||
let id = provider["id"].as_str().expect("provider id");
|
||||
let selectable = provider["selectable"].as_bool().unwrap_or(false);
|
||||
let default = provider["default_model"].as_str().unwrap_or_default();
|
||||
if selectable && !default.is_empty() && !exceptions.contains(&id) {
|
||||
check(&format!("default for {id}"), default);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"ids the catalog does not name:\n{}",
|
||||
missing.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- install age --
|
||||
|
||||
fn day(text: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d").expect("date")
|
||||
}
|
||||
|
||||
fn root_with_state(state: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
std::fs::write(buffer::state_path(&root), state).expect("write state");
|
||||
(home, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_home_is_new_today_and_ages_by_utc_day() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
for (today, expected) in [
|
||||
("2026-11-13", InstallAge::NewToday),
|
||||
("2026-11-14", InstallAge::UnderWeek),
|
||||
("2026-11-16", InstallAge::UnderWeek),
|
||||
("2026-11-19", InstallAge::UnderWeek),
|
||||
("2026-11-20", InstallAge::Older),
|
||||
("2027-06-01", InstallAge::Older),
|
||||
("2026-11-12", InstallAge::NewToday),
|
||||
] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(expected),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
envelope::stamp_first_seen(&root, day("2026-12-25"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13"),
|
||||
"a second stamp never moves the first-seen day"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_written_before_the_day_existed_reads_older_never_new() {
|
||||
let (_home, root) = root_with_state(r#"{"schema_version":4,"last_version":"0.10.2"}"#);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
let state = envelope::read_state(&root);
|
||||
assert_eq!(state.first_seen_day, None);
|
||||
assert!(state.first_seen_unknown);
|
||||
assert_eq!(state.last_version.as_deref(), Some("0.10.2"));
|
||||
for today in ["2026-11-13", "2026-11-14", "2030-01-01"] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(InstallAge::Older),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_or_hostile_state_gives_null_and_is_not_overwritten() {
|
||||
for body in [
|
||||
"not json",
|
||||
r#"{"first_seen_day":"9999-99-99"}"#,
|
||||
r#"{"first_seen_day":"yesterday"}"#,
|
||||
] {
|
||||
let (_home, root) = root_with_state(body);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
None,
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(buffer::state_path(&root)).expect("state"),
|
||||
body,
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_erases_the_age_and_rotating_the_install_id_keeps_it() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
envelope::stamp_first_seen(&root, day("2026-01-01"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::Older)
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
buffer::install_id_path(&root),
|
||||
r#"{"schema_version":1,"install_id":"3f2a9c1e-0000-4000-8000-000000000001","rotated_at":"2020-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.expect("write expired id");
|
||||
let rotated = envelope::read_or_create_install_id(&root).expect("rotate");
|
||||
assert_ne!(rotated.install_id, "3f2a9c1e-0000-4000-8000-000000000001");
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-01-01"),
|
||||
"rotation does not touch the age"
|
||||
);
|
||||
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
std::fs::remove_file(buffer::tombstone_path(&root)).expect("re-enable");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_seen_day_never_reaches_the_wire() {
|
||||
let wire = serde_json::to_string(&every_field_batch()).expect("serialize");
|
||||
assert!(!wire.contains("first_seen"));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z",
|
||||
"install_id": "3f2a9c1e-0000-4000-8000-000000000001",
|
||||
"app_version": "0.9.4",
|
||||
"git_sha": "abcdef012345",
|
||||
"surface": "control-plane",
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"libc": "none",
|
||||
"tty": true,
|
||||
"events": [
|
||||
{
|
||||
"event": "install_or_upgrade",
|
||||
"kind": "upgrade",
|
||||
"previous_version": "0.9.3-rc.1"
|
||||
},
|
||||
{
|
||||
"event": "session_start",
|
||||
"source": "fleet_worker",
|
||||
"install_age": "under_week"
|
||||
},
|
||||
{
|
||||
"event": "session_end",
|
||||
"duration_bucket": "1m_10m",
|
||||
"exit_class": "unclean",
|
||||
"cold_start_bucket": "250_1000",
|
||||
"providers": [
|
||||
"custom",
|
||||
"deepseek"
|
||||
],
|
||||
"counters": {
|
||||
"turns": 14,
|
||||
"tool_calls": 61,
|
||||
"fleet_dispatch": 0,
|
||||
"workflow_run": 0,
|
||||
"subagent_spawn": 2,
|
||||
"mcp_server_connected": 0,
|
||||
"memory_search": 0,
|
||||
"approval_modal_shown": 0,
|
||||
"approval_auto_allowed": 0,
|
||||
"command_palette_open": 3
|
||||
},
|
||||
"errors": {
|
||||
"auth_preflight_failed": 0,
|
||||
"provider_http_4xx": 0,
|
||||
"provider_http_5xx": 1,
|
||||
"tool_denied_by_policy": 0,
|
||||
"tool_timeout": 0,
|
||||
"network_error": 0
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 9,
|
||||
"5_30s": 4,
|
||||
"30_120s": 1,
|
||||
"gte_120s": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "panic",
|
||||
"site": "crates/tui/src/tui/ui.rs:8801:17"
|
||||
},
|
||||
{
|
||||
"event": "product_usage",
|
||||
"counters": {
|
||||
"page_view": 1,
|
||||
"docs_view": 0,
|
||||
"install_copy": 0,
|
||||
"download": 0,
|
||||
"signup": 0,
|
||||
"login": 0,
|
||||
"session_create": 0,
|
||||
"session_resume": 0,
|
||||
"turn_submit": 0,
|
||||
"turn_complete": 0,
|
||||
"settings_open": 0,
|
||||
"integration_connect": 0,
|
||||
"error_shown": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "operations_summary",
|
||||
"requests": 10,
|
||||
"errors": 1,
|
||||
"duration_ms_total": 1500,
|
||||
"duration_ms_max": 300,
|
||||
"probes": 2,
|
||||
"probes_failed": 0
|
||||
},
|
||||
{
|
||||
"event": "model_usage",
|
||||
"provider": "deepseek",
|
||||
"model": "other",
|
||||
"counters": {
|
||||
"turns_ok": 12,
|
||||
"turns_failed": 1,
|
||||
"turns_interrupted": 0,
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 3,
|
||||
"5_30s": 8,
|
||||
"30_120s": 2,
|
||||
"gte_120s": 0
|
||||
},
|
||||
"context": {
|
||||
"lt_32k": 9,
|
||||
"32k_128k": 11,
|
||||
"128k_256k": 5,
|
||||
"gte_256k": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "tool_usage",
|
||||
"tool": "read_file",
|
||||
"calls": 41,
|
||||
"errors": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
+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"]) {
|
||||
|
||||
+34
-2
@@ -2515,7 +2515,10 @@ fn arm_telemetry_with_setup(
|
||||
};
|
||||
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
|
||||
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
|
||||
source,
|
||||
install_age: codewhale_telemetry::install_age(),
|
||||
});
|
||||
}
|
||||
|
||||
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
@@ -3216,6 +3219,7 @@ async fn run_async_main_dispatch(
|
||||
workspace,
|
||||
skills_dir,
|
||||
skills_config,
|
||||
config.tool_launcher.is_some(),
|
||||
)
|
||||
.await
|
||||
} else if http_selected {
|
||||
@@ -7867,7 +7871,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 +16562,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 +16614,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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -2693,7 +2693,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
audit logging is enabled. Future PreToolUse/PostToolUse hooks can add
|
||||
observer input around this layer, but the configured auto-review policy is
|
||||
evaluated before a tool call is allowed to proceed.
|
||||
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
|
||||
- `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
|
||||
|
||||
+59
-19
@@ -4,7 +4,7 @@
|
||||
|
||||
**Usage analytics are on by default in the current source.** The first
|
||||
interactive launch gives a localized, nonblocking disclosure naming **Codewhale
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `6`
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `8`
|
||||
describes this policy; displaying it is not recorded as human acceptance.
|
||||
Missing an older acceptance record does not turn a fresh installation off.
|
||||
Existing explicit opt-outs remain off across upgrades, and unreadable privacy
|
||||
@@ -65,7 +65,7 @@ codewhale --telemetry false # the same kill switch, for one command
|
||||
**`telemetry = false` in the config file is the opt-out.** It is a floor:
|
||||
`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
|
||||
setting you can undo by accident from a wrapper script is not a setting. It
|
||||
deletes the random install id, truncates every buffered event and every dry-run
|
||||
deletes the random install id and the local first-seen day (so the install-age bucket starts over at `new_today` if you turn telemetry back on), truncates every buffered event and every dry-run
|
||||
record, and writes a tombstone. Appends, identity/state writes, and delivery all
|
||||
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
|
||||
POST remains in flight. If any part of that wipe fails, the tombstone is still
|
||||
@@ -104,7 +104,7 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
|
||||
| `buffer.jsonl` | pending events, one JSON object per line |
|
||||
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
|
||||
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
|
||||
| `state.json` | the last app version seen and the last flush attempt |
|
||||
| `state.json` | the last app version seen, the last flush attempt, and the UTC day this state was first created. The date never leaves the device: only the `install_age` bucket is sent |
|
||||
| `install_id.json` | the random install id and when it was minted |
|
||||
| `disabled` | the tombstone; present means nothing is appended or sent |
|
||||
|
||||
@@ -162,10 +162,14 @@ most once per flush point and never grows a queue.
|
||||
|
||||
## Event schema
|
||||
|
||||
`SCHEMA_VERSION = 4`. `notice_version = 6` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 4
|
||||
adds the `model_usage` and `tool_usage` events and the `ci` session source.
|
||||
Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
`SCHEMA_VERSION = 6`. `notice_version = 8` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 6
|
||||
adds, to `model_usage`, the request count, reasoning tokens, whole seconds spent
|
||||
waiting on the model and a four-bucket input-size histogram; adds a coarse
|
||||
install age and three exec start labels to `session_start`; and adds the
|
||||
`unclean` exit class. Schema 4 added the `model_usage` and `tool_usage` events
|
||||
and the `ci` session source. Schema-4 and schema-3 batches are still accepted
|
||||
without the schema-6 fields. Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
is what the web and desktop control-plane relay sends. The closed
|
||||
v2 contract still accepts only `consent_version = 4`, retaining its original
|
||||
explicit opt-in meaning. Fields cannot be mixed between versions. The unchanged
|
||||
@@ -178,8 +182,8 @@ new fields, surfaces, and `product_usage` events.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 4,
|
||||
"notice_version": 6,
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
|
||||
"install_id": "3f2a…", // uuid v4, rotates every 90 days
|
||||
"app_version": "0.9.12",
|
||||
@@ -196,7 +200,7 @@ new fields, surfaces, and `product_usage` events.
|
||||
| Field | Type | Source anchor | Rule |
|
||||
|---|---|---|---|
|
||||
| `schema_version` | `u32` | const in `crates/telemetry/src/event.rs` | Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `6` for schema 4; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `8` for schema 6; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
|
||||
| `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
|
||||
| `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/telemetry/src/lib.rs:112` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
|
||||
@@ -284,14 +288,27 @@ Emitted once when `state.json`'s `last_version` differs from `app_version`.
|
||||
### Event: `session_start`
|
||||
|
||||
```jsonc
|
||||
{ "event": "session_start", "source": "interactive" }
|
||||
{ "event": "session_start", "source": "interactive", "install_age": "older" }
|
||||
```
|
||||
|
||||
`source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown | ci`.
|
||||
`source` is `SessionSource` (`crates/telemetry/src/event.rs`), a closed enum declared in the telemetry crate: `interactive | resume | fork | api | unknown | ci | fleet_worker | agent | scripted`.
|
||||
`ci` replaces the others whenever `CI` or `GITHUB_ACTIONS` is set to a true
|
||||
value, including the Codewhale GitHub Action, so automated runs are never
|
||||
counted as people. Only that boolean environment fact is read.
|
||||
|
||||
`fleet_worker`, `agent` and `scripted` separate automated `exec` sessions from
|
||||
people. `fleet_worker` is an `exec` that Codewhale's own fleet dispatcher
|
||||
started, `agent` is an `exec` started from inside an agent's shell command (only
|
||||
the presence of a Codewhale-owned environment variable is read, never its
|
||||
value), and `scripted` is an `exec` whose standard input or output is not a
|
||||
terminal. When more than one applies the order is `fleet_worker`, `agent`, `ci`,
|
||||
`scripted`. Other surfaces are unchanged.
|
||||
|
||||
`install_age` is `new_today | under_week | older | null`: a coarse bucket of how
|
||||
long this installation has existed. It is computed on your device and only the
|
||||
bucket leaves it; the date it comes from is stored only in the local state file
|
||||
and never leaves the device. The same UTC day is `new_today`, 1 to 6 days is `under_week`, and 7 or more is `older`. An installation whose state predates this field reports `older`, never `new_today`. `null` means the local state could not be read.
|
||||
|
||||
### Event: `session_end`
|
||||
|
||||
The workhorse. Everything a session accumulated ships here, once.
|
||||
@@ -317,11 +334,11 @@ The workhorse. Everything a session accumulated ships here, once.
|
||||
|
||||
**`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1889`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
|
||||
|
||||
**`exit_class`** — `clean | signal | panic | error`. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
**`exit_class`** — `clean | signal | panic | error | unclean`. `unclean` means the process ended without its own shutdown report and its totals were recovered from a local checkpoint at a later start. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
|
||||
**`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/runtime/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
|
||||
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id outside the bundled public catalog is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential. Those are sent as `other`; see `model_usage`.
|
||||
|
||||
**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
|
||||
|
||||
@@ -443,9 +460,13 @@ to make defaults, and whether prompt caching is working.
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 }
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 },
|
||||
"context": { "lt_32k": 9, "32k_128k": 11, "128k_256k": 5, "gte_256k": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -458,6 +479,25 @@ cache reads. Turn counts are by outcome, and `turn_wall` buckets each turn's
|
||||
wall time as `session_end` does. A session reports at most 16 provider and model
|
||||
pairs; later pairs are counted under `other`. No per-turn timeline is sent.
|
||||
|
||||
Schema 6 adds four aggregates to each row:
|
||||
|
||||
- `requests` counts the model requests this session made on its own route for
|
||||
that provider and model.
|
||||
- `reasoning_tokens` is the part of `output_tokens` the provider reports as
|
||||
reasoning. `output_tokens` is not reduced, and the value is `0` when the
|
||||
provider does not report it.
|
||||
- `generation_secs` is the total whole seconds the session spent waiting on the
|
||||
model for those requests. It is one sum for the session; no per-request
|
||||
duration is ever sent.
|
||||
- `context` counts requests by input size, in tokens including cache reads:
|
||||
`lt_32k` is under 32768, `32k_128k` is 32768 up to 131072, `128k_256k` is
|
||||
131072 up to 262144, and `gte_256k` is 262144 or more. The four buckets add up
|
||||
to `requests`. Each edge belongs to the higher bucket. No per-request size is
|
||||
sent.
|
||||
|
||||
Until a client fills them these values are `0`. A schema-4 row carries none of
|
||||
these fields.
|
||||
|
||||
### Event: `tool_usage`
|
||||
|
||||
One event per tool a session called, sent with `session_end`. It is how
|
||||
@@ -508,7 +548,7 @@ configured ingest may forward validated schema-v3 / notice-v5 batches and
|
||||
legacy schema-v2 / consent-v4 batches to
|
||||
PostHog's [batch capture API](https://posthog.com/docs/api/capture). The only
|
||||
permitted origins are `https://us.i.posthog.com` and `https://eu.i.posthog.com`.
|
||||
Schema-v1 and schema-v4 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
Schema-v1, schema-v4 and schema-v6 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
envelope and event fields, the batch timestamp as its event timestamp, and
|
||||
`codewhale:<install_id>` as its anonymous `distinct_id`. Events are named
|
||||
`codewhale_install_or_upgrade`, `codewhale_session_start`,
|
||||
@@ -558,7 +598,7 @@ It prints the daily series, a 7-day trend over complete UTC days, the freshness
|
||||
|
||||
### What is never collected — the public red-line list
|
||||
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; per-request durations or sizes; environment variable values; CI vendor or job names; parent process names; hostnames; container ids; the date an installation was first seen (only the install-age bucket leaves the device); keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
|
||||
Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha`, `git_branch`, `git_origin_url`, `cwd`, and `path` on the threads table (`:93, :399, :653`): a payload builder that accepts a `Thread` or `ThreadMeta` and derives `Serialize` breaches the contract in one line. **Never derive `Serialize` over an existing state type** — build every telemetry struct from scratch with explicit fields. And `crates/core/src/lib.rs:1389-1398` is the one place in the tree where the word `telemetry` sits inside a JSON object next to `prompt`, `base_url`, and `has_api_key`. It is the object someone will copy. Do not.
|
||||
|
||||
@@ -567,7 +607,7 @@ Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha
|
||||
## Website funnel aggregates in the source candidate
|
||||
|
||||
The website extension uses `FUNNEL_SCHEMA_VERSION = 5` and notice version `7`.
|
||||
The Rust runtime remains on `SCHEMA_VERSION = 4`, notice `6`; existing browser
|
||||
The Rust runtime is on `SCHEMA_VERSION = 6`, notice `8`; existing browser
|
||||
schema `3`, notice `5`, and legacy schemas `1` and `2` retain their original
|
||||
contracts. Displaying a newer notice never overrides an opt-out or establishes
|
||||
human acceptance. Schema 5 is first-party only and does not activate PostHog.
|
||||
|
||||
+7
-7
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -249,8 +249,13 @@ DSH 的 `allow` 被视为不干预,每个所有者提示一次;不能预先
|
||||
和审批检查。理由及上下文沿用现有清理与大小限制。
|
||||
|
||||
该桥接作用于 Engine 会话的模型工具、受 gate 管理的 code-mode 调用及 `exec.core`
|
||||
调用。独立 ACP 执行路径没有 TypeScript 宿主 attachment。`prepend`/`global` 顺序
|
||||
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。
|
||||
调用。编辑器通过 ACP(`codewhale serve --acp`)驱动的会话获得同一个扩展宿主,并受相同的
|
||||
`[features]` 开关(`extension_host`、`mcp`)约束:已审查的插件工具(TypeScript 与 DSH)和
|
||||
Codewhale 中配置的 MCP 服务器会作为 ACP 工具调用出现,并通过会话的权限设置询问;Plan 保持只读。
|
||||
ACP 服务器的能力声明会说明这一点(`agentCapabilities._meta.codewhale`)。ACP 客户端在
|
||||
`mcpServers` 中传入的服务器和已配置的 shell hooks 不会挂载。`prepend`/`global` 顺序
|
||||
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。Native
|
||||
`tool_call_after` 仍是只观察的契约。
|
||||
|
||||
## 提示词片段
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
> 英文原文:[HOOKS.md](../HOOKS.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-29。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件。`config.toml` 或项目 `hooks.toml` 里的 hook 是 **Hook** 类插件;插件包也可以携带 hook。
|
||||
hook 仍在终端的 Extensions → Hooks 和桌面端的 Customize › Hooks 管理;把所有种类放在一个插件列表里是
|
||||
0.10.3 的计划,当前版本尚无。配置和 `/hooks` 命令都不变。
|
||||
|
||||
Hooks 会在 Codewhale **TUI** 到达生命周期节点时运行一条 shell 命令。它们是普通进程:通过环境变量接收上下文,其中一些会在 stdin 上收到 JSON 载荷,还有三个可以引导 Codewhale 接下来做什么。
|
||||
|
||||
本页是当前已实现内容的权威参考。与 `config.toml` 其余部分重叠的配置语法见 [CONFIGURATION.md](CONFIGURATION.md);本文件是逐事件的约定说明。
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
在终端中,`/mcp`(也可用 `/mcps`)会打开 **Extensions → MCP**。按 Enter 会对所选服务器执行恢复操作,或打开只读详情。清单为空时会提供服务器建议;浏览这些建议不会安装任何内容。`/mcp status`、`/mcp doctor`、`/mcp login`、`/mcp add` 等显式子命令的行为保持不变。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件。你配置的 MCP 服务器是 **Connector**(连接器)类插件;插件包也可以携带 connector。
|
||||
connector 仍在终端的 Extensions → MCP 和桌面端的 Customize › Connectors 管理;把所有种类放在一个插件列表里是
|
||||
0.10.3 的计划,当前版本尚无。配置文件、登录和 `/mcp` 命令都不变。
|
||||
本页的“连接”(connection)指到服务器的实际链路;作为产品名词,Connection 专指模型提供方或另一个 agent。
|
||||
|
||||
Codewhale 可以通过 MCP(Model Context Protocol,模型上下文协议)加载额外工具。MCP 服务器可以是 Codewhale 启动的本地 stdio 进程,也可以是使用 Streamable HTTP(带传统 SSE 回退)的远程基于 URL 的服务器。
|
||||
|
||||
浏览说明:
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
> 英文原文:[PLUGINS.md](../PLUGINS.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-26。
|
||||
> 2026-11-07 定点更正:版本范围、目录与固定来源、信任确认、本地更新;未做全文重译。
|
||||
> 2026-11-12 定点更正:一律称“插件”;未做全文重译。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件。skill、MCP connector(连接器)和 hook 各自是一个插件,
|
||||
或是插件的一部分,并各有一个种类:Skill、Connector、Hook、Command。你手动设置的 skill 是 Skill 类插件,
|
||||
你配置的服务器是 Connector 类插件,你配置的 hook 是 Hook 类插件。它们仍在原处管理:终端的
|
||||
Extensions → Skills、MCP、Hooks,桌面端的 Customize › Skills、Connectors、Hooks;文件、
|
||||
信任规则和命令(`/skills`、`/mcp`、`/hooks`)都不变。把所有种类放在一个插件列表里是 0.10.3 的计划,
|
||||
当前版本尚无;目前种类标签只出现在插件包详情里的各个部分上。“Connection”(连接)一词专指模型提供方或
|
||||
另一个 agent,不是插件的一种。
|
||||
|
||||
想做一个插件包,先从[编写你的第一个 Codewhale 插件](./PLUGIN_AUTHORING.md)
|
||||
和里面可运行的 Skills 示例入手。
|
||||
|
||||
@@ -652,13 +652,15 @@ Codewhale 0.10.2 的 ACP v1 支持不完整。`session/new` 和 `session/load`
|
||||
会在创建、恢复或返回缓存会话之前拒绝非空 `mcpServers`,返回 `-32602`。
|
||||
该字段可省略或为 `[]`;null 与其他非数组值无效。验证不会执行所提供的命令。
|
||||
[ACP v1 要求 stdio MCP 支持](https://agentclientprotocol.com/protocol/v1/session-setup#mcp-servers);HTTP/SSE 能力为 false 并不代表完整合规。
|
||||
需要 MCP 的集成请用完整 Runtime API。下列工具限制来自 Codewhale host profile,
|
||||
需要这类集成请用完整 Runtime API 及其现有 MCP 管理。下列工具限制来自 Codewhale host profile,
|
||||
不是 ACP 协议本身的限制。
|
||||
|
||||
受信任的本地 ACP profile 将 Core 收窄到文件/搜索/git/patch,以及获准的前台
|
||||
shell 工具。Shell 同时要求编辑器声明 terminal 支持、操作者允许 `allow_shell`;
|
||||
指定的外部沙箱不可用时不提供 shell。此接口不提供 MCP、动态工具、任务、PTY、
|
||||
后台 shell、解释器、子智能体或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
|
||||
shell 工具,外加已审查的插件工具(`extension_host` 功能)和 Codewhale 中配置的
|
||||
MCP 服务器的工具(`mcp` 功能),均受同一权限设置约束;这一点见于 0.10.2 之后的 main,0.10.2 本身不含。Shell 同时要求编辑器声明
|
||||
terminal 支持、操作者允许 `allow_shell`;指定的外部沙箱不可用时不提供 shell。此接口
|
||||
不提供客户端传入的 MCP 服务器、动态工具、任务、PTY、后台 shell、解释器、子智能体
|
||||
或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
|
||||
最终派发再次校验 profile;伪造别名、hook 改写或目录中缺失的工具不能绕过限制。
|
||||
Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端拥有的只读选项,
|
||||
编辑器不能放宽。`approval_policy = "never"` 不是 ACP 审批姿态,服务端会拒绝
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
在终端中,`/skills` 会打开 **Extensions → Skills**。在某个 skill 上按 Enter 会打开专用管理器(也可直接用 `/skills manage` 打开),用于现有 skill 的安装、更新、移除和信任操作。`/skill <name>` 仍然用于激活 skill,显式的检查、远程、同步和建议子命令也仍然可用。
|
||||
|
||||
你添加到 Codewhale 的一切都叫插件:你手动设置的 skill 是 **Skill** 类插件;
|
||||
插件包里的 skill 属于该插件。skill 仍在 Extensions → Skills 和 Customize › Skills 管理;把所有种类放在一个
|
||||
插件列表里是 0.10.3 的计划,当前版本尚无。本页用“skill”指这一种类;文件、信任和 `/skills` 命令都不变。
|
||||
|
||||
Skills 是可复用的 `SKILL.md` 指令包。Codewhale 从多个根目录发现它们,但**只有 Codewhale 拥有的目录可写**。统一的 `/skills manage` 管理器是审计与变更的交互界面;斜杠别名共享同一条写入路径。
|
||||
|
||||
关于 Claude Code 插件边界,参见 [CLAUDE_PLUGIN_COMPAT.md](./CLAUDE_PLUGIN_COMPAT.md)。
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"crates/app-server-protocol/src/bin/export.rs": {
|
||||
"std_fs": 3
|
||||
},
|
||||
"crates/app-server/src/daemon_client.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
"crates/app-server/src/mcp_server.rs": {
|
||||
"std_fs": 1
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ live in the commit bodies, so no network call is needed.
|
||||
Usage: scripts/check-contributor-credit.py [<since-rev>]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re, subprocess, sys
|
||||
import functools, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -35,6 +35,7 @@ def sh(*args: str) -> str:
|
||||
return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=True).stdout
|
||||
|
||||
|
||||
@functools.cache
|
||||
def author_map() -> dict[str, str]:
|
||||
"""alias -> github handle, from .github/AUTHOR_MAP."""
|
||||
out: dict[str, str] = {}
|
||||
@@ -52,19 +53,43 @@ def author_map() -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
NOREPLY = re.compile(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com")
|
||||
|
||||
|
||||
def not_a_person(name: str, email: str) -> bool:
|
||||
low, addr = name.strip().lower(), email.lower()
|
||||
return any(low.startswith(skip) for skip in SKIP) or "[bot]" in addr \
|
||||
or addr.endswith(("[email protected]", "[email protected]"))
|
||||
|
||||
|
||||
def maintainer(name: str, email: str) -> bool:
|
||||
handle = author_map().get(email.lower()) or author_map().get(name.strip().lower()) or name.strip()
|
||||
return any(h.lower().startswith(skip) for skip in SKIP for h in (name.strip(), handle))
|
||||
|
||||
|
||||
def restoring(subject: str, name: str, email: str) -> bool:
|
||||
return subject.startswith("chore(credit):") and maintainer(name, email)
|
||||
|
||||
|
||||
def noreply_ids() -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
path = ROOT / ".github" / "AUTHOR_MAP"
|
||||
if path.exists():
|
||||
for login_id, login in re.findall(r"(\d+)\+([^@<>\s]+)@users\.noreply\.github\.com", path.read_text()):
|
||||
out[login.lower()] = login_id
|
||||
return out
|
||||
|
||||
|
||||
def expected(since: str) -> dict[str, str]:
|
||||
"""handle -> why we think they are owed credit."""
|
||||
amap = author_map()
|
||||
found: dict[str, str] = {}
|
||||
|
||||
def add(name: str, email: str, why: str) -> None:
|
||||
low = name.strip().lower()
|
||||
# Model co-authorship ("Claude Opus 5 (1M context)") and bot addresses
|
||||
# are not people owed credit; match on prefix since the model name and
|
||||
# its context window change release to release.
|
||||
if any(low.startswith(skip) for skip in SKIP):
|
||||
return
|
||||
if "[bot]" in email.lower() or email.lower().endswith(("[email protected]", "[email protected]")):
|
||||
if not_a_person(name, email):
|
||||
return
|
||||
handle = amap.get(email.lower()) or amap.get(name.strip().lower())
|
||||
if not handle:
|
||||
@@ -74,16 +99,17 @@ def expected(since: str) -> dict[str, str]:
|
||||
return
|
||||
found.setdefault(handle, why)
|
||||
|
||||
for line in sh("git", "log", f"{since}..HEAD", "--format=%an\t%ae").splitlines():
|
||||
name, _, email = line.partition("\t")
|
||||
add(name, email, "commit author")
|
||||
|
||||
body = sh("git", "log", f"{since}..HEAD", "--format=%b")
|
||||
for name, email in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
|
||||
add(name, email, "co-author trailer")
|
||||
for pr, handle in re.findall(r"(?i)harvested from PR #(\d+) by @([A-Za-z0-9-]+)", body):
|
||||
if handle.lower() not in SKIP:
|
||||
found.setdefault(handle, f"harvested PR #{pr}")
|
||||
log = sh("git", "log", f"{since}..HEAD", "--format=%an%x1f%ae%x1f%s%x1f%b%x00")
|
||||
work = [parts for parts in (entry.lstrip("\n").split("\x1f", 3) for entry in log.split("\0"))
|
||||
if len(parts) == 4 and not restoring(parts[2], parts[0], parts[1])]
|
||||
for author, email, _, _ in work:
|
||||
add(author, email, "commit author")
|
||||
for _, _, _, body in work:
|
||||
for name, addr in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
|
||||
add(name, addr, "co-author trailer")
|
||||
for pr, handle in re.findall(r"(?i)harvested from PR #(\d+) by @([A-Za-z0-9-]+)", body):
|
||||
if handle.lower() not in SKIP:
|
||||
found.setdefault(handle, f"harvested PR #{pr}")
|
||||
return found
|
||||
|
||||
|
||||
@@ -108,17 +134,50 @@ def main() -> int:
|
||||
|
||||
print(f"Contributor credit since {since}: {len(owed)} contributor(s) found.")
|
||||
amap = author_map()
|
||||
ids = noreply_ids()
|
||||
lost = []
|
||||
for entry in sh("git", "log", f"{since}..HEAD", "--format=%h\t%an\t%ae\t%B%x00").split("\0"):
|
||||
parts = entry.strip().split("\t", 3)
|
||||
if len(parts) < 4:
|
||||
broken: list[tuple[str, str]] = []
|
||||
credited: set[str] = set()
|
||||
|
||||
def login(name: str, addr: str) -> str:
|
||||
nm = NOREPLY.search(addr)
|
||||
return (amap.get(addr.lower()) or amap.get(name.strip().lower()) or (nm.group(1) if nm else name.strip())).lower()
|
||||
|
||||
def who(name: str, addr: str) -> str:
|
||||
nm = re.search(r"(\d+)\+([^@<>\s]+)@users\.noreply\.github\.com", addr)
|
||||
if not nm:
|
||||
return amap.get(addr.lower(), "").lower() if "@" in addr else ""
|
||||
known = ids.get(nm.group(2).lower())
|
||||
if known is not None and known != nm.group(1):
|
||||
broken.append((nm.group(2).lower(), f"uses {addr}, but @{nm.group(2)} is id {known} "
|
||||
f"in AUTHOR_MAP; GitHub credits whoever owns id {nm.group(1)}"))
|
||||
return nm.group(2).lower() if known == nm.group(1) else ""
|
||||
|
||||
log = sh("git", "log", f"{since}..HEAD",
|
||||
"--format=%h%x1f%an%x1f%ae%x1f%(trailers:key=Co-authored-by,unfold)%x1f%B%x00")
|
||||
for entry in log.split("\0"):
|
||||
parts = entry.strip().split("\x1f", 4)
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
sha, name, email, body = parts
|
||||
sha, name, email, parsed, body = parts
|
||||
m = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", email)
|
||||
author = (amap.get(email.lower()) or amap.get(name.lower()) or (m.group(1) if m else name)).lower()
|
||||
trailers = re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", parsed)
|
||||
mark = len(broken)
|
||||
credited.add(who(name, email))
|
||||
credited.update(who(n, e) for n, e in trailers)
|
||||
broken[mark:] = [(login, f"{sha} {why}") for login, why in broken[mark:]]
|
||||
for co_name, co_email in re.findall(r"(?im)^\s*co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
|
||||
if (co_name, co_email) not in trailers and not not_a_person(co_name, co_email):
|
||||
broken.append((login(co_name, co_email),
|
||||
f"{sha} has 'Co-authored-by: {co_name}' outside the final trailer block; GitHub ignores it"))
|
||||
if restoring(body.split("\n", 1)[0], name, email):
|
||||
continue
|
||||
for handle in re.findall(r"(?i)harvested from PR #\d+ by @([A-Za-z0-9-]+)", body):
|
||||
if handle.lower() != author:
|
||||
lost.append(f" {sha} harvested @{handle} but is authored by {name}")
|
||||
broken.append((amap.get(handle.lower(), handle).lower(),
|
||||
f"{sha} harvested @{handle} with neither authorship nor a Co-authored-by trailer for them"))
|
||||
if any(author.startswith(skip) for skip in SKIP):
|
||||
for co_name, co_email in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body):
|
||||
cm = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", co_email)
|
||||
@@ -128,8 +187,18 @@ def main() -> int:
|
||||
and "[bot]" not in co_email.lower() and not co_email.lower().endswith(("[email protected]", "[email protected]")):
|
||||
lost.append(f" {sha} credits @{human} only as a co-author; authored by {name}")
|
||||
if lost:
|
||||
print("\nCommits whose human contributor is not the commit author (GitHub's contributor list will miss them):\n")
|
||||
print("\nCommits whose human contributor is not the commit author (GitHub counts them only through a\n"
|
||||
"well-formed Co-authored-by trailer whose address is linked to their account):\n")
|
||||
print("\n".join(lost))
|
||||
broken = [(login, why) for login, why in broken if login.lower() not in credited]
|
||||
if broken:
|
||||
print("\nCredit GitHub cannot see:\n")
|
||||
print("\n".join(f" {why}" for _, why in broken))
|
||||
print("\nPut each contributor in the final trailer block as\n"
|
||||
"'Co-authored-by: Name <[email protected]>', with no blank line between trailers,\n"
|
||||
"using the id from `gh api users/<login>`. A later commit can carry the trailer when history cannot change.\n"
|
||||
"Credit is taken only from such an address, or from a raw email listed in .github/AUTHOR_MAP; add the\n"
|
||||
"email a contributor commits with there once it is linked to their account.")
|
||||
if missing:
|
||||
print("\nUncredited work landed in this release:\n")
|
||||
print("\n".join(missing))
|
||||
@@ -139,6 +208,8 @@ def main() -> int:
|
||||
"is still their work -- see AGENTS.md 'Landing other people's work'."
|
||||
)
|
||||
return 1
|
||||
if broken:
|
||||
return 1
|
||||
print("Every contributor in the window is credited on all three surfaces.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -538,6 +538,9 @@ def report_huggingface_coverage(
|
||||
def report_env_lookup_order(
|
||||
label: str, source: str, expected_order: list[str], context: str
|
||||
) -> list[str]:
|
||||
non_blank_chain = "env_non_blank(&[" + ", ".join(f'"{name}"' for name in expected_order) + "])"
|
||||
if non_blank_chain in source:
|
||||
return []
|
||||
lookup_needles = [f'std::env::var("{name}")' for name in expected_order]
|
||||
return report_string_order(label, source, lookup_needles, context)
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ fi
|
||||
# Founder decision 2026-09-01: `fleet` is the public product term for the
|
||||
# assembled model team; `Pod` is retired from customer-facing copy. The
|
||||
# English source locale is the gate; other locale packs migrate with their
|
||||
# translation sweep. `/pod` and `codewhale pod` remain parser aliases.
|
||||
# translation sweep. `/pod` answers "Unknown command" and `codewhale pod` is
|
||||
# plain prompt text.
|
||||
if grep -En '": "[^"]*[Pp]od' crates/localization/locales/en.json; then
|
||||
printf '%s\n' 'retired Pod vocabulary remains in English product copy' >&2
|
||||
exit 1
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Everyone whose work landed in or is credited by Codewhale, on one wall.
|
||||
|
||||
GitHub's contributor list counts only commit authors whose email is linked to
|
||||
an account. Harvested pull requests, co-author trailers, unlinked commit
|
||||
emails and credited bug reports never reach it, so it shows about half of the
|
||||
people who built Codewhale.
|
||||
GitHub's contributor count takes only commit authors and co-author trailers
|
||||
whose email is linked to an account. Harvested pull requests without a trailer,
|
||||
unlinked commit emails and credited bug reports never reach it, so it shows
|
||||
about half of the people who built Codewhale.
|
||||
|
||||
refresh rebuild docs/contributors.json from git history, the CHANGELOG,
|
||||
docs/CONTRIBUTORS.md and GitHub (needs `gh`)
|
||||
@@ -202,8 +202,8 @@ def render() -> None:
|
||||
"<!-- contributor-wall:start -->\n## Everyone\n\n"
|
||||
f"[](#everyone)\n\n"
|
||||
f"{total} people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. "
|
||||
"GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts "
|
||||
f"{data['github_contributor_list']} of them. If your commits are missing there, add your commit email to your "
|
||||
"GitHub's contributor count includes only commit authors and co-authors whose email is linked to their "
|
||||
"account, so it misses many of them. If your commits are missing there, add your commit email to your "
|
||||
"GitHub account and they will link.\n\n"
|
||||
f"{links}\n\nAlso with us, from commits whose email is not linked to an account: {' · '.join(extra)}.\n"
|
||||
"<!-- contributor-wall:end -->\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const repo = path.resolve(__dirname, "../..");
|
||||
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\ncase "$1" in --help) printf \'%s\\n\' "${INSTALL_TEST_HELP:-}" ;; *) echo "codewhale 0.9.11" ;; esac\n';
|
||||
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\n[ "$1" = doctor ] && echo "telemetry=${CODEWHALE_TELEMETRY-unset} update_check=${CODEWHALE_NO_UPDATE_CHECK-unset}" >> "$INSTALL_TEST_DOCTOR_ENV"\ncase "$1" in --help) printf \'%s\\n\' "${INSTALL_TEST_HELP:-}" ;; *) echo "codewhale 0.9.11" ;; esac\n';
|
||||
|
||||
function executable(file, body) {
|
||||
fs.writeFileSync(file, body, { mode: 0o755 });
|
||||
@@ -56,6 +56,7 @@ cp "$INSTALL_TEST_ASSETS/$asset" "$output"
|
||||
CODEWHALE_VERSION: "v0.9.11", CODEWHALE_INSTALL_DIR: destination,
|
||||
PREFIX: path.dirname(destination), INSTALL_TEST_ASSETS: assets,
|
||||
INSTALL_TEST_EXECUTED: path.join(root, "executed"),
|
||||
INSTALL_TEST_DOCTOR_ENV: path.join(root, "doctor-env"),
|
||||
INSTALL_TEST_DOWNLOADS: path.join(root, "downloads") };
|
||||
for (const key of ["CODEWHALE_RELEASE_BASE_URL", "DEEPSEEK_TUI_RELEASE_BASE_URL", "CODEWHALE_SKIP_GLIBC_CHECK", "DEEPSEEK_TUI_SKIP_GLIBC_CHECK", "DEEPSEEK_SKIP_GLIBC_CHECK", "TERMUX_VERSION", "CODEWHALE_HOME", "CODEWHALE_FORCE_REINSTALL", "CODEWHALE_INSTALL_COMPILED_HOST", "SHELL", "ZDOTDIR"]) delete env[key];
|
||||
function run(args = []) {
|
||||
@@ -97,6 +98,20 @@ ${hash} codew
|
||||
f.untouched();
|
||||
});
|
||||
|
||||
test(`${kind}: the installer's own doctor run is made with usage reporting forced off`, t => {
|
||||
for (const inherited of [undefined, "1"]) {
|
||||
const f = fixture(t, kind);
|
||||
if (inherited !== undefined) f.env.CODEWHALE_TELEMETRY = inherited;
|
||||
else delete f.env.CODEWHALE_TELEMETRY;
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const seen = fs.readFileSync(f.env.INSTALL_TEST_DOCTOR_ENV, "utf8").trim().split("\n");
|
||||
assert.deepEqual(seen, ["telemetry=0 update_check=1"], `inherited=${inherited}`);
|
||||
assert.ok(result.stdout.includes("config set telemetry false"), result.stdout);
|
||||
assert.ok(!result.stdout.includes("telemetry=0"), result.stdout);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${kind}: the output ends with the PATH command and the verify command`, t => {
|
||||
const f = fixture(t, kind);
|
||||
const result = f.run();
|
||||
|
||||
@@ -164,7 +164,10 @@ try {
|
||||
}
|
||||
Write-Host "Upgraded the earlier Codewhale. Previous files are kept in $recovery for 30 days. Undo: `"$destination\codewhale.exe`" doctor --restore $(Split-Path -Leaf $recovery)"
|
||||
}
|
||||
try { & (Join-Path $destination 'codewhale.exe') doctor --installs --brief 2>$null } catch { }
|
||||
$priorTelemetry = $env:CODEWHALE_TELEMETRY; $priorUpdateCheck = $env:CODEWHALE_NO_UPDATE_CHECK
|
||||
$env:CODEWHALE_TELEMETRY = '0'; $env:CODEWHALE_NO_UPDATE_CHECK = '1'
|
||||
try { & (Join-Path $destination 'codewhale.exe') doctor --installs --brief 2>$null } catch { } finally { $env:CODEWHALE_TELEMETRY = $priorTelemetry; $env:CODEWHALE_NO_UPDATE_CHECK = $priorUpdateCheck }
|
||||
Write-Host "Usage reporting is on by default (counts only, never your code or conversations). Turn it off: `"$destination\codewhale.exe`" config set telemetry false"
|
||||
Write-Host "Updates install automatically; turn off with: codewhale config set update.mode notify"
|
||||
$onPath = @($env:Path -split ';' | Where-Object { $_ -and (Path-Key $_) -eq (Path-Key $destination) }).Count -gt 0
|
||||
try { Ensure-UserPath $destination } catch { Write-Warning "Could not update your user PATH: $($_.Exception.Message). Add $destination to it yourself." }
|
||||
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user