Compare commits

..
17 Commits
Author SHA1 Message Date
Hunter Bown 70743997ca chore(release): finalize v0.8.47 — clippy fixes, npm bumps, contributor credits
- CHANGELOG: add @LING71671 (#1797) contributor credit
- CHANGELOG: add v0.8.47 compare link, bump Unreleased ref
- npm: bump codewhale + deepseek-tui to 0.8.47
- clippy: fix unused_assignments, dead_code, collapsible_if warnings
- cargo fmt: normalize indentation across tui crate
2026-05-26 22:59:12 -05:00
Hunter Bown 503b82b87a feat(verify): Fin Flash inner-loop for edit_file correction (Option B)
When the deterministic fuzzy matcher can't find a close match (score
< 60%), the engine now spawns a Fin Flash call (deepseek-v4-flash,
thinking off) with the file content and failed search string. Flash
returns the closest matching text, and the edit_file is retried with
the corrected search string.

The Flash call is cheap (~sh.0001, ~200ms) because it shares the
prefix cache already primed by the parent turn. This completes the
three-tier correction strategy:
  1. Pure engine retry (same input, I/O flakiness)
  2. Fuzzy matcher (Levenshtein, in-process, zero cost)
  3. Fin Flash (LLM-powered, cache-shared, near-zero cost)

New in verify.rs: flash_correct_search() builds a minimal MessageRequest
for Flash and parses the response. Wired into turn_loop.rs error handler
at the Option B fallback point.
2026-05-26 22:31:55 -05:00
Hunter Bown 64f97bdd87 feat(verify): fuzzy edit_file search correction (Option A)
When edit_file fails with 'search string not found', the engine now
runs a deterministic Levenshtein-based fuzzy matcher against the file
to find the closest matching text. If a match exceeds the 60% similarity
threshold, the search string is auto-corrected and the tool is retried.

The fuzzy matcher handles whitespace diffs, bracket mismatches, and
minor typos — the common cases where the model wrote 'fn main() {'
but the file has 'fn main()  {' or 'pub fn main() {'.

This runs in-process, sub-millisecond, zero API cost. Option B (Fin
Flash inner-loop for edge cases where the fuzzy matcher can't find
a match) is marked as a TODO in the error handler — the hook point
is ready, just needs the Flash API call wired in.

New in verify.rs:
- fuzzy_correct_search(): Levenshtein matcher with line-by-line and
  multi-line window comparison
- correct_edit_file_input(): builds corrected tool input with
  fuzzy_correction metadata annotation
- 4 new tests covering exact match, whitespace diff, unrelated search,
  and Levenshtein distance correctness
2026-05-26 22:25:39 -05:00
Hunter Bown a3a8723bbd feat(verify): auto-retry on verification failure
The verification gate now does inline file checks for write_file,
edit_file, and apply_patch — actually reading the file back to
confirm the operation landed instead of returning Pass blindly.

When a [VERIFY FAIL] is detected, file-mutating tools are
automatically re-executed with the same input (up to
verification_max_retries, default 2). Successful retries are
annotated as '[VERIFY PASS] auto-retried N time(s)' instead of
showing failure annotations. This collapses mechanical retries
into a single turn — the model never sees routine I/O flakiness.

Pure engine retry, no LLM dependency. The Flash/Fir inner-loop
for edit_file search correction is the next layer.

Changes:
- verify.rs: run_verification now calls inline_verify_file_tool
  for write_file/edit_file/apply_patch; is_auto_retryable helper
  added
- tool_execution.rs: Engine::retry_file_tool re-executes tools
- turn_loop.rs: retry loop around verification block, collapses
  retry annotations
- EngineConfig: verification_max_retries field (default 2)
- config.example.toml: max_retries updated to 2
2026-05-26 22:17:37 -05:00
Hunter Bown 1cc84601d9 fix: MiMo model reset, remove cache-savings display, CHANGELOG symlink
- Provider picker: Xiaomi removed from provider_passes_model_through so
  models aren't passed through verbatim. switch_provider now clears
  default_text_model when switching to a different provider, ensuring
  the new provider's default model (mimo-v2.5-pro for Xiaomi) is used.

- Footer: removed 'saved $X.XX' cache-savings hint. Cache pricing is
  the normal price, not savings. Removed dead last_turn_cache_savings
  and calculate_cache_savings functions.

- CHANGELOG: crates/tui/CHANGELOG.md replaced with symlink to root
  CHANGELOG.md, fixing the changelog_entry_exists test.

- Test: provider picker wrap test updated for Xiaomi as last provider.
2026-05-26 22:04:22 -05:00
Hunter Bown f266be5c71 chore(release): finalize v0.8.47 — PR #2235 merge, README polish
- Merged #2235 (/new session command from @reidliu41, all CI green)
- Added Xiaomi MiMo to provider examples and env-var table
- Added verification gate paragraph in harness section
- Updated @reidliu41 entries with #2235 across all READMEs
- CHANGELOG: added /new command entry
2026-05-26 21:50:01 -05:00
Hunter Bown 76b4c05994 Merge remote-tracking branch 'origin/main' into integration/v0.8.47-unified 2026-05-26 21:48:11 -05:00
Hunter Bown e8ee93d4d3 chore(release): prepare v0.8.47 — CHANGELOG, READMEs, contributors
CHANGELOG: [Unreleased] promoted to [0.8.47] with 16 Added, 5 Changed,
11 Fixed entries covering closed-loop verification, all-tools-loaded,
runtime goal tools, DDG default, Xiaomi MiMo, global AGENTS.md fallback,
DeepSeek V4 Pro pricing, and PR #2241 merge.

READMEs: 15 new contributors added across en/zh-CN/ja-JP:
@Fire-dtx, @imkingjh999, @harvey2011888, @victorcheng2333, @IIzzaya,
@PurplePulse, @cyq1017, @knqiufan, @Colorful-glassblock, @hongqitai,
@EmiyaKiritsugu3, @HUQIANTAO, @mvanhorn.
Updated @reidliu41 and @aboimpinto entries with new PRs.
2026-05-26 21:46:21 -05:00
Hunter Bown 793db1ff25 Merge PR #2241: fix(provider): keep picker selection visible
From @reidliu41 — the /provider modal now sizes dynamically, scrolls to
keep the selected row visible, wraps Up/Down between first and last
providers, and renders the selected row with a full-row highlight.
2026-05-26 21:41:50 -05:00
Hunter Bown 969931a10c fix: remove DEEPSEEK.md from project context files
DEEPSEEK.md is redundant — WHALE.md, AGENTS.md, and CLAUDE.md already
cover the project-context surface. Adding another filename just
increases the scan surface without a distinct use case.
2026-05-26 21:36:38 -05:00
Hunter BownandHu Qiantao 623d56cbf8 feat: add Xiaomi MiMo provider support (PR #2240)
- New ProviderKind::Xiaomi across config, TUI, CLI, and agent crates
- Default endpoint: https://token-plan-cn.xiaomimimo.com/v1
- Models: mimo-v2.5-pro, mimo-v2.5, mimo-v2-flash
- MiMo-specific thinking toggle (enabled/disabled)
- /models command for Xiaomi provider
- Provider-specific model picker UI

Co-Authored-By: Hu Qiantao <[email protected]>
2026-05-26 21:31:17 -05:00
Hunter Bown dcc5bdb91d chore: merge PRs #1937, #2237, #1852, #1910 for v0.8.47
- #1937: Make DeepSeek V4 Pro 75% discount permanent (pricing.rs)
- #2237: Fix two clippy warnings (config.rs, runtime_log.rs)
- #1852: Add DEEPSEEK.md as project context file
- #1910: Suppress verbose CLI logging on Windows alt-screen
2026-05-26 21:30:39 -05:00
Hunter Bown 690acecf77 chore: bump version to 0.8.47
- Workspace version: 0.8.46 → 0.8.47
- All inter-crate dependency versions updated
- Merged PR #2236: global AGENTS.md from ~/.agents/
2026-05-26 21:29:33 -05:00
Hunter Bown 96be850cc3 Merge branch 'fix/2156-codewhale-global-agents-md-fallback' into integration/v0.8.47-unified 2026-05-26 21:28:23 -05:00
Hunter Bown a448a8a611 feat(v0.8.47): closed-loop verification, all-tools-loaded, response_format
- New verify.rs: closed-loop verification gate re-checks side-effect
  tool claims (write_file, edit_file, apply_patch, exec_shell) before
  the result enters the session stream. [VERIFY FAIL] annotations flag
  contradictions; verdicts recorded in session verification ledger.
  Enabled by default; configurable via [verification] in config.toml.

- All native tools loaded upfront: removed deferred loading policy.
  Every tool is visible from turn one instead of requiring tool_search
  discovery. 80+ tools in catalog, ~1% context overhead.

- response_format field on MessageRequest: supports DeepSeek JSON
  Output mode for structured responses when needed.

- Constitution (base.md): clarified 'begin with an A' as grade
  metaphor (A+ awarded in advance). Article I connected to credit-first
  stance. Article V updated with structural verification note.

- System prompt: verification gate statute added.

- config.example.toml: [verification] section.
- docs/ARCHITECTURE.md: step 7.5 verification gate in data flow.
2026-05-26 21:28:17 -05:00
Hunter Bown f2b0292b1f fix: remove WHALE.md from vendor-neutral ~/.agents/ directory
~/.agents/ is a vendor-neutral location intended to be shared across
tools. WHALE.md is CodeWhale-specific — placing it there at priority 3
ahead of AGENTS.md at priority 4 silently shadows a user's
cross-tool config. Restrict ~/.agents/ to AGENTS.md only.

Resolves Greptile review issues on PR #2236.
2026-05-26 21:02:56 -05:00
Matt Van Horn c863634dc5 feat: read global AGENTS.md from ~/.agents/ as vendor-neutral fallback 2026-05-26 17:13:54 -07:00
4537 changed files with 236584 additions and 2407023 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
# No suppressions: the old Starlark dependencies are absent from Cargo.lock.
# Keep remaining maintenance warnings visible in cargo-audit. The separately
# scoped cargo-deny exception is explained in docs/dependency-maintenance.md.
[advisories]
ignore = []
+47 -108
View File
@@ -17,31 +17,6 @@
npm run check
npm test
.rust_workspace_gates_stage: &rust_workspace_gates_stage
name: rust workspace gates
# The all-feature TUI test crate is large enough that concurrent rustc and
# clippy processes or disposable test debug metadata can exceed the shared
# CNB runner's memory. Keep the full gate surface, but serialize Cargo,
# omit test-only debug tables, and use the established workspace-test stack
# size so deep runtime API tests do not abort on the platform default.
timeout: 45m
script: |
set -eu
export CARGO_BUILD_JOBS=1
export CARGO_PROFILE_TEST_DEBUG=0
./scripts/release/check-versions.sh
./scripts/release/check-ohos-deps.sh
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo build -p codewhale-cli --bin codewhale --all-features --locked
# Use the shared test HOME boundary while retaining the real toolchain.
RUST_MIN_STACK=16777216 sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked
# Parity gates as first-class steps so drift surfaces as a named failure,
# not a buried workspace-test entry. Mirrors release.yml's parity job.
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-protocol --test parity_protocol --locked
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-state --test parity_state --locked
.linux_rust_gates: &linux_rust_gates
name: linux rust gates
runner:
@@ -49,12 +24,6 @@
cpus: 16
docker:
image: rust:1.88-bookworm
# codewhale-cnb-bridge GitHub App credentials from the CNB KeyStore
# (codewhale.net/codewhale-ci-secrets, github-bridge.yml), injected as
# environment variables. Values are never printed.
# https://docs.cnb.cool/en/repo/secret.html
imports:
- https://cnb.cool/codewhale.net/codewhale-ci-secrets/-/blob/main/github-bridge.yml
stages:
- name: install linux dependencies
script: |
@@ -65,46 +34,25 @@
rustup component add rustfmt clippy
fi
- *rust_workspace_gates_stage
- name: linux npm wrapper smoke
# Full LTO can link silently for longer than CNB's default 10-minute
# no-output window. Keep the production profile and give the job enough
# time to emit its version and wrapper receipts.
timeout: 45m
- name: rust workspace gates
script: |
set -eu
# The release profile uses full LTO and one codegen unit. Bound Cargo's
# parallelism so the final links cannot exhaust a shared CNB runner.
cargo build --jobs 2 --release --locked -p codewhale-cli
cp target/release/codewhale target/release/codew
./scripts/release/check-versions.sh
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
- name: linux npm wrapper smoke
script: |
set -eu
cargo build --release --locked -p codewhale-cli -p codewhale-tui
export PATH="$PWD/target/release:$PATH"
node scripts/release/npm-wrapper-smoke.js
./target/release/codewhale --version
./target/release/codew --version
# Shadow-parity bridge (ops design: CNB-PRIMARY-CI-DESIGN-20260830). Post one
# non-required "-cnb" Check Run on the exact GitHub SHA being built so the
# CNB verdict is visible on GitHub while GitHub Actions stays the canonical,
# required CI. endStages always run and cannot fail the pipeline, so a
# bridge outage never turns a green CNB build red (and vice versa: the CNB
# verdict is reported from CNB_PIPELINE_STATUS, not from this stage).
endStages:
- name: github shadow check run
script: |
set -eu
case "${CNB_PIPELINE_STATUS:-error}" in
success) conclusion="success" ;;
cancel) conclusion="cancelled" ;;
*) conclusion="failure" ;;
esac
node scripts/ci/cnb-github-checkrun.mjs \
--name "linux rust gates -cnb" \
--sha "${CNB_COMMIT}" \
--status completed \
--conclusion "${conclusion}" \
--details-url "${CNB_BUILD_WEB_URL:-https://cnb.cool/${CNB_REPO_SLUG}}" \
--summary "CNB pipeline '${CNB_PIPELINE_NAME:-linux rust gates}' finished with status ${CNB_PIPELINE_STATUS:-unknown} on ${CNB_BRANCH:-unknown branch} (${CNB_COMMIT}). Shadow lane per the CNB-primary CI design: GitHub Actions remains canonical and required; nothing is gated on this check."
./target/release/codewhale-tui --version
./target/release/deepseek --version
./target/release/deepseek-tui --version
.linux_release_preflight: &linux_release_preflight
name: linux release preflight
@@ -123,7 +71,14 @@
rustup component add rustfmt clippy
fi
- *rust_workspace_gates_stage
- name: rust workspace gates
script: |
set -eu
./scripts/release/check-versions.sh
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
- name: crate publish dry-run
script: |
@@ -131,20 +86,15 @@
./scripts/release/publish-crates.sh dry-run
- name: release binary smoke
# Full LTO can link silently for longer than CNB's default 10-minute
# no-output window. Keep the production profile and give the job enough
# time to emit its version and wrapper receipts.
timeout: 45m
script: |
set -eu
# Keep the production release profile intact while avoiding a burst of
# concurrent rustc/linker processes on the shared release runner.
cargo build --jobs 2 --release --locked -p codewhale-cli
cp target/release/codewhale target/release/codew
cargo build --release --locked -p codewhale-cli -p codewhale-tui
export PATH="$PWD/target/release:$PATH"
node scripts/release/npm-wrapper-smoke.js
./target/release/codewhale --version
./target/release/codew --version
./target/release/codewhale-tui --version
./target/release/deepseek --version
./target/release/deepseek-tui --version
main:
push:
@@ -165,48 +115,37 @@ $:
- docker:
image: rust:1.88-bookworm
stages:
- name: build linux x64 release assets (static)
# The static full-LTO link can also outlive CNB's default no-output
# window. Do not weaken the release profile to keep the runner alive.
timeout: 45m
- name: build linux x64 release assets
script: |
set -eu
apt-get update
apt-get install -y git musl-tools nodejs pkg-config
rustup target add x86_64-unknown-linux-musl
apt-get install -y git libdbus-1-dev nodejs pkg-config
./scripts/release/check-versions.sh --require-dated-release
./scripts/release/check-ohos-deps.sh
checkout_sha="$(git rev-parse 'HEAD^{commit}')"
commit_sha="${CNB_COMMIT:-${checkout_sha}}"
if [ "$commit_sha" != "$checkout_sha" ]; then
echo "ERROR: CNB_COMMIT ${commit_sha} does not match checkout ${checkout_sha}" >&2
exit 1
fi
export CODEWHALE_BUILD_SHA="$commit_sha"
cargo build --jobs 2 --release --locked \
--target x86_64-unknown-linux-musl \
-p codewhale-cli # single binary
./scripts/release/check-versions.sh
cargo build --release --locked -p codewhale-cli -p codewhale-tui
mkdir -p target/cnb-release
BIN_DIR="target/x86_64-unknown-linux-musl/release"
cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-linux-x64
cp "$BIN_DIR/codewhale" target/cnb-release/codew-linux-x64
cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-tui-linux-x64
cp target/release/codewhale target/cnb-release/codewhale-linux-x64
cp target/release/codewhale-tui target/cnb-release/codewhale-tui-linux-x64
cp target/release/deepseek target/cnb-release/deepseek-linux-x64
cp target/release/deepseek-tui target/cnb-release/deepseek-tui-linux-x64
strip \
target/cnb-release/codewhale-linux-x64 \
target/cnb-release/codew-linux-x64 \
target/cnb-release/codewhale-tui-linux-x64 \
target/cnb-release/deepseek-linux-x64 \
target/cnb-release/deepseek-tui-linux-x64 \
|| true
(
cd target/cnb-release
sha256sum \
codewhale-linux-x64 \
codew-linux-x64 \
codewhale-tui-linux-x64 \
deepseek-linux-x64 \
deepseek-tui-linux-x64 \
> codewhale-artifacts-sha256.txt
cp codewhale-artifacts-sha256.txt deepseek-artifacts-sha256.txt
)
tag_name="${CNB_BRANCH:-}"
@@ -214,11 +153,7 @@ $:
tag_name="$(git describe --tags --exact-match 2>/dev/null || true)"
fi
version="${tag_name#v}"
cargo_version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
if [ -n "$tag_name" ] && [ "$version" != "$cargo_version" ]; then
echo "ERROR: tag ${tag_name} does not match Cargo.toml version ${cargo_version}" >&2
exit 1
fi
commit_sha="${CNB_COMMIT:-$(git rev-parse HEAD)}"
{
echo "# ${tag_name:-CNB release}"
echo
@@ -232,9 +167,11 @@ $:
echo
echo "Assets:"
echo "- codewhale-linux-x64"
echo "- codew-linux-x64"
echo "- codewhale-tui-linux-x64 (v0.9.4 compatibility alias)"
echo "- codewhale-tui-linux-x64"
echo "- codewhale-artifacts-sha256.txt"
echo "- deepseek-linux-x64"
echo "- deepseek-tui-linux-x64"
echo "- deepseek-artifacts-sha256.txt"
} > target/cnb-release/CNB_RELEASE.md
- name: create cnb release
@@ -248,6 +185,8 @@ $:
settings:
attachments:
- target/cnb-release/codewhale-linux-x64
- target/cnb-release/codew-linux-x64
- target/cnb-release/codewhale-tui-linux-x64
- target/cnb-release/codewhale-artifacts-sha256.txt
- target/cnb-release/deepseek-linux-x64
- target/cnb-release/deepseek-tui-linux-x64
- target/cnb-release/deepseek-artifacts-sha256.txt
-30
View File
@@ -1,30 +0,0 @@
{
"schema_version": 1,
"authority": [
"the user's request, this turn",
"this constitution",
"project law and instructions — nearest in scope wins",
"standing user-global preferences",
"memory and previous-session handoffs"
],
"protected_invariants": [
"Keep the active first-turn tool-catalog head byte-stable (DeepSeek KV prefix-cache invariant); changes to it must be one-time and deterministic.",
"Preserve old-session transcript replay: never remove a tool's registration just because it is deprecated/hidden.",
"Stable Rust only (edition 2024); no nightly features.",
"Keep the codewhale CLI dispatcher and the codewhale-tui binary in sync when crates/tui changes.",
"Precedence is stated only in BASE_PROMPT § Whose word wins; other layers describe behavior, not rank."
],
"branch_policy": "Start from live branch and handoff truth. Never commit directly to main; use the active integration branch or a fresh codex/... branch/worktree for isolated work, and open reviewable PRs into main. One PR per logical workstream; do not mix unrelated fixes.",
"verification_policy": {
"before_claiming_done": [
"run the focused tests for the changed crate (cargo test -p <crate>), then cargo check/clippy as appropriate",
"read changed files back to confirm the edit landed as intended",
"never claim verification you did not perform"
]
},
"escalate_when": [
"an action is destructive or hard to reverse and was not explicitly authorized",
"changing provider/auth/config or anything that sends data to an external service",
"deleting or overwriting files you did not create, or that contradict how they were described"
]
}
-23
View File
@@ -1,23 +0,0 @@
[[hooks]]
name = "shell guard (staging, release freeze)"
event = "tool_call_before"
command = 'cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py'
timeout_secs = 10
continue_on_error = true
condition = { type = "tool_name", name = "bash" }
[[hooks]]
name = "edit guard (CODEWHALE_RELEASE_FREEZE=1)"
event = "tool_call_before"
command = '[ -z "$CODEWHALE_RELEASE_FREEZE" ] || { cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py; }'
timeout_secs = 10
continue_on_error = true
[hooks.condition]
type = "any"
conditions = [
{ type = "tool_category", category = "file_write" },
{ type = "tool_name", name = "write_file" },
{ type = "tool_name", name = "edit_file" },
{ type = "tool_name", name = "fim_edit" },
]
-120
View File
@@ -1,120 +0,0 @@
# cargo-nextest profile for contributors (`cargo nextest run --workspace`).
#
# Required GitHub CI and RC/release parity use nextest plus Cargo doctests.
# nextest runs each test in its own process; that does not establish a pass
# under libtest's shared-process globals. GH6698 separately requires two full
# `cargo test --workspace --all-features --locked -- --format=pretty` passes
# at one exact revision. CI's manual `shared-process-twice` mode records that
# evidence; adding the mode or passing nextest does not satisfy the issue.
# nextest also remains a faster local loop with named slow/hanging tests.
#
# Because tests no longer share a process, integration suites that serialize
# on in-process locks must be bounded here when they contend for mock-server
# ports or wall-clock timing budgets.
[profile.default]
# Name tests that take longer than this so contributors see where the time goes.
slow-timeout = { period = "30s" }
# Keep failures visible in the final summary and never retry silently:
# a flaky test is a bug report, not something to paper over.
retries = 0
fail-fast = false
final-status-level = "slow"
# RUST_MIN_STACK is not a nextest.toml key. CI and scripts/dev-test.sh
# export 16 MiB so local nextest matches the product thread stack.
[test-groups]
# Integration tests that spawn the real `codewhale` binary and wait on
# service start-up deadlines; bounded so a fully parallel run on a busy
# machine cannot starve them past their 30 s budgets.
spawns-binaries = { max-threads = 3 }
# Telemetry contract tests also spawn the real binary, and their fixture's
# in-process mutex cannot serialize nextest's one-process-per-test workers.
# Keep one telemetry process alongside the other three integration workers.
telemetry-contract = { max-threads = 1 }
# Persistent-service exec tests wait on a pid file from a real child. Under
# parallel load the file never appears (#5355). Serialize them; do not drop
# the tests.
exec-persistent-service = { max-threads = 1 }
# Fleet manager lifecycle tests start real `/bin/sh` workers under setsid and
# wait 10-15 s for the child's first line. Under full macOS CI load that line
# never ran for three overlapping tests (#6424, #6428 runs), while each
# passed alone from the same binary in under 1.2 s. Serialize the module; do
# not lengthen its deadlines or drop tests. The product side of the load
# (a durable heartbeat and a `ps` sample on every scheduler tick) is now
# bounded to once a second per worker.
fleet-manager-lifecycle = { max-threads = 1 }
# Extension fixtures launch real Node hosts, and the production handshake
# deadline is 30 s (`HANDSHAKE_DEADLINE`, raised from 5 s: Windows full-suite
# runs on #6776/#6780 reported ordinary hosts missing the old one). Bound
# contention across nextest processes; keep production deadlines and the
# explicit hang-detection tests unchanged.
extension-host = { max-threads = 1 }
# First matching test-group override wins. Keep these more-specific
# integration filters before binary(integration) so they are not stolen
# by the three-thread group.
[[profile.default.overrides]]
filter = 'binary(integration) & test(/^telemetry_contract::/)'
test-group = 'telemetry-contract'
# Same hazard, different binary. `telemetry_kill_switch_dispatch` is a
# codewhale-cli integration binary, not `integration`, so none of the filters
# below ever matched it and it ran at full parallelism beside ~15.7k tests.
# Each of its cases spawns the real binary, which gives a detached writer
# thread only CLI_PERSIST_TIMEOUT (250 ms, crates/telemetry/src/lib.rs) to
# re-read config from disk and fsync its batch before the process exits. Under
# Ubuntu CI load that deadline is missed and the dry-run receipt never lands —
# observed at telemetry_kill_switch_dispatch.rs:180 on PR #6104 (2026-09-12)
# and again on PR #6267 (2026-09-16), both Ubuntu-only, both on changes that
# touch neither telemetry nor the CLI.
[[profile.default.overrides]]
filter = 'binary(telemetry_kill_switch_dispatch)'
test-group = 'telemetry-contract'
[[profile.default.overrides]]
filter = 'package(codewhale-engine) & kind(lib) & test(/^fleet::manager::tests::/)'
test-group = 'fleet-manager-lifecycle'
[[profile.default.overrides]]
# DSH preparation also launches the isolated host against the shared hermetic
# home. OCR/PDF consumers also launch real hosts outside extension_host's
# namespace. Keep their process lifecycle checks in this existing serial lane.
filter = '(package(codewhale-engine) | package(codewhale-tui)) & kind(lib) & (test(/^extension_host::/) | test(/^tools::image_ocr::host_tests::/) | test(/^tools::pdf::host_tests::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))'
test-group = 'extension-host'
[[profile.default.overrides]]
filter = 'binary(integration) & test(/^exec_persistent_service::/)'
test-group = 'exec-persistent-service'
slow-timeout = { period = "120s" }
[[profile.default.overrides]]
filter = 'binary(integration)'
test-group = 'spawns-binaries'
[[profile.default.overrides]]
filter = 'binary(owner_crash_reentry)'
test-group = 'spawns-binaries'
# CI (profiles inherit from default): the final summary lists every failure and
# slow test in full so the run log is enough to diagnose without rerunning.
[profile.ci]
retries = 0
# A test that runs past ten minutes is a hang, not a slow test: terminate it
# and surface its captured output instead of letting the job die at the
# runner's 90-minute limit with no diagnostics (v0.9.12 Windows candidate).
slow-timeout = { period = "60s", terminate-after = 10 }
fail-fast = false
final-status-level = "slow"
failure-output = "immediate-final"
# Exact compiled-image Native delivery receipt comes from this same full run.
# Only the fixed containment and memory marker/output is retained on success; the rest
# of the workspace keeps its existing output policy.
[profile.ci.junit]
path = "junit.xml"
report-name = "codewhale-native-host"
[[profile.ci.overrides]]
filter = 'package(codewhale-engine) & kind(lib) & (test(/^extension_host::tests::compiled_native_host_cannot_read_secrets_or_write_outside_its_data_dir$/) | test(/^extension_host::tests::compiled_native_host_memory_cap_is_enforced$/))'
junit.store-success-output = true
-29
View File
@@ -1,29 +0,0 @@
ARG RUST_VERSION=1.88
FROM rust:${RUST_VERSION}-slim-bookworm
# Native DBus headers and pkg-config are required by libdbus-sys during
# `cargo build`. Keep these development dependencies in the Dev Container,
# rather than adding them to the production runtime image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libdbus-1-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# The official Rust image keeps its toolchain here. Preserve it after switching
# from the image's root user to the non-root development user below.
ENV PATH=/usr/local/cargo/bin:${PATH}
RUN printf '%s\n' 'export PATH=/usr/local/cargo/bin:$PATH' \
> /etc/profile.d/codewhale-rust.sh
RUN rustup component add rustfmt
RUN groupadd --gid 1000 codewhale \
&& useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \
&& install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.codewhale \
&& install -d -m 0755 -o codewhale -g codewhale /home/codewhale/.cargo \
&& install -d -m 0755 -o codewhale -g codewhale /home/codewhale/.cargo/target
USER codewhale
WORKDIR /workspaces/CodeWhale
+10 -9
View File
@@ -1,8 +1,7 @@
{
"name": "CodeWhale",
"name": "DeepSeek TUI",
"dockerFile": "../Dockerfile",
"build": {
"context": "..",
"dockerfile": "Dockerfile",
"args": {
"RUST_VERSION": "1.88"
}
@@ -21,13 +20,15 @@
}
},
"remoteEnv": {
"DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}",
"CARGO_TARGET_DIR": "/home/codewhale/.cargo/target"
"DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}"
},
"mounts": [
"source=codewhale-state,target=/home/codewhale/.codewhale,type=volume",
"source=codewhale-cargo-target,target=/home/codewhale/.cargo/target,type=volume"
"source=${localEnv:HOME}/.deepseek,target=/home/deepseek/.deepseek,type=bind,consistency=cached"
],
"postCreateCommand": "cargo build --locked",
"remoteUser": "codewhale"
"features": {
"ghcr.io/devcontainers/features/rust:1": {},
"ghcr.io/devcontainers/features/git:1": {}
},
"postCreateCommand": "cargo build",
"remoteUser": "deepseek"
}
+1 -3
View File
@@ -48,10 +48,8 @@ venv/
/.claude/
/.ace-tool/
# Documentation (not needed at runtime), except schemas embedded via include_str!
# Documentation (not needed at runtime)
/docs/
!/docs/schemas/
!/docs/schemas/**
/website/
/*.md
!/README.md
+40 -18
View File
@@ -1,28 +1,50 @@
# Codewhale workspace credentials
#
# Shell-exported variables override values in this file. Codewhale loads only
# literal credential names for built-in providers from a workspace `.env`.
# Routing, model/base URL, config/profile, MCP/plugin, sandbox, approval,
# executable-path, runtime, and other control settings are ignored here; put
# those in config.toml, CLI flags, or the launching shell.
#
# Variable expansion is deliberately unsupported. Keep values literal and
# keep `.env` untracked.
# DeepSeek TUI environment
# Shell-exported variables override values in this file.
# Copy this file to `.env`, then uncomment only the values you want to use.
# DeepSeek API (default provider)
# Get an API key from DeepSeek, then keep it local in `.env`.
# DEEPSEEK_API_KEY=
# NVIDIA NIM-hosted models
# Official DeepSeek Platform host (see api-docs.deepseek.com); `deepseek-cn` uses the same host.
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# DEEPSEEK_PROVIDER=deepseek-cn
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
# to the current V4 flash model in the TUI.
# DEEPSEEK_MODEL=deepseek-v4-pro
# DEEPSEEK_MODEL=deepseek-v4-flash
# NVIDIA NIM-hosted DeepSeek V4
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
# DEEPSEEK_PROVIDER=nvidia-nim
# NVIDIA_API_KEY=
# NVIDIA_NIM_API_KEY=
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
# AtlasCloud OpenAI-compatible endpoint
# ATLASCLOUD_API_KEY=
# Xiaomi MiMo Token Plan (https://platform.xiaomimimo.com/docs/zh-CN/price/tokenplan/quick-access)
# DEEPSEEK_PROVIDER=xiaomi
# MIMO_API_KEY=tp-YOUR_TOKEN_PLAN_KEY # Token Plan key format: tp-xxxxx
# MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 # cn / sgp / ams cluster
# MIMO_MODEL=mimo-v2.5-pro # mimo-v2.5-pro | mimo-v2.5
# Mistral AI (la Plateforme) — https://console.mistral.ai/api-keys
# MISTRAL_API_KEY=
# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
# DEEPSEEK_LOG_LEVEL=debug
# RUST_LOG=deepseek_tui=debug
# Codewhale API — account key (cwc_key_…) with the models:infer scope
# https://app.codewhale.net/settings?section=api
# CODEWHALE_API_KEY=
# Agent safety defaults
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
# inside the workspace unless a sandbox elevation path is explicitly used.
# DEEPSEEK_APPROVAL_POLICY=on-request
# DEEPSEEK_SANDBOX_MODE=workspace-write
# DEEPSEEK_ALLOW_SHELL=true
# DEEPSEEK_YOLO=true
# Optional extension paths
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
+1 -80
View File
@@ -1,86 +1,7 @@
# Ensure LF line endings for files consumed by include_str!() on all platforms.
# include_str!() preserves raw bytes; CRLF breaks substring assertions and
# produces different compiled binaries on Windows vs Linux/macOS.
# Cover nested mode/approval/personality prompts — a top-level *.md glob alone
# left modes/*.md on CRLF under Windows autocrlf and blew the agent token budget.
crates/engine/src/prompts/**/*.md text eol=lf
crates/engine/src/prompts/*.md text eol=lf
crates/engine/src/prompts/*.txt text eol=lf
# The rest of the include_str!() inputs. `* text=auto` below converts these to
# CRLF on a Windows checkout, which is invisible for the ones that are only
# parsed or substring-matched and fatal for the one that is compared byte for
# byte: crates/telemetry/tests/golden/v1.json is asserted against
# serde_json::to_string_pretty output, which always emits LF, so Windows CI
# failed `golden_payload_v1` with a pure \r\n diff.
crates/telemetry/tests/golden/*.json text eol=lf
# Generated pet native artifacts are regenerated in memory and compared byte
# for byte by `npm --prefix pet run check` (pet/scripts/build-pet.mjs), and the
# TUI copy is embedded with include_str!() (pet_watch/owner.rs). The generator
# emits LF, so a CRLF Windows checkout reported all three as stale and would
# embed different bytes.
crates/tui/src/tui/pet_watch/pet-native.js text eol=lf
pet/ios/Resources/pet-native.js text eol=lf
pet/ios/Resources/demo.jsonl text eol=lf
# Tideline component goldens (crates/tui/src/tui/goldens/*.txt) are
# fs::read_to_string'd and compared byte for byte against buffer dumps that
# always join rows with LF (golden_harness.rs::render_golden_text), so a
# CRLF checkout failed all eight Windows golden tests with a pure \r\n diff.
crates/tui/src/tui/goldens/*.txt text eol=lf
# FEAT-025 baseline export goldens (crates/tui/src/commands/fixtures/*.md) are
# include_str!()'d and compared byte for byte against a document the exporter
# builds with LF, so a CRLF checkout failed the four Windows golden tests with a
# pure \r\n diff (Windows CI caught this; Linux cannot see it).
crates/tui/src/commands/fixtures/*.md text eol=lf
# FEAT-029 debug diagnostics baselines (crates/tui/src/commands/fixtures/
# diagnostics/*) are compared byte for byte against command output joined
# with LF, so a CRLF checkout failed every Windows baseline parity test with a
# pure \r\n diff.
crates/tui/src/commands/fixtures/diagnostics/* text eol=lf
# Conformance fixtures are recorded wire bytes (SSE framing is line-exact) and
# goldens compared byte for byte; a CRLF checkout must not rewrite either.
crates/tui/tests/fixtures/conformance/** text eol=lf
crates/*/assets/**/*.json text eol=lf
crates/*/assets/**/*.md text eol=lf
crates/*/locales/*.json text eol=lf
workflows/*.js text eol=lf
# Executable documentation: the Fleet tutorial's JSON fence is parsed by the
# task-spec contract test, so its bytes must agree on Windows and Unix.
docs/FLEET_WORKFLOW_TUTORIAL.md text eol=lf
# The dsh bundle scene is include_str!() into the generated client.js and
# hashed for stale detection; CRLF would change both across platforms.
crates/tui/src/integrations/dsh/*.js text eol=lf
# The native DSH import fixture bundle is hashed into the conversion receipt,
# so a CRLF checkout changed the expected digest on Windows.
scripts/fixtures/dsh-web-app/** text eol=lf
# The extension host's generated protocol TS is compared byte for byte with
# the generator's LF output (protocol/tests.rs), and its dist bundle is
# include_bytes!()'d and digest-checked at launch; a CRLF Windows checkout
# failed the former and would embed different bytes.
crates/engine/extension-host/src/protocol.generated.ts text eol=lf
crates/engine/extension-host/dist/** text eol=lf
# Reviewed extension-host fixtures carry exact source SHA-256 receipts.
# A Windows checkout must not rewrite their admitted source closure to CRLF.
crates/engine/tests/fixtures/extension_host/** text eol=lf
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
# Release shell scripts are invoked directly by bash on Windows
# checkouts; CRLF turns `set -euo pipefail` into an invalid option.
scripts/release/*.sh text eol=lf
# Keep repository attributes themselves stable on every platform.
.gitattributes text eol=lf
crates/tui/src/prompts/*.md text eol=lf
# Everything else auto-detects (default).
* text=auto
# Preserve the qualified upstream font license, including its trailing space.
web/public/brand/fonts/OFL.txt text eol=lf whitespace=-blank-at-eol
# Generated protocol and SDK exports are compared byte for byte; keep LF on Windows checkouts.
crates/app-server-protocol/schema/** text eol=lf
npm/runtime-sdk/generated/** text eol=lf
-5
View File
@@ -1,5 +0,0 @@
# This exact fixture is a deliberately public TEST-ONLY signing key.
# It is never a production trust anchor and signs only local test payloads.
# No other key files are excluded.
paths-ignore:
- docs/cloud-facts/fixtures/test-only-signing-key.pem
-279
View File
@@ -1,279 +0,0 @@
# Contributor credit identity map.
#
# Format:
# 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.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
fleitz = Fred Leitz <[email protected]>
[email protected] = Fred Leitz <[email protected]>
[email protected] = Fred Leitz <[email protected]>
Angel-Hair = Lu Shihan <[email protected]>
[email protected] = Lu Shihan <[email protected]>
dmitri-0 = dmitri-0 <[email protected]>
[email protected] = dmitri-0 <[email protected]>
SparkofSpike = Sh1Zuku <[email protected]>
zhangweiii = zhangweiii <[email protected]>
[email protected] = zhangweiii <[email protected]>
sternelee = Sterne Lee <[email protected]>
[email protected] = Sterne Lee <[email protected]>
Mr-Moon121 = Jeffrey Luna <[email protected]>
lerugray = Ray Weiss <[email protected]>
[email protected] = Ray Weiss <[email protected]>
reidliu41 = reidliu41 <[email protected]>
[email protected] = reidliu41 <[email protected]>
ousamabenyounes = Ben Younes <[email protected]>
[email protected] = Ben Younes <[email protected]>
ljm3790865 = ljm3790865 <[email protected]>
HUQIANTAO = HUQIANTAO <[email protected]>
Hu Qiantao = HUQIANTAO <[email protected]>
[email protected] = HUQIANTAO <[email protected]>
[email protected] = HUQIANTAO <[email protected]>
[email protected] = HUQIANTAO <[email protected]>
punkcanyang = Punkcan Yang <[email protected]>
Punkcan Yang = Punkcan Yang <[email protected]>
[email protected] = Punkcan Yang <[email protected]>
merchloubna70-dot = merchloubna70-dot <[email protected]>
h3c-hexin = h3c-hexin <[email protected]>
[email protected] = h3c-hexin <[email protected]>
[email protected] = h3c-hexin <[email protected]>
axobase001 = axobase001 <[email protected]>
donglovejava = donglovejava <[email protected]>
Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
[email protected] = aboimpinto <[email protected]>
[email protected] = aboimpinto <[email protected]>
encyc = encyc <[email protected]>
Duducoco = Duducoco <[email protected]>
cyq1017 = cyq1017 <[email protected]>
cyq = cyq1017 <[email protected]>
[email protected] = cyq1017 <[email protected]>
cy2311 = CY <[email protected]>
noaft = Toan Nguyen <[email protected]>
[email protected] = Toan Nguyen <[email protected]>
zlh124 = zlh124 <[email protected]>
LeoLin990405 = LeoLin990405 <[email protected]>
THINKER-ONLY = THINKER-ONLY <[email protected]>
nightt5879 = nightt5879 <[email protected]>
LmeSzinc = LmeSzinc <[email protected]>
Lstarsky0 = Lstarsky0 <[email protected]>
RepentStar = RepentStar <[email protected]>
CCChisato = Fushimi Rio <[email protected]>
aznikline = aznikline <[email protected]>
Aznable = aznikline <[email protected]>
[email protected] = aznikline <[email protected]>
Liu-Vince = Liu-Vince <[email protected]>
Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = wdw8276 <[email protected]>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <[email protected]>
[email protected] = gus <[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
[email protected] = Darrell Thomas <[email protected]>
taixinguo = Taixin Guo <[email protected]>
Taixin Guo = Taixin Guo <[email protected]>
JayBeest = JayBeest <[email protected]>
jcorneli = JayBeest <[email protected]>
[email protected] = JayBeest <[email protected]>
lucaszhu-hue = lucaszhu-hue <[email protected]>
zhuangbiaowei = zhuangbiaowei <[email protected]>
yuanchenglu = yuanchenglu <[email protected]>
Vishnu1837 = Vishnu1837 <[email protected]>
sximelon = sximelon <[email protected]>
Sskift = Sskift <[email protected]>
New2Niu = New2Niu <[email protected]>
mvanhorn = mvanhorn <[email protected]>
MengZ-super = MengZ-super <[email protected]>
membphis = membphis <[email protected]>
LeoAlex0 = LeoAlex0 <[email protected]>
Lee-take = Lee-take <[email protected]>
lbcheng888 = lbcheng888 <[email protected]>
kunpeng-ai-lab = kunpeng-ai-lab <[email protected]>
elowen53 = elowen53 <[email protected]>
Elowen = elowen53 <[email protected]>
[email protected] = elowen53 <[email protected]>
CrepuscularIRIS = CrepuscularIRIS <[email protected]>
chnjames = chnjames <[email protected]>
ChaceLyee2101 = ChaceLyee2101 <[email protected]>
ci4ic4 = ci4ic4 <[email protected]>
Chavdar Ivanov = ci4ic4 <[email protected]>
[email protected] = ci4ic4 <[email protected]>
yusufgurdogan = yusufgurdogan <[email protected]>
Yusuf Gurdogan = yusufgurdogan <[email protected]>
hotelswith = yusufgurdogan <[email protected]>
[email protected] = yusufgurdogan <[email protected]>
AresNing = AresNing <[email protected]>
shenjackyuanjie = shenjackyuanjie <[email protected]>
shenjack = shenjackyuanjie <[email protected]>
[email protected] = shenjackyuanjie <[email protected]>
xyuai = xyuai <[email protected]>
AdityaVG13 = AdityaVG13 <[email protected]>
[email protected] = AdityaVG13 <[email protected]>
Implementist = Implementist <[email protected]>
implecao = Implementist <[email protected]>
[email protected] = Implementist <[email protected]>
jrcjrcc = jrcjrcc <[email protected]>
[email protected] = jrcjrcc <[email protected]>
RefuseOdd = RefuseOdd <[email protected]>
wywsoor = wywsoor <[email protected]>
hsdbeebou = hsdbeebou <[email protected]>
tdccccc = tdccccc <[email protected]>
greyfreedom = greyfreedom <[email protected]>
[email protected] = greyfreedom <[email protected]>
puneetdixit200 = puneetdixit200 <[email protected]>
roian6 = Chanhyo Jung <[email protected]>
[email protected] = Chanhyo Jung <[email protected]>
yekern = Stime <[email protected]>
Stime = Stime <[email protected]>
pkeging = pkeging <[email protected]>
[email protected] = pkeging <[email protected]>
findshan = Wenshan Deng <[email protected]>
[email protected] = Wenshan Deng <[email protected]>
KUK4 = KUK4 <[email protected]>
[email protected] = KUK4 <[email protected]>
# Harvest-credit reconciliation: contributors restored to docs/CONTRIBUTORS.md
# whose harvested work previously lacked a canonical map entry.
GTC2080 = TaoMu <[email protected]>
[email protected] = TaoMu <[email protected]>
MMMarcinho = MMMarcinho <[email protected]>
MeAiRobot = MeAiRobot <[email protected]>
NorethSea = NorethSea <[email protected]>
SamhandsomeLee = SamhandsomeLee <[email protected]>
YaYII = YaYII <[email protected]>
sandofree = sandofree <[email protected]>
tiger-dog = tiger-dog <[email protected]>
Jianfengwu2024 = Jianfengwu2024 <[email protected]>
wplll = wplll <[email protected]>
buko = buko <[email protected]>
# Machine-credit reconciliation follow-up: contributors credited in prose or
# harvested by handle but missing from AUTHOR_MAP. Logins/IDs verified against
# the GitHub user API.
1Git2Clone = 1Git2Clone <[email protected]>
jieshu666 = jieshu666 <[email protected]>
rockeverm3m = rockeverm3m <[email protected]>
[email protected] = rockeverm3m <[email protected]>
wavezhang = wavezhang <[email protected]>
hxy91819 = hxy91819 <[email protected]>
[email protected] = hxy91819 <[email protected]>
heloanc = heloanc <[email protected]>
[email protected] = heloanc <[email protected]>
bistack = Sun Zhenyuan <[email protected]>
[email protected] = Sun Zhenyuan <[email protected]>
OctoBored = OctoBored <[email protected]>
skyzhao1223 = SKY ZHAO <[email protected]>
zhaotian1 = SKY ZHAO <[email protected]>
[email protected] = SKY ZHAO <[email protected]>
vFONGv = Matthew.Fong <[email protected]>
[email protected] = Matthew.Fong <[email protected]>
luismateusvargas = Luis Mateus Vargas <[email protected]>
redjade75723 = redjade75723 <[email protected]>
# v0.8.68 underwater-lane harvests.
moduvoice = moduvoice <[email protected]>
[email protected] = moduvoice <[email protected]>
qinlinwang = qinlinwang <[email protected]>
hange = qinlinwang <[email protected]>
[email protected] = qinlinwang <[email protected]>
knqiufan = knqiufan <[email protected]>
[email protected] = knqiufan <[email protected]>
# v0.9.1 Windows TUI reports harvested into maintainer fixes.
SparkofSpike = Sh1Zuku <[email protected]>
Sh1Zuku = Sh1Zuku <[email protected]>
# v0.9.1 provider harvests.
baendlorel = baendlorel <[email protected]>
[email protected] = baendlorel <[email protected]>
EvanProgramming = Evan <[email protected]>
adity982 = ADITYA <[email protected]>
vibecoding-skills = Harsh Dattani <[email protected]>
XhesicaFrost = XhesicaFrost <[email protected]>
ffaacceelee = ffaacceelee <[email protected]>
mky = mky <[email protected]>
cacdcaecawae = cacdcaecawae <[email protected]>
XiaoHuo888-hue = XiaoHuo888-hue <[email protected]>
[email protected] = XiaoHuo888-hue <[email protected]>
wuisabel-gif = Isabel Wu <[email protected]>
redstar = Kai Nacke <[email protected]>
[email protected] = Kai Nacke <[email protected]>
Shizuku = Sh1Zuku <[email protected]>
[email protected] = Sh1Zuku <[email protected]>
musichen = Alex Musichen <[email protected]>
[email protected] = Alex Musichen <[email protected]>
M-Maciej = M-Maciej <[email protected]>
serephus = Serephus <[email protected]>
# [email protected] = UNRESOLVED — 21 commits on main via #704; GitHub login not yet identified
qiuYliangM = 秋月凉梦 <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
whp233 = whp233 <[email protected]>
[email protected] = whp233 <[email protected]>
Gabriel-Degret = Gabriel Degret <[email protected]>
[email protected] = Gabriel Degret <[email protected]>
huangxianzhan = huangxianzhan <[email protected]>
[email protected] = huangxianzhan <[email protected]>
zhuowp = zhuowp <[email protected]>
[email protected] = zhuowp <[email protected]>
z27014 = zhuowp <[email protected]>
[email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
Vincent Corleone = Vincent Corleone <[email protected]>
[email protected] = Vincent Corleone <[email protected]>
yetuge = yetuge <[email protected]>
[email protected] = yetuge <[email protected]>
Serendo = Serendo <[email protected]>
[email protected] = Serendo <[email protected]>
dajiaohuang = dajiaohuang <[email protected]>
[email protected] = dajiaohuang <[email protected]>
Wu Shuwen = dajiaohuang <[email protected]>
[email protected] = dajiaohuang <[email protected]>
harryvgiunta = Harry Giunta <[email protected]>
[email protected] = Harry Giunta <[email protected]>
xiaoray-blip = Arlo <[email protected]>
Arlo = Arlo <[email protected]>
xyzs996 = Mark Yan <[email protected]>
[email protected] = Mark Yan <[email protected]>
Mark Yan = Mark Yan <[email protected]>
henrikdukefoss = Henrik Bakke Dukefoss <[email protected]>
Henrik Bakke Dukefoss = Henrik Bakke Dukefoss <[email protected]>
[email protected] = Henrik Bakke Dukefoss <[email protected]>
[email protected] = Dhruv Singal <[email protected]>
[email protected] = Boyce <[email protected]>
Boyce = Boyce <[email protected]>
Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
-8
View File
@@ -1,8 +0,0 @@
# Default owner for everything in the repo.
* @Hmbown
# AI code review is advisory and not wired through CODEOWNERS: GitHub CODEOWNERS
# only accepts users and teams, not bots. @Hmbown stays the human code owner.
# - Claude: .github/workflows/claude-review.yml (GitHub Actions).
# - Codex/ChatGPT: the ChatGPT Codex cloud integration (chatgpt.com/codex ->
# connect GitHub -> enable Code review), authed by the ChatGPT subscription.
-62
View File
@@ -1,62 +0,0 @@
# Berkontribusi pada Codewhale
Terima kasih atas minat Anda untuk berkontribusi pada Codewhale! Dokumen ini memberikan panduan dan instruksi untuk berkontribusi.
---
## Memulai (Getting Started)
### Prasyarat
- Rust 1.88 atau lebih baru (edisi 2024)
- Pengelola paket Cargo
- Git
### Menyiapkan Lingkungan Pengembangan
1. Fork dan klon repositori:
```bash
git clone https://github.com/USERNAME_ANDA/CodeWhale.git
cd CodeWhale
```
2. Kompilasi proyek:
```bash
cargo build
```
3. Jalankan pengujian (tests):
```bash
cargo test --workspace --all-features
```
4. Jalankan dalam mode pengembangan:
```bash
cargo run --bin codewhale
```
---
## Alur Kerja Pengembangan
### Gaya Kode (Code Style)
- Jalankan `cargo fmt` sebelum melakukan commit untuk memastikan format kode konsisten.
- Jalankan `cargo clippy` dan atasi seluruh peringatan.
- Ikuti konvensi penamaan Rust (`snake_case` untuk fungsi/variabel, `CamelCase` untuk tipe data/struct).
- Tambahkan komentar dokumentasi untuk API publik.
### Pengujian
- Tulis pengujian untuk fungsionalitas baru.
- Pastikan seluruh pengujian yang ada lulus: `cargo test --workspace --all-features`.
- Tempatkan unit test di samping kode yang diuji (`#[cfg(test)]`), serta tambahkan pengujian integrasi di bawah direktori `tests/` pada crate yang bersangkutan.
---
## Pengajuan Pull Request (PR)
1. Buat branch baru dari `main` untuk fitur atau perbaikan Anda.
2. Pastikan `cargo fmt`, `cargo clippy`, dan pengujian lulus sebelum mengajukan PR.
3. Tulis judul dan deskripsi PR yang jelas yang menjelaskan tujuan perubahan.
4. Setiap kontributor akan mendapatkan kredit dalam changelog dan `docs/CONTRIBUTORS.md`.
-67
View File
@@ -1,67 +0,0 @@
name: Agent task
description: A self-contained task a headless Codewhale agent can finish end to end. Mostly for maintainers.
labels: ["agent-ready"]
body:
- type: markdown
attributes:
value: |
A headless agent runs this with `codewhale exec --auto` on a fresh clone of `main`.
It has shell, file, and git tools, and **no context beyond this issue**. Name every
path and command explicitly. Assign a milestone during triage; this template sets none.
- type: textarea
id: goal
attributes:
label: Goal
description: What is wrong or missing, and why it matters now. 2-4 sentences.
placeholder: |
The TUI freezes when 4+ sub-agents run at once, because each progress
event triggers a full redraw.
validations:
required: true
- type: textarea
id: scope
attributes:
label: Plan
description: Numbered steps. Start each with the file path it changes. List any extra files to read first.
placeholder: |
1. crates/tui/src/tui/ui.rs — throttle redraws in the progress handler
2. crates/tui/src/tui/app.rs — add a last-redraw timestamp
Read first: crates/tui/src/tui/sidebar.rs
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Done when
description: Observable, testable outcomes.
placeholder: |
- [ ] 4 concurrent sub-agents do not freeze input
- [ ] Ctrl+C still works during sub-agent activity
validations:
required: true
- type: textarea
id: verification
attributes:
label: Verification
description: Exact commands that prove it. Scope them to the crates touched; CI runs the full suite.
placeholder: |
scripts/dev-cargo.sh check -p codewhale-tui
scripts/dev-test.sh tui subagent
render: shell
validations:
required: true
- type: textarea
id: out-of-scope
attributes:
label: Out of scope
description: What this task must not change.
placeholder: |
- The sub-agent execution model
- The recommended fanout count
validations:
required: false
+49
View File
@@ -0,0 +1,49 @@
---
name: Bug report
about: Report a reproducible problem or regression
labels: bug
---
## Description
<!-- What happened? Please keep this focused on one bug. -->
## Steps to reproduce
<!-- Include exact commands, config, or key presses when relevant. -->
1.
2.
3.
## Expected behavior
## Actual behavior
## Impact
<!-- How often does this happen, and how much does it block your workflow? -->
## Environment
- OS:
- codewhale version:
- Install method:
- `codewhale doctor` summary:
- Model/provider:
- Terminal app:
- Shell:
<!-- Hints:
OS: Windows 11 / Ubuntu 22.04 / macOS 14
codewhale version: run `codewhale --version`
Install method: cargo install / release binary / source build
codewhale doctor summary: paste only the relevant lines, and redact secrets
Model/provider: deepseek-v4-pro / DeepSeek, or qwen2.5-coder / Ollama
Terminal app: iTerm2 / Windows Terminal / GNOME Terminal / VS Code terminal
Shell: bash / zsh / fish / PowerShell
-->
## Logs, screenshots, or recordings
<!-- Add logs, screenshots, config snippets, a short screen recording, or whether this also happens with --no-alt-screen. -->
-124
View File
@@ -1,124 +0,0 @@
name: Bug report
description: Something is broken or behaves wrong. 报告问题。
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting. English or 中文 are both fine.
- Security problem? Do not post it here. Use a [private advisory](https://github.com/codewhale-hq/CodeWhale/security/advisories/new).
- Already reported? Add a 👍 to that issue instead of opening a new one.
- Remove API keys and tokens from anything you paste.
- type: checkboxes
id: searched
attributes:
label: Before you start
options:
- label: I searched existing issues and this is not a duplicate. 我已搜索过现有 issue。
required: true
- type: textarea
id: what-happened
attributes:
label: What happened? 发生了什么?
description: What you saw, and what you expected instead. Paste error text as text, not only a screenshot.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce 复现步骤
description: Exact commands, key presses, or prompts. Shorter is better.
placeholder: |
1. Run `codewhale` in a git repository
2. Type ...
3. See ...
validations:
required: true
- type: input
id: version
attributes:
label: Codewhale version 版本
description: Output of `codewhale --version`. Desktop app — the version in About.
placeholder: codewhale 0.10.1
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where did it happen? 在哪里出现?
multiple: true
options:
- TUI (terminal interface)
- CLI command (codewhale exec, codewhale doctor, ...)
- Desktop app
- Web (codewhale web or app.codewhale.net)
- Plugins / extension host
- MCP
- Other / not sure
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system 操作系统
options:
- macOS
- Linux
- Windows
- WSL
- Android (Termux)
- HarmonyOS
- Other
validations:
required: true
- type: dropdown
id: channel
attributes:
label: How did you get Codewhale? 获取方式
options:
- Official script (codewhale.net/install.sh)
- GitHub Releases download
- Desktop app download
- npm
- Homebrew
- Cargo (crates.io)
- Built from a git checkout
- Scoop / Nix / Docker / other package
- Not sure
validations:
required: true
- type: input
id: model
attributes:
label: Provider and model 提供商和模型
description: As shown by `/model`. Leave empty if it does not matter.
placeholder: "provider / model id"
- type: input
id: terminal
attributes:
label: Terminal and shell 终端
description: Only for terminal problems. Mention tmux, zellij, or screen if you use one.
placeholder: iTerm2 + zsh, Windows Terminal + PowerShell, VS Code terminal + bash
- type: textarea
id: doctor
attributes:
label: codewhale doctor (optional)
description: Run `codewhale doctor` and paste the relevant lines. Check it for secrets first.
render: text
- type: textarea
id: extra
attributes:
label: Logs, screenshots, anything else (optional)
description: Logs, a screenshot or recording, config snippets, or a guess at the cause.
+1 -11
View File
@@ -1,11 +1 @@
blank_issues_enabled: false
contact_links:
- name: Report a security vulnerability
url: https://github.com/codewhale-hq/CodeWhale/security/advisories/new
about: Please report security problems privately, not in a public issue. See SECURITY.md.
- name: Ask a question
url: https://github.com/codewhale-hq/CodeWhale/discussions
about: How-to questions and ideas you want to talk through. 提问和讨论。
- name: Discord
url: https://discord.gg/37gfS3ksug
about: Chat with the community and maintainers.
blank_issues_enabled: true
+29
View File
@@ -0,0 +1,29 @@
---
name: Feature request
about: Suggest an idea or improvement
labels: enhancement
---
## Problem
<!-- What problem are you trying to solve? Who is affected? -->
## Proposed solution
<!-- What should codewhale do differently? Include commands, UI behavior, or config shape if relevant. -->
## Use case
<!-- Describe when you would use this and why the current workflow is not enough. -->
## Alternatives considered
<!-- What workarounds or other approaches have you tried? -->
## Impact
<!-- How often would you use this, and how much would it improve your workflow? -->
## Additional context
<!-- Add screenshots, mockups, examples from other tools, links, or config snippets if helpful. -->
@@ -1,64 +0,0 @@
name: Feature request
description: Suggest an idea or an improvement. 功能建议。
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the idea. English or 中文 are both fine. Short is good.
If someone already asked for this, add a 👍 to their issue instead.
Want to talk it through first? Use [Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or [Discord](https://discord.gg/37gfS3ksug).
- type: checkboxes
id: searched
attributes:
label: Before you start
options:
- label: I searched existing issues and this is not a duplicate. 我已搜索过现有 issue。
required: true
- type: textarea
id: problem
attributes:
label: What problem does this solve? 要解决什么问题?
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What should Codewhale do? 希望 Codewhale 怎么做?
description: The behavior you want. Commands, key presses, or config are welcome.
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where? 在哪里?
multiple: true
options:
- TUI (terminal interface)
- CLI command (codewhale exec, ...)
- Desktop app
- Web (codewhale web or app.codewhale.net)
- Plugins / extension host
- MCP
- Providers and models
- Docs
- Other / not sure
- type: textarea
id: alternatives
attributes:
label: Workarounds or other tools (optional)
description: What you do today, or how another tool handles it.
- type: checkboxes
id: contribute
attributes:
label: Contributing
options:
- label: I would like to open a pull request for this. 我愿意提交 PR。
+8 -19
View File
@@ -1,24 +1,13 @@
<!-- Thanks for the PR! English or 中文 are both fine. Short is good. -->
## Summary
## What and why
## Testing
<!-- What does this change, and why? A few sentences is enough. -->
## Related issue (optional)
<!-- Closes #N if this finishes it, Refs #N if related. Skip it if there is none.
Please do not write a closing word inside a negation ("this does not close #N"):
GitHub closes the issue anyway. -->
## How I tested it
<!-- Commands you ran and what they showed. Test what you changed, for example
`scripts/dev-test.sh tui <filter>`. A few quick checks run on your PR; if one
fails for a reason unrelated to your change, a maintainer will deal with it.
For visible UI changes, add a screenshot or recording. -->
- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --workspace --all-targets --all-features`
- [ ] `cargo test --workspace --all-features`
## Checklist
- [ ] One focused change, started from current `main` (no need to rebase when `main` moves)
- [ ] `cargo fmt --all` passes, and tests cover new or changed behavior
- [ ] If this adds or changes a user-facing feature, I updated its docs and its row in `docs/features.toml` (`cargo test -p codewhale-tui --test feature_registry` checks it)
- [ ] Updated docs or comments as needed
- [ ] Added or updated tests where relevant
- [ ] Verified TUI behavior manually if UI changes
-97
View File
@@ -1,97 +0,0 @@
# Security Policy
codewhale is a coding agent with direct access to file operations, shell execution, and the network. Security disclosures are taken seriously.
## Supported Versions
Only the latest stable release receives security patches. No backports to older versions.
| Version | Supported |
|---|---|
| latest stable | :white_check_mark: |
| < latest | :x: |
Check the [releases page](https://github.com/codewhale-hq/CodeWhale/releases) for the current version.
## Reporting a Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Report privately via one of:
- **GitHub private advisory**: [github.com/codewhale-hq/CodeWhale/security/advisories/new](https://github.com/codewhale-hq/CodeWhale/security/advisories/new)
- **Email**: [[email protected]](mailto:[email protected]) — include `[SECURITY]` in the subject line
Include in your report:
- A description of the vulnerability and the impact if exploited
- Steps to reproduce or a proof of concept
- Affected versions and configuration details
- Any suggested mitigation (optional)
## Response Timeline
| Phase | Target |
|---|---|
| Acknowledgment | Within 48 hours of receipt |
| Assessment | Within 5 days — triage severity, scope, and fix approach |
| Patch (critical) | Within 14 days from assessment |
| Patch (moderate/low) | Next feature release or per-maintainer timeline |
| Disclosure | After patch is shipped and users have had time to update |
You will receive status updates at each phase. If the timeline slips, we will communicate the reason and the revised estimate.
## Scope
### In scope (what counts)
- Remote code execution through crafted prompts or model responses
- Sandbox escape — breaking out of an active Seatbelt/bubblewrap wrapper or a declared workspace boundary
- Credential leak — exfiltration of API keys, tokens, or environment secrets
- Arbitrary file read/write outside the intended workspace (`PathEscape` bypass)
- SSRF via `fetch_url` or `web_search` against internal network endpoints
- Unauthorised MCP server access or tool invocation
### Out of scope
- Social engineering of the maintainer or contributors
- Denial of service / rate-limit exhaustion against the DeepSeek API
- Vulnerabilities in third-party dependencies (report to the upstream project)
- Attacks requiring physical access to the victim's machine
- Theoretical ML-model injection attacks not demonstrated in the codewhale context
If you are unsure whether a bug is in scope, report it anyway. We will triage and respond.
## WeCom Bridge Security
The WeCom Bridge (`integrations/wecom-bridge/`) extends Codewhale to WeCom
(企业微信) Smart Bot WebSocket sessions. It inherits all standard Codewhale
security boundaries and adds bridge-specific controls.
### Bridge-specific protections
- **No public port**: The bridge communicates with `codewhale serve --http` on `127.0.0.1` only
- **Token gate**: All runtime API calls carry `CODEWHALE_RUNTIME_TOKEN`
- **Chat allowlist**: Only chats/users listed in `WECOM_CHAT_ALLOWLIST` can interact. First-pairing mode (`WECOM_ALLOW_UNLISTED=true`) is meant for onboarding only
- **Approval required**: Tool calls from WeCom sessions must be approved — either via explicit `/allow <id>` commands or natural-language keywords (`允许`, `yes`, `ok`, etc.)
- **No workspace exposure**: Only prompts, status summaries, and approval requests are sent to WeCom. Workspace contents, shell output, and runtime internals stay on the local machine
### Reporting WeCom Bridge vulnerabilities
Report bridge-specific security issues through the same channels listed above.
Include the bridge version (check `package.json`) and your WeCom deployment configuration
(sensitive values redacted). Bridge logs may be requested for reproduction.
### Bridge environment safety
- `WECOM_BOT_SECRET` and `CODEWHALE_RUNTIME_TOKEN` must never be committed to git
- The `.env` file is gitignored; use `.env.example` as the template
- Rotate secrets periodically, especially after sharing screen captures
- Use `CODEWHALE_APPROVAL_TIMEOUT_MS` (default 5 min) to limit the approval window
## Hall of Fame
We maintain a hall of fame for reporters who submit verified security vulnerabilities. To be credited, include your preferred name / handle in the report.
*No entries yet — be the first.*
-33
View File
@@ -1,33 +0,0 @@
# CodeQL configuration for code scanning.
#
# Test code is out of scope for security alerts: it runs only in CI, talks to
# in-process loopback servers, and routinely prints fixture secrets in
# assertion messages to prove they are redacted elsewhere. Those paths are
# excluded here so alerts point at product code.
#
# Inline `#[cfg(test)] mod tests` blocks inside product files cannot be
# excluded by path; alerts there are dismissed as "used in tests".
#
# This file takes effect only with CodeQL advanced setup:
# .github/workflows/codeql.yml passes it to `github/codeql-action/init`.
# Default setup ignores it, so the repository's code scanning setting must be
# switched from Default to Advanced for either to apply.
name: codewhale-codeql
paths-ignore:
# Rust integration tests and split-out unit test modules.
- "**/tests/**"
- "**/tests.rs"
- "**/*_tests.rs"
- "**/test_support.rs"
- "**/*_test_support.rs"
# JavaScript / TypeScript test suites.
- "**/test/**"
- "**/__tests__/**"
- "**/*.test.js"
- "**/*.test.mjs"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.js"
- "**/*.spec.ts"
- "**/*.spec.tsx"
-34
View File
@@ -1,34 +0,0 @@
version: 2
updates:
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
# `windows` re-exports windows-core's types; bumping one alone makes
# BOOL/HRESULT two distinct types and the Windows leg stops compiling.
windows:
patterns:
- "windows"
- "windows-*"
ignore:
# rusty_alloc 2.x does not compile on macOS: its unix backend passes
# `&mut u8` where macOS libc's `mincore` expects `*mut c_char` (seen on
# #6599). The dependency is opt-in behind the `rusty-alloc` feature.
# Drop this once upstream fixes the macOS signature.
- dependency-name: "rusty_alloc-api"
versions: [">= 2"]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: nix
directory: /
schedule:
interval: monthly
- package-ecosystem: npm
directory: /web
schedule:
interval: monthly
open-pull-requests-limit: 3
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo}"
template=".github/ISSUE_TEMPLATE/agent-task.yml"
retired_workflows=(
".github/workflows/v0868-milestone-sync.yml"
".github/workflows/agent-task-labels.yml"
)
fail() {
echo "agent-task metadata contract failed: $*" >&2
exit 1
}
[[ -f "${template}" ]] || fail "missing ${template}"
for retired in "${retired_workflows[@]}"; do
[[ ! -e "${retired}" ]] || fail "retired workflow still exists: ${retired}"
done
if grep -nE '^(title|labels):.*v[0-9]+\.[0-9]+\.[0-9]+' "${template}"; then
fail "agent-task defaults must not pin a release version"
fi
grep -qF 'labels: ["agent-ready"]' "${template}" \
|| fail "agent-task template must opt into agent-ready without a version label"
echo "Agent-task metadata is release-neutral and milestone-safe."
-83
View File
@@ -1,83 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { test } = require("node:test");
const sourceRoot = path.resolve(__dirname, "../..");
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cw-d8-launch-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const repo = path.join(root, "source");
fs.mkdirSync(path.join(repo, "scripts"), { recursive: true });
for (const name of ["dev-test.sh", "dev-cache.sh", "dev-cargo.sh", "with-hermetic-test-home.sh", "build-lock.py"]) {
fs.copyFileSync(path.join(sourceRoot, "scripts", name), path.join(repo, "scripts", name));
fs.chmodSync(path.join(repo, "scripts", name), 0o755);
}
fs.writeFileSync(path.join(repo, "Cargo.toml"), '[workspace]\nmembers = []\n');
const bin = path.join(root, "bin");
const home = path.join(root, "ambient home");
fs.mkdirSync(bin); fs.mkdirSync(home);
const log = path.join(root, "cargo-invocations.jsonl");
fs.writeFileSync(path.join(bin, "cargo"), `#!${process.execPath}
const fs = require("node:fs");
const args = process.argv.slice(2);
if (args.includes("--version")) { console.log("cargo 1.97.0 (source fixture)"); process.exit(0); }
fs.appendFileSync(process.env.D8_INVOCATIONS, JSON.stringify({args, home: process.env.HOME, cache: process.env.CODEWHALE_CACHE_ROOT}) + "\\n");
if (args.includes("build") && process.env.D8_BUILD_FAILURE) process.exit(Number(process.env.D8_BUILD_FAILURE));
console.log("test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s");
`);
fs.writeFileSync(path.join(bin, "rustc"), `#!/bin/sh\nprintf '%s\n' 'rustc 1.97.0 (source fixture)' 'commit-hash: source-fixture'\n`);
fs.writeFileSync(path.join(bin, "rustup"), `#!/bin/sh\nif [ "$1" = which ] && [ "$2" = rustc ]; then printf '%s\n' "$D8_TOOLCHAIN/rustc"; else exit 99; fi\n`);
for (const name of ["cargo", "rustc", "rustup"]) fs.chmodSync(path.join(bin, name), 0o755);
return {
home, log,
run(area, failure) {
const result = spawnSync("sh", [path.join(repo, "scripts/dev-test.sh"), area, "filter with spaces"], {
cwd: repo, encoding: "utf8",
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: home,
USERPROFILE: home, CARGO_HOME: path.join(root, "cargo-home"), RUSTUP_HOME: path.join(root, "rustup-home"),
CODEWHALE_DEV_NEXTEST: "0", CODEWHALE_BUILD_LOCK: "0", CODEWHALE_BUILD_LOCK_HELD: "",
CODEWHALE_CACHE_ROOT: path.join(root, "cache"), D8_INVOCATIONS: log, D8_TOOLCHAIN: bin,
D8_BUILD_FAILURE: failure ? String(failure) : "" },
});
const calls = fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
return { result, calls };
},
};
}
for (const area of ["tui-integration", "tui-cucumber"]) {
test(`${area} builds the canonical executable before isolated acceptance`, (t) => {
const f = fixture(t); const { result, calls } = f.run(area);
assert.equal(result.status, 0, result.stderr + result.stdout);
assert.equal(calls.length, 2);
const build = calls[0].args.slice(calls[0].args.indexOf("build"));
assert.deepEqual(build, ["build", "-p", "codewhale-cli", "--bin", "codewhale", "--locked"]);
const check = calls[1].args.slice(calls[1].args.indexOf("test"));
assert.deepEqual(check, ["test", "-p", "codewhale-tui", "--test", area === "tui-integration" ? "integration" : "cucumber", "--locked", "filter with spaces"]);
for (const call of calls) {
assert.notEqual(call.home, f.home);
assert.equal(fs.existsSync(call.home), false, "temporary test HOME is removed");
assert.ok(call.cache.endsWith("cache"));
}
});
}
test("canonical build failure prevents acceptance and preserves its exit status", (t) => {
const f = fixture(t); const { result, calls } = f.run("tui-integration", 39);
assert.equal(result.status, 39, result.stderr + result.stdout);
assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes("build"));
});
test("library-only verification does not build an executable", (t) => {
const f = fixture(t); const { result, calls } = f.run("config");
assert.equal(result.status, 0, result.stderr + result.stdout);
assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes("test"));
assert.ok(calls[0].args.includes("--lib"));
});
-909
View File
@@ -1,909 +0,0 @@
#!/usr/bin/env node
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { execFileSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..", "..");
const {
allAssetNames,
allReleaseAssetNames,
BUNDLE_ASSET_NAMES,
LEGACY_TUI_BRIDGE_ASSET_NAMES,
} = require(path.join(repoRoot, "npm", "codewhale", "scripts", "artifacts"));
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function valuesForKey(source, key) {
const expression = new RegExp(`^\\s+${key}:\\s+([^#\\s]+)\\s*$`, "gm");
return [...source.matchAll(expression)].map((match) => match[1]);
}
function namedStep(source, name) {
const marker = ` - name: ${name}\n`;
const start = source.indexOf(marker);
assert.notEqual(start, -1, `missing workflow step: ${name}`);
const next = source.indexOf("\n - ", start + marker.length);
return source.slice(start, next === -1 ? source.length : next);
}
const ci = read(".github/workflows/ci.yml");
const nightly = read(".github/workflows/nightly.yml");
const candidate = read(".github/workflows/release-candidate.yml");
const artifacts = read(".github/workflows/release-artifacts.yml");
const release = read(".github/workflows/release.yml");
const parityWorkflow = read(".github/workflows/release-parity.yml");
const republish = read(".github/workflows/release-republish.yml");
const releaseDockerfile = read("packaging/docker/Dockerfile.release");
const cnb = read(".cnb.yml");
const cnbSync = read(".github/workflows/sync-cnb.yml");
const bundles = read("scripts/release/create-release-bundles.sh");
const archiveInstaller = read("scripts/release/install.sh");
const cliDispatcher = read("crates/cli/src/lib.rs");
const runbook = read("docs/RELEASE_RUNBOOK.md");
const ciTestJob = ci.slice(ci.indexOf("\n test:\n"));
assert.match(
ciTestJob,
/matrix\.os == 'ubuntu-latest'.*github\.event_name == 'pull_request'/,
"heavy pull requests must select the real Ubuntu test lane",
);
assert.match(
ciTestJob,
/cargo nextest run --workspace --all-features --locked --profile ci/,
"the Ubuntu pull-request lane must run workspace nextest",
);
assert.match(
ciTestJob,
/name: Linux test location \(CNB\)/,
"the non-PR CNB fallback must be named explicitly",
);
assert.match(
namedStep(ciTestJob, "Build canonical executable for acceptance tests"),
/cargo build -p codewhale-cli --bin codewhale --all-features --locked/,
"Engine acceptance must build the canonical executable first",
);
const npmSmokeJob = ci.match(/^ npm-wrapper-smoke:\n([\s\S]*?)(?=^ \S)/m)?.[1];
assert.ok(npmSmokeJob, "CI must retain the npm-wrapper job");
assert.match(
namedStep(npmSmokeJob, "Build wrapper binaries"),
/run: cargo build --release --locked -p codewhale-cli --bin codewhale/,
);
assert.match(
namedStep(npmSmokeJob, "Smoke wrapper install and delegated entrypoints"),
/run: node scripts\/release\/npm-wrapper-smoke\.js/,
);
const npmSmokeSteps = npmSmokeJob.split(/(?=^ - )/m).slice(1);
// Exercise the workflow's actual Boolean guards. The smoke is manual-only
// (workflow_dispatch); pull requests, pushes and schedules skip the job.
const npmSmokeCases = [
// name, event, heavy, OS, trusted, cache success, execute, Linux deps, CNB
["own PR", "pull_request", true, "ubuntu-latest", true, true, false, false, false],
["fork PR", "pull_request", true, "ubuntu-latest", false, true, false, false, false],
["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, false, false, false],
["light PR", "pull_request", false, "ubuntu-latest", true, true, false, false, false],
["manual Ubuntu", "workflow_dispatch", true, "ubuntu-latest", true, true, true, true, false],
["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, false],
["manual macOS", "workflow_dispatch", true, "macos-latest", true, true, true, false, false],
["main Windows", "push", true, "windows-latest", true, true, false, false, false],
["main cache failure", "push", true, "windows-latest", true, false, false, false, false],
["light main", "push", false, "ubuntu-latest", true, true, false, false, false],
["schedule", "schedule", true, "ubuntu-latest", true, true, false, false, false],
];
// The sccache GitHub Actions backend is main-only, mirroring rust-cache's
// save-if: pull requests never install or enable it (cache bloat, PLAN D).
const sccacheInstallStep = "mozilla-actions/[email protected]";
for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of npmSmokeCases) {
const ref = event === "pull_request" ? "refs/pull/1/merge" : "refs/heads/main";
const onMain = ref === "refs/heads/main";
const installed = execute && onMain;
const context = {
needs: { changes: { outputs: { heavy: String(heavy), trusted: String(trusted) } } },
github: { event_name: event, ref },
matrix: { os },
steps: { sccache: { outcome: installed ? (cache ? "success" : "failure") : "skipped" } },
};
const jobGuard = npmSmokeJob.match(/^ if: (.+)$/m)?.[1];
assert.ok(jobGuard, "the wrapper job must retain its event guard");
const jobEnabled = vm.runInNewContext(jobGuard, context);
for (const step of npmSmokeSteps) {
const name = step.match(/^ - (?:name|uses): (.+)$/m)?.[1];
const guard = step.match(/^ if: (.+)$/m)?.[1];
assert.ok(name && guard, "every wrapper step must have an explicit guard");
let expected = execute;
if (name === "Skip npm wrapper smoke for light change") expected = !heavy;
else if (name === "Install Linux system dependencies") expected = linuxDeps;
else if (name === "Linux smoke location") expected = cnb;
else if (name === sccacheInstallStep) expected = installed;
else if (name === "Enable sccache" || name === "sccache stats") expected = installed && cache;
if (!jobEnabled) expected = false;
assert.equal(
Boolean(jobEnabled && vm.runInNewContext(guard, context)),
expected,
`${label}: ${name} must ${expected ? "execute" : "stay skipped"}`,
);
}
}
// The matrix itself: a manual full-CI dispatch covers Ubuntu and Windows; the
// release artifacts cover macOS.
const npmSmokeMatrix = npmSmokeJob.match(/^ os: \$\{\{ fromJSON\((.+)\) \}\}$/m)?.[1];
assert.ok(npmSmokeMatrix, "the wrapper job must keep an event-keyed OS matrix");
for (const [event, expected] of [
["pull_request", ["ubuntu-latest"]],
["push", ["ubuntu-latest", "windows-latest"]],
["workflow_dispatch", ["ubuntu-latest", "windows-latest"]],
]) {
assert.deepEqual(
JSON.parse(vm.runInNewContext(npmSmokeMatrix, { github: { event_name: event } })),
expected,
`npm wrapper smoke matrix for ${event}`,
);
}
// Change detection: a path Rust embeds or its tests read must never classify
// light, or a "docs-only" edit skips the Rust gates that consume it. Run the
// workflow's own `case` block in bash against every include_str!/include_bytes!
// target in the tree plus the files Rust tests read at runtime.
const heavyCase = ci.match(
/\n( +)case "\$\{path\}" in\n[\s\S]*?\n\1 \*\)\n\1 heavy=true\n\1 ;;\n\1esac\n/,
)?.[0];
assert.ok(heavyCase, "ci.yml must keep the heavy/light change classification case block");
function classify(paths) {
const script = `while IFS= read -r path; do heavy=false\n${heavyCase}\nprintf '%s\\t%s\\n' "$heavy" "$path"; done`;
const out = execFileSync("bash", ["-c", script], { input: `${paths.join("\n")}\n`, encoding: "utf8" });
return new Map(out.trim().split("\n").map((line) => line.split("\t").reverse()));
}
const rustSources = execFileSync("git", ["ls-files", "-z", "--", "*.rs"], { cwd: repoRoot, encoding: "utf8" })
.split("\0")
.filter((file) => file && fs.existsSync(path.join(repoRoot, file)));
const includeTargets = new Set();
const includePattern =
/include_(?:str|bytes)!\s*\(\s*(?:concat!\s*\(\s*(?:env!\s*\(\s*"(\w+)"\s*\)\s*,\s*)?)?"([^"]+)"/g;
for (const file of rustSources) {
const text = fs.readFileSync(path.join(repoRoot, file), "utf8");
for (const [, env, target] of text.matchAll(includePattern)) {
let base = path.dirname(file);
if (env === "CARGO_MANIFEST_DIR") {
while (base !== "." && !fs.existsSync(path.join(repoRoot, base, "Cargo.toml"))) base = path.dirname(base);
} else if (env) {
continue; // OUT_DIR and friends are build outputs, not tracked inputs.
}
const resolved = path.posix.normalize(path.posix.join(base, target.replace(/^\//, "")));
if (!resolved.endsWith(".rs") && fs.existsSync(path.join(repoRoot, resolved))) includeTargets.add(resolved);
}
}
assert.ok(includeTargets.size > 60, `include_str! scan found only ${includeTargets.size} targets`);
assert.ok(
[...includeTargets].some((target) => !target.startsWith("crates/")),
"include_str! scan must see the targets outside crates/ (docs/HOOKS.md, ...)",
);
// Read with fs at test time, not embedded; keep in sync with the ci.yml arm.
const rustTestReads = [
"docs/FLEET_WORKFLOW_TUTORIAL.md",
"docs/examples/fleet-dogfood.toml",
"docs/2512.24601v2.pdf",
];
const mustBeHeavy = [...includeTargets, ...rustTestReads];
const heavyVerdicts = classify(mustBeHeavy);
for (const target of mustBeHeavy) {
assert.equal(heavyVerdicts.get(target), "true", `${target} feeds Rust and must classify heavy`);
}
const lightVerdicts = classify(["README.md", "docs/ARCHITECTURE.md", "CHANGELOG.md", ".github/ISSUE_TEMPLATE/bug.yml"]);
for (const [target, verdict] of lightVerdicts) {
assert.equal(verdict, "false", `${target} is docs-only and must stay light`);
}
console.log(`Change detection OK: ${mustBeHeavy.length} Rust-consumed non-.rs paths classify heavy.`);
console.log(`Wrapper CI guards OK: ${npmSmokeCases.length} event cases, ${npmSmokeSteps.length} steps each.`);
assert.match(ci, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
const manualForceBlock = ci.match(
/if \[\[ "\$\{EVENT_NAME\}" == "workflow_dispatch" \]\]; then([\s\S]*?)\n\s+if \[\[ "\$\{EVENT_NAME\}" == "schedule" \]\]; then/,
);
assert.ok(manualForceBlock, "CI must have a dedicated manual-dispatch force-full branch");
for (const output of ["heavy", "workflow", "mobile", "actions"]) {
assert.match(manualForceBlock[1], new RegExp(`echo "${output}=true"`));
}
assert.match(manualForceBlock[1], /#EXPECTED_SHA.*-ne 40/s);
assert.match(manualForceBlock[1], /actual.*EXPECTED_SHA/s);
const expectedNightlyTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
].sort();
assert.deepEqual([...new Set(valuesForKey(nightly, "target"))].sort(), expectedNightlyTargets);
assert.deepEqual(
[
...valuesForKey(nightly, "primary_artifact"),
...valuesForKey(nightly, "alias_artifact"),
].sort(),
[
"codewhale-linux-x64",
"codew-linux-x64",
"codewhale-linux-arm64",
"codew-linux-arm64",
"codewhale-macos-x64",
"codew-macos-x64",
"codewhale-macos-arm64",
"codew-macos-arm64",
"codewhale-windows-x64.exe",
"codew-windows-x64.exe",
"codewhale-windows-arm64.exe",
"codew-windows-arm64.exe",
].sort(),
);
assert.match(
nightly,
/cargo build --release --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
);
assert.match(nightly, /startsWith\(matrix\.target, 'x86_64-'\).*runner\.arch == 'X64'/s);
assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch == 'ARM64'/s);
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
const nightlyArmStaticSmoke = namedStep(
nightly,
"Verify static Linux ARM64 binary and launch",
);
assert.match(
nightlyArmStaticSmoke,
/matrix\.target == 'aarch64-unknown-linux-musl' && runner\.arch == 'ARM64'/,
);
assert.match(nightlyArmStaticSmoke, /readelf -l "\$\{bin_path\}"/);
assert.match(nightlyArmStaticSmoke, /grep -Fq 'INTERP'/);
assert.match(nightlyArmStaticSmoke, /"\$\{bin_path\}" --version/);
assert.doesNotMatch(nightly, /codewhale-tui/);
assert.doesNotMatch(nightly, /target\/[^\n]*\/codew(?:\.exe)?/);
assert.match(nightly, /cp "\$\{bin_path\}" "\$\{dir\}\/\$\{artifact\}"/);
assert.match(nightly, /cmp -s[\s\S]*nightly-primary[\s\S]*nightly-alias/);
assert.equal((nightly.match(/retention-days: 14/g) || []).length, 2);
assert.match(candidate, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m);
assert.match(candidate, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
assert.match(candidate, /source_sha: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.match(candidate, /^ web:\n/m);
assert.doesNotMatch(
candidate,
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"candidate jobs must checkout GITHUB_SHA, not interpolate the dispatch SHA into ref",
);
assert.match(candidate, /cache-dependency-path: web\/package-lock\.json/);
assert.match(candidate, /package-manager-cache: false/);
assert.match(candidate, /working-directory: web/);
for (const workflow of [candidate, read(".github/workflows/web.yml")]) {
for (const command of ["npm ci", "npm test", "npm run check"]) {
assert.ok(workflow.includes(`run: ${command}\n`), `missing web gate: ${command}`);
}
}
// Both workflows use this gate. Preserve every check and its order: checking
// committed facts after prebuild could silently repair drift before testing it.
assert.deepEqual(JSON.parse(read("web/package.json")).scripts.check.split(" && "), [
"npm run check:facts",
"npm run check:latest-release",
"npm run prebuild",
"npm run check:docs",
"npm run check:tokens",
"npm run lint",
"tsc --noEmit",
"npm run build",
]);
assert.match(candidate, /^ needs: \[resolve, web\]$/m);
assert.match(candidate, /needs\.web\.result == 'success'/);
for (const [label, workflow] of [
["release candidate", candidate],
["shared artifact", artifacts],
]) {
for (const forbidden of [
/contents:\s*write/,
/packages:\s*write/,
/softprops\/action-gh-release/,
/docker\/login-action/,
/docker\/build-push-action/,
/\bgh release\b/,
/\bnpm publish\b/,
/\bcargo publish\b/,
/\bgit push\b/,
]) {
assert.doesNotMatch(workflow, forbidden, `${label} workflow contains publication capability`);
}
}
for (const [label, workflow] of [
["release candidate", candidate],
["shared artifact", artifacts],
["public release", release],
["release republish", republish],
]) {
const remoteActions = [...workflow.matchAll(/^\s+(?:-\s+)?uses:\s+([^@\s]+)@([^#\s]+)/gm)]
.map((match) => ({ action: match[1], ref: match[2] }))
.filter(({ action }) => !action.startsWith("./"));
assert.ok(remoteActions.length > 0, `${label} workflow must exercise pinned actions`);
for (const { action, ref } of remoteActions) {
assert.match(
ref,
/^[0-9a-f]{40}$/,
`${label} action ${action} must use an audited full commit SHA`,
);
}
}
const republishHomebrewJob = republish.match(/\n homebrew:\n([\s\S]*)$/);
assert.ok(republishHomebrewJob, "republish must retain a Homebrew recovery job");
const republishHomebrewCheckout = namedStep(
republishHomebrewJob[0],
"Checkout release infrastructure",
);
assert.match(
republishHomebrewCheckout,
/ref: \$\{\{ github\.event\.repository\.default_branch \}\}/,
"Homebrew recovery must use the repaired default-branch infrastructure",
);
assert.doesNotMatch(
republishHomebrewCheckout,
/needs\.resolve\.outputs\.sha/,
"Homebrew recovery must not resurrect release-tag infrastructure",
);
assert.match(republishHomebrewJob[0], /gh release download "\$\{\{ needs\.resolve\.outputs\.tag \}\}"/);
assert.match(republishHomebrewJob[0], /MANIFEST: \/tmp\/codewhale-artifacts-sha256\.txt/);
assert.match(artifacts, /^ workflow_call:/m);
assert.match(artifacts, /^permissions:\n contents: read$/m);
const expectedTargets = [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"aarch64-linux-android",
"aarch64-unknown-linux-ohos",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
].sort();
assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expectedTargets);
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
assert.match(
releaseMuslBuild,
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
);
const releaseStaticSmoke = namedStep(
artifacts,
"Verify static Linux binaries and launch on matching native runners",
);
assert.match(releaseStaticSmoke, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
assert.match(
releaseStaticSmoke,
/startsWith\(matrix\.target, 'aarch64-'\) && runner\.arch == 'ARM64'/,
);
assert.match(releaseStaticSmoke, /readelf -l "\$\{bin_path\}"/);
assert.match(releaseStaticSmoke, /grep -Fq 'INTERP'/);
assert.match(releaseStaticSmoke, /"\$\{bin_path\}" --version/);
const builtAssetNames = [
...valuesForKey(artifacts, "cli_artifact"),
...valuesForKey(artifacts, "shim_artifact"),
...valuesForKey(artifacts, "compat_tui_artifact"),
];
assert.equal(builtAssetNames.length, 24);
assert.deepEqual(
[...new Set(builtAssetNames)].sort(),
[
...allAssetNames().filter((name) => name !== "codewhale.bat"),
...LEGACY_TUI_BRIDGE_ASSET_NAMES,
].sort(),
);
assert.match(
artifacts,
/stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.compat_tui_artifact \}\}"/,
"legacy TUI bridge assets must be staged from the one compiled codewhale binary",
);
const bundleInvocations = [...bundles.matchAll(
/^bundle (\S+) \\\n\s+\S+ \S+ (tar\.gz|zip) (""|portable)$/gm,
)].map((match) => {
const variant = match[3] === "portable" ? "-portable" : "";
return `codewhale-${match[1]}${variant}.${match[2]}`;
});
assert.deepEqual(bundleInvocations.sort(), [...BUNDLE_ASSET_NAMES].sort());
assert.match(artifacts, /aarch64-pc-windows-msvc/);
assert.match(artifacts, /aarch64-linux-android/);
// HarmonyOS bytes cannot be shipped unsigned: the device refuses to execute an
// unsigned ELF, so the leg must self-sign and prove the signature before
// staging. Assert the order too — signing after staging would publish the
// unsigned copy.
assert.match(artifacts, /aarch64-unknown-linux-ohos/);
const ohosSdk = namedStep(artifacts, "Install OpenHarmony native SDK");
assert.match(ohosSdk, /components: 'native;toolchains'/, "the SDK must include the binary signer");
assert.match(ohosSdk, /cache: false/, "a release must download the SDK, not publish a new cache entry");
// A step header glued onto the previous line because a `run:` block lost its
// trailing newline parses as valid YAML (the header becomes script text) and
// silently drops the step. Every `- name:` in these workflows must start its
// own line.
for (const [file, source] of [
["release-artifacts.yml", artifacts],
["release.yml", release],
["nightly.yml", nightly],
]) {
for (const match of source.matchAll(/- name:/g)) {
const lineStart = source.lastIndexOf("\n", match.index) + 1;
assert.match(
source.slice(lineStart, match.index),
/^\s*$/,
`${file}: a step header must start its own line, not follow other content`,
);
}
}
const ohosBuild = namedStep(artifacts, "Cross-compile and code-sign the OpenHarmony binary");
assert.match(ohosBuild, /matrix\.platform == 'ohos-arm64'/);
assert.match(ohosBuild, /binary-sign-tool\.jar/);
assert.match(ohosBuild, /-selfSign 1/);
assert.match(ohosBuild, /code signature is self-sign/);
assert.match(ohosBuild, /scripts\/ohos-env\.sh/);
assert.ok(
ohosBuild.indexOf("codesign") === -1 ||
ohosBuild.indexOf("binary-sign-tool.jar") < ohosBuild.indexOf("display-sign"),
"the signature must be applied before it is verified",
);
assert.match(artifacts, /codew-windows-arm64\.exe/);
assert.match(artifacts, /CodeWhaleSetup\.exe/);
assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/);
assert.match(artifacts, /^ pin:\n/m);
assert.match(artifacts, /Require source_sha equals github\.sha/);
assert.doesNotMatch(
artifacts,
/ref: \$\{\{ inputs\.source_sha \}\}/,
"artifact jobs must checkout GITHUB_SHA, not interpolate the caller SHA into ref",
);
assert.match(artifacts, /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
assert.equal(
(artifacts.match(/package-manager-cache: false/g) || []).length,
2,
"assemble and smoke must disable setup-node's implicit npm cache",
);
const bundleStep = namedStep(artifacts, "Create and checksum platform archives");
assert.match(bundleStep, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
assert.match(bundleStep, /git show -s --format=%ct "\$\{SOURCE_SHA\}"/);
assert.match(
bundleStep,
/SOURCE_DATE_EPOCH="\$\{source_date_epoch\}"[\s\\]+bash scripts\/release\/create-release-bundles\.sh artifacts bundles/,
);
assert.doesNotMatch(bundleStep, /inputs\.source_sha/);
assert.doesNotMatch(bundleStep, /\bdate\b/, "bundle timestamps must come from the pinned source commit, not wall-clock time");
const rustCacheBlocks = [...artifacts.matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.ok(rustCacheBlocks.length >= 1, "shared artifact workflow must pin rust-cache");
for (const block of rustCacheBlocks) {
assert.doesNotMatch(block, /github\.(event|ref|sha)|inputs\./);
}
// One parity gate, called by the release candidate and the public release,
// and the release refuses a tag without a green RC receipt for its exact SHA.
// The release gate must lint at least as strictly as the merge gate.
function clippyAllows(source, label) {
const command = source.match(/cargo clippy --workspace --all-targets --all-features --locked -- \\\n([\s\S]*?)\n(?! +-)/)?.[1];
assert.ok(command, `${label} must run workspace all-targets clippy`);
return [...command.matchAll(/-A (clippy::\w+)/g)].map((match) => match[1]).sort();
}
assert.deepEqual(
clippyAllows(parityWorkflow, "release-parity.yml"),
clippyAllows(ci, "ci.yml"),
"release-parity.yml clippy allowances must match ci.yml",
);
const parity = parityWorkflow.match(/\n parity:\n([\s\S]*)$/);
assert.ok(parity, "release-parity.yml must define the parity job");
assert.match(parityWorkflow, /^on:\n workflow_call:\n/m, "parity must be a reusable workflow");
for (const [name, source] of [["release.yml", release], ["release-candidate.yml", candidate]]) {
const caller = source.match(/\n parity:\n([\s\S]*?)\n\n/);
assert.ok(caller, `${name} must run the parity job`);
assert.match(caller[1], /name: Parity\n/, `${name}: the RC receipt check matches the "Parity" job name`);
assert.match(caller[1], /uses: \.\/\.github\/workflows\/release-parity\.yml/, `${name} must call the shared parity gate`);
}
assert.match(
namedStep(release, "Require a green release-candidate receipt for this exact SHA"),
/require-rc-receipt\.sh "\$\{GITHUB_REPOSITORY\}" "\$\{SHA\}"/,
);
assert.match(release, /^ resolve:\n(?:.*\n)*? actions: read\n/m, "resolve needs actions: read for the RC receipt");
assert.doesNotMatch(
parity[1],
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"parity must checkout GITHUB_SHA after resolve, not interpolate the tag SHA into ref",
);
assert.match(parity[1], /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
const parityRustCache = [...parity[1].matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.equal(parityRustCache.length, 1, "parity must pin exactly one rust-cache");
assert.doesNotMatch(parityRustCache[0], /github\.(event|ref|sha)|inputs\./);
assert.equal(allReleaseAssetNames().length, 38);
assert.match(release, /^ artifacts:\n/m);
assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
assert.doesNotMatch(release, /^ (build|bundle|windows-installer):/m);
assert.match(release, /name: codewhale-release-assets\n\s+path: artifacts/);
assert.match(release, /files: artifacts\/\*/);
assert.equal(
(release.match(/ensure-release-assets-absent\.js/g) || []).length,
2,
"public release must refuse existing assets before work and immediately before upload",
);
assert.match(release, /overwrite_files:\s*false/);
assert.match(release, /fail_on_unmatched_files:\s*true/);
// Assets land in a draft; the whole verified set becomes public at once, and
// derived channels (the tagged container manifest) follow the canonical release.
assert.match(release, /files: artifacts\/\*\n\s+draft: true\n/);
assert.match(
namedStep(release, "Verify the draft's exact asset set, then publish it at once"),
/verify-release-inventory\.js \\\n\s+--draft --asset-dir artifacts --publish/,
);
const releaseJob = release.match(/\n release:\n([\s\S]*?)\n npm:\n/);
assert.ok(releaseJob, "public release must retain its release job");
assert.match(releaseJob[1], /^ needs: \[artifacts, docker-build, resolve\]$/m);
const dockerJob = release.match(/\n docker:\n([\s\S]*?)\n release:\n/);
assert.ok(dockerJob, "public release must retain its container manifest job");
assert.match(dockerJob[1], /^ needs: \[docker-build, release, resolve\]$/m);
assert.match(dockerJob[1], /needs\.release\.result == 'success'/);
const cnbJob = release.match(/\n cnb:\n([\s\S]*?)\n npm:\n/);
assert.ok(cnbJob, "CNB tag publication must follow the canonical release");
assert.match(cnbJob[1], /^ needs: \[release, resolve\]$/m);
assert.match(cnbJob[1], /needs\.release\.result == 'success'/);
assert.match(cnbJob[1], /uses: \.\/\.github\/workflows\/sync-cnb\.yml/);
assert.doesNotMatch(cnbSync, /^\s+tags:/m, "a tag push alone must not start CNB publication");
assert.match(cnbSync, /^ workflow_call:/m);
const cnbPush = namedStep(cnbSync, "Push triggering ref to CNB");
const cnbPublicGate = cnbPush.indexOf("node scripts/release/verify-release-inventory.js --manifest");
const cnbTagPush = cnbPush.indexOf('scripts/release/mirror-push.sh cnb "tag ${TAG}"');
assert.ok(cnbPublicGate >= 0 && cnbTagPush > cnbPublicGate,
"manual and called tag mirrors must verify the published inventory before pushing");
assert.match(cnbPush, /verify-remote-tag\.sh/);
assert.doesNotMatch(cnbPush, /\+refs\/tags\//, "published mirror tags must not be rewritten");
assert.match(
namedStep(republish, "Require a complete published release"),
/verify-release-inventory\.js --manifest/,
);
assert.match(release, /^ docker-build:\n/m);
assert.match(release, /^ docker:\n/m);
assert.match(release, /runner: ubuntu-latest\n\s+platform: linux\/amd64/);
assert.match(release, /runner: ubuntu-24\.04-arm\n\s+platform: linux\/arm64/);
assert.match(release, /cli_artifact: codewhale-linux-x64/);
assert.match(release, /cli_artifact: codewhale-linux-arm64/);
assert.match(release, /shim_artifact: codew-linux-x64/);
assert.match(release, /shim_artifact: codew-linux-arm64/);
assert.doesNotMatch(
release,
/docker\/setup-qemu-action/,
"public container publication must not funnel both architectures through QEMU",
);
const releaseDockerBytes = namedStep(release, "Verify native release bytes");
assert.match(releaseDockerBytes, /CLI_ARTIFACT: \$\{\{ matrix\.cli_artifact \}\}/);
assert.match(releaseDockerBytes, /SHIM_ARTIFACT: \$\{\{ matrix\.shim_artifact \}\}/);
assert.match(
releaseDockerBytes,
/mv -- "docker-context\/bin\/\$\{CLI_ARTIFACT\}" docker-context\/bin\/codewhale/,
);
assert.match(
releaseDockerBytes,
/mv -- "docker-context\/bin\/\$\{SHIM_ARTIFACT\}" docker-context\/bin\/codew/,
);
assert.match(releaseDockerBytes, /cmp docker-context\/bin\/codewhale docker-context\/bin\/codew/);
const releaseDockerBuild = namedStep(release, "Assemble and push native image by digest");
assert.match(releaseDockerBuild, /context: docker-context/);
assert.match(releaseDockerBuild, /file: infra\/packaging\/docker\/Dockerfile\.release/);
assert.match(releaseDockerBuild, /platforms: \$\{\{ matrix\.platform \}\}/);
assert.match(releaseDockerBuild, /provenance: mode=max/);
assert.match(releaseDockerBuild, /sbom: true/);
assert.match(releaseDockerBuild, /push-by-digest=true/);
const releaseDockerManifest = namedStep(release, "Publish multi-architecture manifest");
assert.match(releaseDockerManifest, /Expected exactly two native image digests/);
assert.match(releaseDockerManifest, /docker buildx imagetools create/);
const releaseDockerSmoke = namedStep(release, "Verify and smoke published container");
assert.match(releaseDockerSmoke, /linux\/amd64/);
assert.match(releaseDockerSmoke, /linux\/arm64/);
assert.match(releaseDockerSmoke, /--entrypoint codewhale/);
assert.match(releaseDockerSmoke, /--entrypoint codew/);
const npmJob = release.match(/\n npm:\n([\s\S]*?)\n homebrew:\n/);
assert.ok(npmJob, "public release must retain a dedicated npm publication job");
assert.match(npmJob[1], /^ needs: \[release, resolve\]$/m);
assert.match(npmJob[1], /needs\.release\.result == 'success'/);
assert.match(npmJob[1], /^ contents: read$/m);
assert.match(npmJob[1], /^ id-token: write$/m);
assert.match(npmJob[1], /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.match(npmJob[1], /fetch-depth: 0/);
assert.match(npmJob[1], /node-version: 24/);
assert.match(npmJob[1], /registry-url: https:\/\/registry\.npmjs\.org/);
assert.match(npmJob[1], /package-manager-cache: false/);
assert.match(npmJob[1], /npm install --global npm@12\.0\.2/);
const npmTagGate = namedStep(release, "Revalidate release tag before npm publish");
const npmAssetGate = namedStep(release, "Revalidate public release assets");
const npmPublish = namedStep(release, "Publish npm wrapper with trusted publishing");
assert.match(npmTagGate, /verify-remote-tag\.sh/);
assert.match(npmAssetGate, /verify-release-assets\.sh/);
assert.match(npmAssetGate, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(npmPublish, /working-directory: npm\/codewhale/);
assert.match(npmPublish, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(npmPublish, /npm publish --access public/);
assert.doesNotMatch(npmJob[1], /NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./);
assert.ok(
release.indexOf("Revalidate public release assets") <
release.indexOf("Publish npm wrapper with trusted publishing"),
"npm publication must follow the public exact-asset gate",
);
assert.match(releaseDockerfile, /^FROM debian:bookworm-slim$/m);
assert.match(releaseDockerfile, /ca-certificates/);
assert.match(releaseDockerfile, /libdbus-1-3/);
assert.match(releaseDockerfile, /COPY .*bin\/codewhale \/usr\/local\/bin\/codewhale/);
assert.match(releaseDockerfile, /COPY .*bin\/codew \/usr\/local\/bin\/codew/);
assert.match(releaseDockerfile, /^USER codewhale$/m);
assert.doesNotMatch(
releaseDockerfile,
/\bcargo\s+build\b|^FROM\s+rust:/m,
"release container assembly must reuse the already-verified release binaries",
);
assert.match(runbook, /release[- ]candidate/i);
assert.match(runbook, /expected_sha/);
assert.match(runbook, /38/);
assert.match(runbook, /does not create a tag/i);
assert.match(runbook, /explicit.*approval/i);
assert.match(runbook, /last[- ]useful[- ]log/i, "runbook must document the last-useful-log rule (#5496)");
assert.match(runbook, /404 logs/i, "runbook must document the 404-log cancellation rule (#5496)");
const cnbRustGates = cnb.match(
/\.rust_workspace_gates_stage: &rust_workspace_gates_stage([\s\S]*?)\n\.linux_rust_gates:/,
);
assert.ok(cnbRustGates, "CNB must retain the shared Rust workspace gate");
assert.match(
cnbRustGates[1],
/timeout: 45m[\s\S]*export CARGO_BUILD_JOBS=1[\s\S]*export CARGO_PROFILE_TEST_DEBUG=0[\s\S]*cargo check --workspace --all-targets --locked[\s\S]*cargo clippy --workspace --all-targets --all-features --locked -- -D warnings[\s\S]*RUST_MIN_STACK=16777216 sh scripts\/with-hermetic-test-home.sh cargo test --workspace --all-features --locked/,
"CNB must serialize the memory-heavy Rust gate and preserve the workspace test stack contract",
);
assert.doesNotMatch(
cnbRustGates[1],
/export (?:HOME|USERPROFILE|CODEWHALE_HOME)=/,
"CNB must reuse the shared test-home boundary without overriding legacy migration fixtures",
);
// Cover every test invocation, including named parity and narrow crate gates.
// These launchers protect production dependencies as well as cfg(test) code.
// `release parity` is 4 rather than 3: parity runs the workspace under nextest
// for the same one-process-per-test isolation CI's lanes use, and keeps a
// separate doctest invocation because nextest does not run doctests. release.yml
// itself runs none: its parity job calls release-parity.yml.
let hermeticInvocations = 0;
for (const [label, workflow, expected] of [
["CI", ci, 6],
["release", release, 0],
["release parity", parityWorkflow, 4],
["CNB", cnb, 3],
]) {
const commands = workflow.split("\n").filter((line) =>
!line.trimStart().startsWith("#") &&
// Exclude only this standalone quoted receipt argument. A printf line
// with command substitution or any appended execution still counts.
line.trim() !== "'command=sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked -- --format=pretty'" &&
/\bcargo (?:test|nextest run)\b/.test(line),
);
assert.equal(commands.length, expected, `${label} must retain every Rust test invocation`);
for (const command of commands) {
assert.match(command, /sh scripts\/with-hermetic-test-home.sh cargo (?:test|nextest run)\b/,
`${label} Rust tests must use the shared test-home boundary`);
}
hermeticInvocations += commands.length;
}
for (const name of ["Run tests", "Run doctests"]) {
const step = namedStep(ciTestJob, name);
assert.match(step, /shell: bash/, `${name} must invoke the POSIX helper on Windows too`);
assert.match(step, /RUST_MIN_STACK: '16777216'/);
}
console.log(`Hermetic Rust workflow invocations OK: ${hermeticInvocations} checks passed.`);
const nextest = read(".config/nextest.toml");
const integrationGroup = nextest.search(/^filter = 'binary\(integration\)'$/m);
const telemetryGroup = nextest.indexOf(
"filter = 'binary(integration) & test(/^telemetry_contract::/)'",
);
const execGroup = nextest.indexOf(
"filter = 'binary(integration) & test(/^exec_persistent_service::/)'",
);
assert.ok(integrationGroup >= 0, "nextest must bound the integration binary");
assert.ok(
telemetryGroup >= 0 && telemetryGroup < integrationGroup,
"telemetry-contract override must precede binary(integration); first matching group wins",
);
assert.ok(
execGroup >= 0 && execGroup < integrationGroup,
"exec_persistent_service override must precede binary(integration); first matching group wins",
);
assert.match(nextest, /exec-persistent-service = \{ max-threads = 1 \}/);
assert.equal(
(cnb.match(/^\s+- \*rust_workspace_gates_stage$/gm) || []).length,
2,
"both CNB Rust pipelines must reuse the constrained workspace gate",
);
const cnbPreflight = cnb.match(
/\.linux_release_preflight: &linux_release_preflight([\s\S]*?)\nmain:/,
);
assert.ok(cnbPreflight, "CNB must retain a dedicated release preflight");
const cnbBuild = cnbPreflight[1].indexOf(
"cargo build --jobs 2 --release --locked -p codewhale-cli",
);
const cnbAlias = cnbPreflight[1].indexOf(
"cp target/release/codewhale target/release/codew",
);
const cnbSmoke = cnbPreflight[1].indexOf("node scripts/release/npm-wrapper-smoke.js");
assert.ok(cnbBuild >= 0, "CNB release preflight must build the consolidated runtime");
assert.ok(cnbAlias > cnbBuild, "CNB release preflight must materialize codew after the build");
assert.ok(cnbSmoke > cnbAlias, "CNB release preflight must materialize codew before smoke");
const cnbTagRelease = cnb.match(/\$:\n tag_push:\n([\s\S]*)$/);
assert.ok(cnbTagRelease, "CNB must retain a tag release pipeline");
const cnbTagStamp = cnbTagRelease[1].indexOf(
'export CODEWHALE_BUILD_SHA="$commit_sha"',
);
const cnbTagBuild = cnbTagRelease[1].indexOf(
"cargo build --jobs 2 --release --locked \\",
);
const cnbTagVersionCheck = cnbTagRelease[1].indexOf(
"./scripts/release/check-versions.sh --require-dated-release",
);
assert.ok(cnbTagVersionCheck >= 0, "CNB publication must reject undated source candidates");
for (const [label, workflow] of [["release-candidate.yml", candidate], ["release.yml", release]]) {
assert.match(
workflow,
/\.\/scripts\/release\/check-versions\.sh --require-dated-release/,
`${label} must reject undated source candidates before building`,
);
}
assert.ok(cnbTagVersionCheck < cnbTagBuild, "CNB must validate release notes before building public assets");
assert.match(cnbTagRelease[1], /checkout_sha="\$\(git rev-parse 'HEAD\^\{commit\}'\)"/);
assert.match(cnbTagRelease[1], /commit_sha="\$\{CNB_COMMIT:-\$\{checkout_sha\}\}"/);
assert.match(cnbTagRelease[1], /CNB_COMMIT[\s\S]*does not match checkout[\s\S]*exit 1/);
assert.ok(cnbTagStamp >= 0, "CNB tag releases must stamp the consolidated runtime");
assert.ok(cnbTagBuild > cnbTagStamp, "CNB tag releases must stamp before compiling");
assert.doesNotMatch(
archiveInstaller,
/cargo install codewhale --locked/,
"glibc recovery must name the published codewhale-cli crate",
);
assert.equal(
(archiveInstaller.match(/cargo install codewhale-cli --locked/g) || []).length,
2,
"both glibc recovery branches must name codewhale-cli",
);
assert.doesNotMatch(
archiveInstaller,
/install_binary "\$SCRIPT_DIR\/[^"]+" "\$BIN_DIR\/codewhale-tui"/,
"archive installs must not write the retired codewhale-tui command",
);
assert.doesNotMatch(
cliDispatcher,
/codewhale_config::auto_model::classify/,
"the CLI dispatcher must leave auto routing to the provider-aware runtime",
);
// #5496: every release-lane job carries an explicit `timeout-minutes`.
//
// GitHub's default is 360 minutes, so an assigned-but-dead runner sits for six
// hours before anything reclaims it — observed on the v0.9.9 train as a job
// stuck `in_progress` with 404 logs. Timeouts are containment, not recovery:
// the runbook keeps the 404-log cancel/rerun rule for infrastructure failures.
//
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
// bounds live in release-artifacts.yml rather than in its callers.
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
assert.notEqual(jobsAt, -1, "workflow must declare jobs");
const offenders = [];
for (let i = jobsAt + 1; i < lines.length; i += 1) {
const header = lines[i].match(/^ ([A-Za-z0-9_-]+):\s*$/);
if (!header) continue;
let reusable = false;
let capped = false;
for (let j = i + 1; j < lines.length; j += 1) {
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
if (/^ uses:/.test(lines[j])) reusable = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
return offenders;
}
assert.deepEqual(
jobsWithoutTimeout("jobs:\n uncapped:\n runs-on: ubuntu-latest\n"),
["uncapped"],
"jobsWithoutTimeout must detect an uncapped job",
);
assert.deepEqual(
jobsWithoutTimeout("jobs:\n reusable:\n uses: ./.github/workflows/reusable.yml\n"),
[],
"jobsWithoutTimeout must skip reusable workflow callers",
);
assert.deepEqual(
jobsWithoutTimeout("jobs:\n capped:\n runs-on: ubuntu-latest\n timeout-minutes: 15\n"),
[],
"jobsWithoutTimeout must accept a capped job",
);
for (const [name, source] of [
["release-candidate.yml", candidate],
["release-artifacts.yml", artifacts],
["release.yml", release],
["release-parity.yml", parityWorkflow],
["release-republish.yml", republish],
["ci.yml", ci],
["nightly.yml", nightly],
]) {
assert.deepEqual(
jobsWithoutTimeout(source),
[],
`${name}: every job must set timeout-minutes (#5496)`,
);
}
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
// keep real margin — a tight bound here fails healthy releases.
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
assert.ok(buildTimeout, "release-artifacts build job must be capped");
assert.ok(
Number(buildTimeout[1]) >= 60,
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
);
function jobTimeout(source, job) {
const match = source.match(
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
return Number(match[1]);
}
// Fast setup and packaging fail quickly. Native builds need the same finite
// cold-build allowance as full CI: both 0.10.1 macOS artifacts hit the old
// 90m compilation cap before reaching their launch and inventory checks.
assert.equal(jobTimeout(candidate, "resolve"), 10);
assert.equal(jobTimeout(candidate, "web"), 15);
assert.equal(jobTimeout(artifacts, "pin"), 10);
assert.ok(
jobTimeout(artifacts, "build") >= jobTimeout(ci, "test"),
"native artifacts must allow the full CI cold-build budget",
);
for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
assert.equal(jobTimeout(artifacts, job), 15, `${job} must keep the 15m packaging cap`);
}
assert.equal(
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
"nightly and release must share the native cold-build allowance",
);
assert.equal(jobTimeout(release, "resolve"), 10);
// The 0.10.1 cold parity build exhausted 45 minutes before tests started.
// Reuse the full CI suite's budget rather than pinning an older release's cap.
assert.ok(
jobTimeout(parityWorkflow, "parity") >= jobTimeout(ci, "test"),
"release parity must allow the full CI suite's cold-build budget",
);
console.log(
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
);
+82 -70
View File
@@ -1,18 +1,11 @@
#!/usr/bin/env bash
# Update the Homebrew tap after a release.
#
# The tap GitHub repo is still Hmbown/homebrew-deepseek-tui until Hunter
# renames it. The formula users type is `codewhale`. The legacy
# `deepseek-tui` formula stays as a deprecated alias for one overlap release.
# Update the Homebrew tap at Hmbown/homebrew-deepseek-tui after a release.
#
# Expected environment:
# TAG – git tag, e.g. "v0.8.31"
# MANIFEST – path to codewhale-artifacts-sha256.txt
# MANIFEST – path to deepseek-artifacts-sha256.txt
# TAP_REPO – owner/repo of the Homebrew tap
# TOKEN – PAT with contents:write on TAP_REPO (required unless
# FORMULA_OUTPUT renders locally for contract tests)
# FORMULA_OUTPUT – optional local render path used by contract tests
# FORMULA_LEGACY_OUTPUT – optional local render path for the alias formula
# TOKEN – PAT with contents:write on TAP_REPO (optional; skips if unset)
set -euo pipefail
@@ -20,9 +13,9 @@ set -euo pipefail
: "${MANIFEST:?}"
: "${TAP_REPO:?}"
if [ -z "${TOKEN:-}" ] && [ -z "${FORMULA_OUTPUT:-}" ]; then
echo "::error::No Homebrew tap token (TOKEN) configured; refusing to skip the tap update." >&2
exit 1
if [ -z "${TOKEN:-}" ]; then
echo "No Homebrew tap token configured; skipping."
exit 0
fi
VERSION="${TAG#v}"
@@ -41,54 +34,72 @@ sha() {
# --- read checksums ---------------------------------------------------
# One compiled runtime exposed under the two supported command names.
SHA_COD_MACOS_ARM="$(sha codewhale-macos-arm64)"
SHA_CODEW_MACOS_ARM="$(sha codew-macos-arm64)"
SHA_COD_MACOS_X64="$(sha codewhale-macos-x64)"
SHA_CODEW_MACOS_X64="$(sha codew-macos-x64)"
SHA_COD_LINUX_ARM="$(sha codewhale-linux-arm64)"
SHA_CODEW_LINUX_ARM="$(sha codew-linux-arm64)"
SHA_COD_LINUX_X64="$(sha codewhale-linux-x64)"
SHA_CODEW_LINUX_X64="$(sha codew-linux-x64)"
readonly SHA_COD_MACOS_ARM SHA_CODEW_MACOS_ARM
readonly SHA_COD_MACOS_X64 SHA_CODEW_MACOS_X64
readonly SHA_COD_LINUX_ARM SHA_CODEW_LINUX_ARM
readonly SHA_COD_LINUX_X64 SHA_CODEW_LINUX_X64
# Canonical dispatcher and TUI
readonly SHA_COD_MACOS_ARM="$(sha codewhale-macos-arm64)"
readonly SHA_TUI_MACOS_ARM="$(sha codewhale-tui-macos-arm64)"
readonly SHA_COD_MACOS_X64="$(sha codewhale-macos-x64)"
readonly SHA_TUI_MACOS_X64="$(sha codewhale-tui-macos-x64)"
readonly SHA_COD_LINUX_ARM="$(sha codewhale-linux-arm64)"
readonly SHA_TUI_LINUX_ARM="$(sha codewhale-tui-linux-arm64)"
readonly SHA_COD_LINUX_X64="$(sha codewhale-linux-x64)"
readonly SHA_TUI_LINUX_X64="$(sha codewhale-tui-linux-x64)"
# Legacy shims (removed in v0.9.0)
readonly SHA_LEG_MACOS_ARM="$(sha deepseek-macos-arm64)"
readonly SHA_LEG_TUI_MACOS_ARM="$(sha deepseek-tui-macos-arm64)"
readonly SHA_LEG_MACOS_X64="$(sha deepseek-macos-x64)"
readonly SHA_LEG_TUI_MACOS_X64="$(sha deepseek-tui-macos-x64)"
readonly SHA_LEG_LINUX_ARM="$(sha deepseek-linux-arm64)"
readonly SHA_LEG_TUI_LINUX_ARM="$(sha deepseek-tui-linux-arm64)"
readonly SHA_LEG_LINUX_X64="$(sha deepseek-linux-x64)"
readonly SHA_LEG_TUI_LINUX_X64="$(sha deepseek-tui-linux-x64)"
# --- temp dirs --------------------------------------------------------
FORMULA_FILE="$(mktemp)"
LEGACY_FILE="$(mktemp)"
TAP_DIR="$(mktemp -d)"
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}" "${LEGACY_FILE}"' EXIT
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}"' EXIT
readonly BASE_URL="https://github.com/codewhale-hq/CodeWhale/releases/download/${TAG}"
# --- generate formula --------------------------------------------------
render_formula() {
local class_name="${1:?}"
local extra_header="${2:-}"
cat << EOF
class ${class_name} < Formula
desc "Agentic terminal for open-source and open-weight coding models"
homepage "https://github.com/codewhale-hq/CodeWhale"
readonly BASE_URL="https://github.com/Hmbown/CodeWhale/releases/download/${TAG}"
cat > "${FORMULA_FILE}" << EOF
class DeepseekTui < Formula
desc "Terminal-native coding agent for DeepSeek V4"
homepage "https://github.com/Hmbown/CodeWhale"
version "${VERSION}"
license "MIT"
depends_on "node"
${extra_header}
on_macos do
if Hardware::CPU.arm?
url "${BASE_URL}/codewhale-macos-arm64", using: :nounzip
sha256 "${SHA_COD_MACOS_ARM}"
resource "codew" do
url "${BASE_URL}/codew-macos-arm64", using: :nounzip
sha256 "${SHA_CODEW_MACOS_ARM}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-macos-arm64", using: :nounzip
sha256 "${SHA_TUI_MACOS_ARM}"
end
resource "legacy-shim" do
url "${BASE_URL}/deepseek-macos-arm64", using: :nounzip
sha256 "${SHA_LEG_MACOS_ARM}"
end
resource "legacy-tui-shim" do
url "${BASE_URL}/deepseek-tui-macos-arm64", using: :nounzip
sha256 "${SHA_LEG_TUI_MACOS_ARM}"
end
else
url "${BASE_URL}/codewhale-macos-x64", using: :nounzip
sha256 "${SHA_COD_MACOS_X64}"
resource "codew" do
url "${BASE_URL}/codew-macos-x64", using: :nounzip
sha256 "${SHA_CODEW_MACOS_X64}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-macos-x64", using: :nounzip
sha256 "${SHA_TUI_MACOS_X64}"
end
resource "legacy-shim" do
url "${BASE_URL}/deepseek-macos-x64", using: :nounzip
sha256 "${SHA_LEG_MACOS_X64}"
end
resource "legacy-tui-shim" do
url "${BASE_URL}/deepseek-tui-macos-x64", using: :nounzip
sha256 "${SHA_LEG_TUI_MACOS_X64}"
end
end
end
@@ -97,46 +108,48 @@ ${extra_header}
if Hardware::CPU.arm?
url "${BASE_URL}/codewhale-linux-arm64", using: :nounzip
sha256 "${SHA_COD_LINUX_ARM}"
resource "codew" do
url "${BASE_URL}/codew-linux-arm64", using: :nounzip
sha256 "${SHA_CODEW_LINUX_ARM}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-linux-arm64", using: :nounzip
sha256 "${SHA_TUI_LINUX_ARM}"
end
resource "legacy-shim" do
url "${BASE_URL}/deepseek-linux-arm64", using: :nounzip
sha256 "${SHA_LEG_LINUX_ARM}"
end
resource "legacy-tui-shim" do
url "${BASE_URL}/deepseek-tui-linux-arm64", using: :nounzip
sha256 "${SHA_LEG_TUI_LINUX_ARM}"
end
else
url "${BASE_URL}/codewhale-linux-x64", using: :nounzip
sha256 "${SHA_COD_LINUX_X64}"
resource "codew" do
url "${BASE_URL}/codew-linux-x64", using: :nounzip
sha256 "${SHA_CODEW_LINUX_X64}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-linux-x64", using: :nounzip
sha256 "${SHA_TUI_LINUX_X64}"
end
resource "legacy-shim" do
url "${BASE_URL}/deepseek-linux-x64", using: :nounzip
sha256 "${SHA_LEG_LINUX_X64}"
end
resource "legacy-tui-shim" do
url "${BASE_URL}/deepseek-tui-linux-x64", using: :nounzip
sha256 "${SHA_LEG_TUI_LINUX_X64}"
end
end
end
def install
bin.install Dir["*"].first => "codewhale"
resource("codew").stage { bin.install Dir["*"].first => "codew" }
resource("tui").stage { bin.install Dir["*"].first => "codewhale-tui" }
resource("legacy-shim").stage { bin.install Dir["*"].first => "deepseek" }
resource("legacy-tui-shim").stage { bin.install Dir["*"].first => "deepseek-tui" }
end
test do
system "#{bin}/codewhale", "--version"
system "#{bin}/codew", "--version"
end
end
EOF
}
render_formula "Codewhale" "" > "${FORMULA_FILE}"
render_formula "DeepseekTui" " deprecate! date: \"2026-08-14\", because: \"renamed to codewhale\"
" > "${LEGACY_FILE}"
if [ -n "${FORMULA_OUTPUT:-}" ]; then
cp "${FORMULA_FILE}" "${FORMULA_OUTPUT}"
echo "Rendered Homebrew formula to ${FORMULA_OUTPUT}"
if [ -n "${FORMULA_LEGACY_OUTPUT:-}" ]; then
cp "${LEGACY_FILE}" "${FORMULA_LEGACY_OUTPUT}"
echo "Rendered legacy Homebrew formula to ${FORMULA_LEGACY_OUTPUT}"
fi
exit 0
fi
# --- push to tap repo --------------------------------------------------
@@ -146,14 +159,13 @@ TAP_URL="https://x-access-token:${ENCODED_TOKEN}@github.com/${TAP_REPO}.git"
git clone --depth 1 "${TAP_URL}" "${TAP_DIR}"
mkdir -p "${TAP_DIR}/Formula"
cp "${FORMULA_FILE}" "${TAP_DIR}/Formula/codewhale.rb"
cp "${LEGACY_FILE}" "${TAP_DIR}/Formula/deepseek-tui.rb"
cp "${FORMULA_FILE}" "${TAP_DIR}/Formula/deepseek-tui.rb"
cd "${TAP_DIR}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/codewhale.rb Formula/deepseek-tui.rb
git add Formula/deepseek-tui.rb
if git diff --cached --quiet; then
echo "Formula unchanged (already at ${VERSION}); nothing to push."
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
manifest="${tmp_dir}/codewhale-artifacts-sha256.txt"
formula="${tmp_dir}/codewhale.rb"
legacy="${tmp_dir}/deepseek-tui.rb"
assets=(
codewhale-macos-arm64
codew-macos-arm64
codewhale-macos-x64
codew-macos-x64
codewhale-linux-arm64
codew-linux-arm64
codewhale-linux-x64
codew-linux-x64
)
for asset in "${assets[@]}"; do
printf '%064d %s\n' 0 "${asset}" >> "${manifest}"
done
TAG=v1.2.3 \
MANIFEST="${manifest}" \
TAP_REPO=Hmbown/homebrew-deepseek-tui \
FORMULA_OUTPUT="${formula}" \
FORMULA_LEGACY_OUTPUT="${legacy}" \
bash "${repo_root}/.github/scripts/update-homebrew-tap.sh"
ruby -c "${formula}" >/dev/null
ruby -c "${legacy}" >/dev/null
grep -Fq 'class Codewhale < Formula' "${formula}"
grep -Fq 'class DeepseekTui < Formula' "${legacy}"
grep -Fq 'deprecate! date: "2026-08-14", because: "renamed to codewhale"' "${legacy}"
grep -Fq 'desc "Agentic terminal for open-source and open-weight coding models"' "${formula}"
grep -Fq 'depends_on "node"' "${formula}"
test "$(grep -Fc 'resource "codew" do' "${formula}")" -eq 4
grep -Fq 'bin.install Dir["*"].first => "codew"' "${formula}"
grep -Fq 'system "#{bin}/codew", "--version"' "${formula}"
if grep -Fq 'codewhale-tui' "${formula}"; then
echo "Homebrew formula must not install the legacy TUI compatibility asset" >&2
exit 1
fi
if grep -Fq 'class DeepseekTui' "${formula}"; then
echo "Primary Homebrew formula must be Codewhale, not DeepseekTui" >&2
exit 1
fi
# Without a token and without a local render target the script must fail,
# never report a skipped tap update as success.
if TAG=v1.2.3 MANIFEST="${manifest}" TAP_REPO=Hmbown/homebrew-deepseek-tui TOKEN= \
bash "${repo_root}/.github/scripts/update-homebrew-tap.sh" >"${tmp_dir}/no-token.log" 2>&1; then
echo "update-homebrew-tap.sh must fail when no tap token is configured" >&2
exit 1
fi
grep -Fq 'No Homebrew tap token' "${tmp_dir}/no-token.log"
echo "update-homebrew-tap tests passed"
+153
View File
@@ -0,0 +1,153 @@
name: Auto-close harvested PRs
# When a commit on main contains a "Harvested from PR #N" line in its
# message, close PR #N with a templated thank-you that links back to
# the merged commit. Solves the long-standing problem where contributor
# PRs whose code lands via maintainer cherry-pick stay open and
# `CONFLICTING` forever, even though their fix is credited in the
# CHANGELOG.
#
# The expected commit-message convention is documented in
# CONTRIBUTING.md. Two patterns are recognised:
#
# * `Harvested from PR #1234 by @username` (preferred)
# * `harvested from #1234` (case-insensitive fallback)
#
# The first match's PR number is closed; multiple PRs can be closed
# per commit by repeating the line. The match runs on the commit
# body only, not on the subject line, so the subject can describe
# the change naturally without baking a number into it.
on:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
# Only one auto-close run at a time so two near-simultaneous main
# pushes can't both try to close the same PR (the second would just
# fail with "Pull request is already closed", harmless but noisy).
concurrency:
group: auto-close-harvested
cancel-in-progress: false
jobs:
close:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# We need at least the commits that this push introduced.
# fetch-depth: 0 is the simplest correct option; the
# alternative (fetching just `before..after`) is fragile
# when force-pushes happen.
fetch-depth: 0
- name: Close PRs referenced by harvested-from lines
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
shell: bash
run: |
set -euo pipefail
# The first push to a fresh branch has BEFORE_SHA = 0000…0000.
# In that case fall back to the latest commit only — we don't
# want to scan the entire history.
if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then
RANGE="${AFTER_SHA}"
RANGE_ARGS=("-1" "${AFTER_SHA}")
else
RANGE="${BEFORE_SHA}..${AFTER_SHA}"
RANGE_ARGS=("${RANGE}")
fi
echo "Scanning commit range: ${RANGE}"
# `git log --format=%H%n%B%n--END--` separates commits with a
# sentinel so multi-line bodies don't get mangled.
mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H")
if [[ ${#commits[@]} -eq 0 ]]; then
echo "No commits in range; nothing to do."
exit 0
fi
declare -A processed_prs=()
for sha in "${commits[@]}"; do
body="$(git log -1 --format=%B "${sha}")"
# Two patterns, both case-insensitive on the keyword:
# "Harvested from PR #1234 by @username" (preferred form)
# "harvested from #1234" (short form)
mapfile -t pr_numbers < <(
printf '%s\n' "${body}" \
| grep -oiE 'harvested from (pr )?#[0-9]+' \
| grep -oE '#[0-9]+' \
| tr -d '#' \
| sort -u || true
)
if [[ ${#pr_numbers[@]} -eq 0 ]]; then
continue
fi
short_sha="${sha:0:12}"
subject="$(git log -1 --format=%s "${sha}")"
for pr in "${pr_numbers[@]}"; do
key="${pr}-${sha}"
if [[ -n "${processed_prs[${key}]:-}" ]]; then
continue
fi
processed_prs[${key}]=1
# Idempotency: skip if the PR is already closed.
state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")"
if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then
echo "PR #${pr} is already ${state}; skipping."
continue
fi
if [[ "${state}" == "MISSING" ]]; then
echo "::warning::PR #${pr} not found or inaccessible; skipping."
continue
fi
author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")"
greeting="Hi"
if [[ -n "${author}" ]]; then
greeting="Thanks @${author}"
fi
# NOTE: this block intentionally avoids `<<EOF` heredocs.
# YAML's `|` block scalar requires consistent indentation,
# but heredoc bodies have to start at column 0 — those two
# constraints can't coexist in the same file. We assemble
# the body with `printf` + `\n` so every line of the
# message lives at the same indent as the surrounding
# shell code.
commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${sha}"
contributing_url="https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md"
body_text="$(printf '%s\n' \
"${greeting} — your contribution landed in [\`${short_sha}\`](${commit_url}) on \`main\`:" \
"" \
"> ${subject}" \
"" \
"Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \
"" \
"If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \
)"
echo "Closing PR #${pr} (harvested in ${short_sha})"
if ! gh pr close "${pr}" \
--repo "${GITHUB_REPOSITORY}" \
--comment "${body_text}"; then
echo "::warning::Failed to close PR #${pr}; continuing"
fi
done
done
echo "Auto-close pass complete."
+24 -114
View File
@@ -1,63 +1,41 @@
name: Create release tag
name: Auto-tag on version bump
# Manual helper that creates `vX.Y.Z` from the current `main` workspace
# version after the release source is frozen. This is intentionally not
# triggered by every version bump on `main`: release prep can land before the
# same-version issue/PR queue is truly empty, and an eager tag leaves later
# same-version work outside the public release anchor.
# When the workspace version on `main` advances past the latest existing
# `vX.Y.Z` tag, push the matching tag automatically. The push then triggers
# `release.yml`, which runs parity, builds binaries, drafts the GitHub
# Release, and publishes the npm wrapper.
#
# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger
# downstream `on: push: tags` workflows (GitHub Actions safety rule). For
# this auto-tag flow to actually fire `release.yml`, store a PAT (or
# fine-grained token) with `contents: write` on this repo as the
# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml`
# does NOT run automatically; run `release.yml` manually for the version.
# does NOT run automatically — you'd have to push the tag again manually
# (`git push origin v$VERSION` from a developer machine) to trigger release.
on:
push:
branches: [main]
paths:
- 'Cargo.toml'
- 'npm/codewhale/package.json'
- 'npm/deepseek-tui/package.json'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: auto-tag-${{ github.ref_name }}
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.sha }}
# Prefer PAT so the resulting tag push triggers release.yml.
# Falls back to GITHUB_TOKEN, which will tag but NOT trigger.
token: ${{ secrets.RELEASE_TAG_PAT || github.token }}
- name: Require main dispatch
id: source
run: |
set -euo pipefail
if [ "${GITHUB_REF_NAME}" != "main" ]; then
echo "::error::Create release tags from main only; rerun this workflow with ref 'main'." >&2
exit 1
fi
intended_sha="$(git rev-parse 'HEAD^{commit}')"
event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")"
if [ "${intended_sha}" != "${event_sha}" ]; then
echo "::error::Checkout ${intended_sha} does not match dispatch SHA ${event_sha}." >&2
exit 1
fi
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
live_main="$(git rev-parse 'refs/remotes/origin/main^{commit}')"
if [ "${intended_sha}" != "${live_main}" ]; then
echo "::error::Dispatch SHA ${intended_sha} is stale; live origin/main is ${live_main}. Rerun from the current main ref." >&2
exit 1
fi
echo "sha=${intended_sha}" >> "${GITHUB_OUTPUT}"
- name: Read workspace version
id: ver
run: |
@@ -66,10 +44,6 @@ jobs:
echo "::error::Could not parse workspace version from Cargo.toml" >&2
exit 1
fi
if ! echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z or X.Y.Z-prerelease)" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "Workspace version: $v"
@@ -77,21 +51,13 @@ jobs:
- name: Check whether tag already exists
id: check
env:
EXPECTED_SHA: ${{ steps.source.outputs.sha }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk 'NR == 1 {print $1}')"
if [ -z "${remote_tag_sha}" ]; then
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk 'NR == 1 {print $1}')"
fi
if [ -n "${remote_tag_sha}" ]; then
if [ "${remote_tag_sha}" != "${EXPECTED_SHA}" ]; then
echo "::error::Tag ${TAG} already exists at ${remote_tag_sha}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists at the intended SHA; nothing to do."
echo "Tag ${TAG} already exists; nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} does not exist; will create."
@@ -99,78 +65,22 @@ jobs:
- name: Verify version consistency
if: steps.check.outputs.exists == 'false'
run: |
./scripts/release/check-versions.sh --require-dated-release || {
echo "::error::Version consistency check failed. Aborting tag creation." >&2
exit 1
}
run: ./scripts/release/check-versions.sh
- name: Create and push tag
id: create
if: steps.check.outputs.exists == 'false'
env:
EXPECTED_SHA: ${{ steps.source.outputs.sha }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
live_main="$(git rev-parse 'refs/remotes/origin/main^{commit}')"
if [ "${live_main}" != "${EXPECTED_SHA}" ]; then
echo "::error::origin/main advanced to ${live_main}; refusing to tag stale ${EXPECTED_SHA}." >&2
exit 1
fi
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk 'NR == 1 {print $1}')"
if [ -z "${remote_tag_sha}" ]; then
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk 'NR == 1 {print $1}')"
fi
if [ -n "${remote_tag_sha}" ]; then
if [ "${remote_tag_sha}" != "${EXPECTED_SHA}" ]; then
echo "::error::Tag ${TAG} appeared at ${remote_tag_sha}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists at the intended SHA after refresh; nothing to do."
exit 0
fi
git tag -f "${TAG}" "${EXPECTED_SHA}"
max_retries=3
retry_count=0
while [ "${retry_count}" -lt "${max_retries}" ]; do
if git push origin "${TAG}"; then
echo "pushed=true" >> "$GITHUB_OUTPUT"
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
exit 0
fi
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk 'NR == 1 {print $1}')"
if [ -z "${remote_tag_sha}" ]; then
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk 'NR == 1 {print $1}')"
fi
if [ -n "${remote_tag_sha}" ]; then
if [ "${remote_tag_sha}" != "${EXPECTED_SHA}" ]; then
echo "::error::Tag ${TAG} appeared during push at ${remote_tag_sha}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} appeared at the intended SHA during push; treating as already handled."
exit 0
fi
retry_count=$((retry_count + 1))
if [ "${retry_count}" -lt "${max_retries}" ]; then
echo "Push attempt ${retry_count} failed; retrying in 10s..."
sleep 10
fi
done
echo "::error::Failed to push tag ${TAG} after ${max_retries} attempts." >&2
exit 1
git tag "${TAG}"
git push origin "${TAG}"
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
- name: Warn if PAT missing
if: steps.create.outputs.pushed == 'true'
if: steps.check.outputs.exists == 'false' && env.HAS_PAT != 'true'
env:
HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }}
run: |
if [ "${HAS_PAT}" != "true" ]; then
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. First confirm no tag-triggered Release run is queued or active, then run 'gh workflow run release.yml --ref ${{ steps.ver.outputs.tag }} -f version=${{ steps.ver.outputs.version }}'."
fi
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Manually re-push the tag from a developer machine, or run 'gh workflow run release.yml --ref ${{ steps.ver.outputs.tag }}'."
-82
View File
@@ -1,82 +0,0 @@
name: Cache janitor
# The Actions cache held 11,099,951,127 bytes across 2,896 entries, past the
# repo's 10 GiB cap, so GitHub evicted live main entries first. A cache is
# readable only from its own ref and the default branch: once a PR closes or
# a release finishes, its refs/pull/N or refs/tags/vX entries are dead weight.
# This deletes them. Branch caches (main included) are never touched; see
# scripts/release/prune-actions-caches.sh.
on:
# pull_request_target so fork PRs get a token that can delete caches. It
# never checks out or runs PR code: the checkout below is the base branch.
pull_request_target:
types: [closed]
workflow_run:
workflows: [Release]
types: [completed]
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
inputs:
dry_run:
description: List what the sweep would delete without deleting it
required: false
default: true
type: boolean
permissions:
contents: read
concurrency:
group: cache-janitor-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.workflow_run.id || 'sweep' }}
cancel-in-progress: false
jobs:
prune:
name: Prune dead caches
if: github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
# Read PR state for the sweep.
pull-requests: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Base-branch script only. Never the PR head.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Prune
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
script=scripts/release/prune-actions-caches.sh
case "${EVENT_NAME}" in
pull_request_target)
"${script}" --ref "refs/pull/${PR_NUMBER}/merge" --ref "refs/pull/${PR_NUMBER}/head"
;;
workflow_run)
# A tag-push Release run reports the tag as head_branch. The
# script refuses anything that is not a refs/tags/<tag> ref.
"${script}" --ref "refs/tags/${RUN_HEAD_BRANCH}"
;;
workflow_dispatch)
if [[ "${DRY_RUN}" == "true" ]]; then
"${script}" --dry-run --sweep
else
"${script}" --sweep
fi
;;
*)
"${script}" --sweep
;;
esac
-39
View File
@@ -1,39 +0,0 @@
name: cargo-deny
permissions: {}
on:
workflow_dispatch: {}
workflow_call: {}
env:
CARGO_TERM_COLOR: always
jobs:
deny:
name: cargo-deny
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
checks:
- advisories
- bans licenses sources
# Prevent sudden announcement of a new advisory from failing CI
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
# rust-toolchain.toml pins `channel = "stable"` with no target, so
# inside the action's alpine (musl) container rustup resolves it to
# `stable-x86_64-unknown-linux-musl` — which the container does not
# have. Without provisioning, the action's `rustup show` step prints
# "error: override toolchain 'stable-x86_64-unknown-linux-musl' is not
# installed" and then auto-installs mid-run (network-dependent ~12s
# detour, hard failure if the download stalls). `rust-version` makes the
# entrypoint run `rustup default stable` (same musl host triple) before
# cargo-deny touches the workspace, so the toolchain file resolves to an
# already-installed toolchain on every run.
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
rust-version: stable
+54 -1382
View File
File diff suppressed because it is too large. Load diff
-61
View File
@@ -1,61 +0,0 @@
# CodeQL advanced setup.
#
# Scans the same languages the repository's default setup scanned (Actions,
# JavaScript/TypeScript, Python, Rust) with the same default query suite, but
# reads .github/codeql/codeql-config.yml so test paths stay out of alerts.
#
# Gated off by default. While the repository uses CodeQL "Default" setup,
# GitHub rejects SARIF uploads from this workflow ("Code Scanning could not
# process the submitted SARIF"), so the analyze job runs only when the
# repository variable CODEQL_ADVANCED_SETUP is 'true'. The founder flips it
# after switching the repository to "Advanced" setup (Settings -> Code
# security -> Code scanning); until then every run skips the job.
name: CodeQL
on:
workflow_dispatch:
permissions: {}
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
name: Analyze (${{ matrix.language }})
if: vars.CODEQL_ADVANCED_SETUP == 'true'
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: rust
build-mode: none
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}
-119
View File
@@ -1,119 +0,0 @@
name: Codewhale PR Review
on:
workflow_dispatch:
inputs:
pr-number:
description: Pull request number
required: true
type: string
permissions: {}
concurrency:
group: codewhale-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr-number }}
cancel-in-progress: false
jobs:
installation:
if: >-
github.actor != 'dependabot[bot]' &&
((github.event_name == 'workflow_dispatch') ||
(github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) ||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(github.event.issue.pull_request || github.event.pull_request) &&
github.event.comment.user.type == 'User' && contains(github.event.comment.body, '@codewhale')))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: write
review:
needs: installation
if: >-
github.actor != 'dependabot[bot]' &&
((github.event_name == 'workflow_dispatch') ||
(github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) ||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(github.event.issue.pull_request || github.event.pull_request) &&
github.event.comment.user.type == 'User' && contains(github.event.comment.body, '@codewhale')))
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
pull-requests: read
outputs:
findings: ${{ steps.review.outputs.findings }}
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
- uses: codewhale-hq/Codewhale@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
id: review
with:
version: 'v0.10.2'
provider: 'deepseek'
model: 'deepseek-flash'
reasoning-effort: 'off'
max-output-tokens: '16384'
pr-number: ${{ inputs.pr-number }}
trigger-phrase: '@codewhale'
allow-forks: 'true'
post: 'false'
env:
DEEPSEEK_API_KEY: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'issue_comment' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.DEEPSEEK_API_KEY || '' }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: steps.review.outputs.findings != ''
with:
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.review.outputs.findings }}
retention-days: 1
if-no-files-found: error
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always() && steps.review.outputs.receipt != ''
with:
name: codewhale-review-receipt-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.review.outputs.receipt }}
retention-days: 14
if-no-files-found: error
publish:
needs: review
if: needs.review.outputs.findings != ''
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
path: findings
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: write
- uses: codewhale-hq/Codewhale/scripts/github-action/publish@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
id: publish
with:
findings: findings/findings.json
pr-number: ${{ inputs.pr-number }}
trigger-phrase: '@codewhale'
allow-forks: 'true'
github-token: ${{ steps.app-token.outputs.token }}
reviewer-login: 'codewhale-agent[bot]'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always() && steps.publish.outputs.receipt != ''
with:
name: codewhale-publication-receipt-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.receipt }}
retention-days: 14
if-no-files-found: error
-70
View File
@@ -1,70 +0,0 @@
name: Main landing gate
# Why this exists
#
# Until 2026-11-08 `ci.yml` ran only on `pull_request` and `workflow_dispatch`,
# so nothing validated a push to `main` and a landing that was red on arrival stayed red
# until the next release candidate run, and every open PR then inherits the
# failure: on 2026-10-29 four commits landed red behind `[skip ci]`
# (`PersistRequest::ClearCheckpoint` became dead, `clippy` failed on
# `codewhale-skills`, and the blocking-calls budget went stale for a file that
# had moved crates) and all nine open PRs looked broken for reasons that had
# nothing to do with their authors' code.
#
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
# which is the checked-in local preflight for exactly the class of breakage that
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
# the dead-code and blocking-calls budgets, generated-asset drift, and the
# feature receipts. Measured at 45 s on a warm Linux runner.
#
# Since 2026-11-08 (founder) the `full-ci` job also dispatches `ci.yml` on every
# push to main, so every gate and the three-OS suite run on each landing. The
# preflight job stays narrow so a red landing still shows up in minutes.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: main-landing-${{ github.sha }}
cancel-in-progress: true
jobs:
preflight:
name: Landing preflight
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
# The preflight diffs against the push's base commit to scope feature
# receipts, and the changelog/budget checks read history.
fetch-depth: 0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: rustfmt
- name: Preflight
env:
BEFORE_SHA: ${{ github.event.before }}
run: bash scripts/preflight.sh --check
full-ci:
name: Dispatch full CI
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
contents: read
steps:
- name: Run ci.yml with every gate on the pushed commit
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref main -f expected_sha="${GITHUB_SHA}"
-55
View File
@@ -1,55 +0,0 @@
name: Marketplace connection
on:
workflow_dispatch:
permissions:
contents: read
jobs:
catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
path: core
persist-credentials: false
- name: Select catalog revision
id: source
working-directory: core
env:
EVENT_NAME: ${{ github.event_name }}
run: |
python3 - <<'PY'
import json, os, re
snapshot = json.load(open('crates/plugins/assets/first-party-marketplace.json'))
revision = snapshot['revision']
assert re.fullmatch(r'[0-9a-f]{40}', revision), 'Invalid marketplace revision'
if os.environ['EVENT_NAME'] in ('schedule', 'workflow_dispatch'):
revision = 'main'
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'revision={revision}\n')
PY
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: codewhale-hq/codewhale-plugin-marketplace
ref: ${{ steps.source.outputs.revision }}
path: marketplace
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
package-manager-cache: false
- name: Check catalog and real bundle manifests
run: node marketplace/scripts/check-marketplace.mjs
- name: Check bundled catalog snapshot
run: python3 core/scripts/sync-marketplace.py --marketplace marketplace --check
- name: Check current skill mirrors
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: |
node marketplace/scripts/check-marketplace.mjs --core core
- name: Explain a drift failure
if: failure()
run: |
echo 'Review upstream changes using docs/PLUGIN_MARKETPLACE.md. Sync source-owned mirrors, rerun checks, regenerate the catalog, and rebuild. This workflow never installs, trusts, enables, or publishes plugins.' >> "$GITHUB_STEP_SUMMARY"
+45 -131
View File
@@ -1,7 +1,8 @@
name: Nightly
# Manual artifact build for final release validation.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
@@ -13,79 +14,69 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
CODEWHALE_BUILD_SHA: ${{ github.sha }}
DEEPSEEK_BUILD_SHA: ${{ github.sha }}
jobs:
build:
name: Build ${{ matrix.platform }}
# Native builds use the same finite cold-build allowance as release/CI.
timeout-minutes: 210
name: Build ${{ matrix.artifact_name }}
strategy:
fail-fast: false
matrix:
include:
# --- codewhale (cli dispatcher, canonical) ---
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
platform: linux-x64
binary: codewhale
primary_artifact: codewhale-linux-x64
alias_artifact: codew-linux-x64
artifact_name: codewhale-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
platform: linux-arm64
target: aarch64-unknown-linux-gnu
binary: codewhale
primary_artifact: codewhale-linux-arm64
alias_artifact: codew-linux-arm64
artifact_name: codewhale-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
binary: codewhale
primary_artifact: codewhale-macos-x64
alias_artifact: codew-macos-x64
artifact_name: codewhale-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
binary: codewhale
primary_artifact: codewhale-macos-arm64
alias_artifact: codew-macos-arm64
artifact_name: codewhale-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
binary: codewhale.exe
primary_artifact: codewhale-windows-x64.exe
alias_artifact: codew-windows-x64.exe
- os: windows-11-arm
target: aarch64-pc-windows-msvc
platform: windows-arm64
binary: codewhale.exe
primary_artifact: codewhale-windows-arm64.exe
alias_artifact: codew-windows-arm64.exe
artifact_name: codewhale-windows-x64.exe
# --- codewhale-tui (TUI runtime, canonical) ---
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: codewhale-tui
artifact_name: codewhale-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary: codewhale-tui
artifact_name: codewhale-tui-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
binary: codewhale-tui
artifact_name: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
binary: codewhale-tui
artifact_name: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: codewhale-tui.exe
artifact_name: codewhale-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
echo "SCCACHE_GHA_ENABLED=true"
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install Linux GNU system dependencies
if: matrix.target == 'x86_64-unknown-linux-gnu'
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
@@ -93,69 +84,9 @@ jobs:
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Install Linux ARM64 musl toolchain
if: matrix.target == 'aarch64-unknown-linux-musl'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable aarch64-unknown-linux-musl
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
# so skip fat LTO; tagged releases keep the full optimized profile via
# the Release workflow. (`codegen-units` is not overridden here --
# [profile.release] already sets 16, so restating it changed nothing.)
#
# RUST_MIN_STACK sizes the stack of the LLVM worker threads rustc
# spawns to run `optimize module <crate>-cgu.N`. With `lto=off` the
# per-CGU optimization pipeline runs while the *library* is compiled
# rather than being deferred to the ThinLTO stage, and one crates/tui
# codegen unit needs more stack than a 1 MiB thread provides. Measured
# on aarch64 by holding the crate and every flag fixed and varying only
# this value: 1 MiB crashes rustc, 2 MiB and 4 MiB succeed. Unix std
# defaults to 2 MiB and passes; windows-11-arm sat under the
# requirement and failed every nightly from 2026-08-16 with
# `thread 'optimize module codewhale_tui...-cgu.13' has overflowed its
# stack`, deterministically on the same codegen unit across all three
# build attempts. This is a property of the crate's size, not of
# Windows, so it is set for every target: at 788k lines in crates/tui
# the other platforms are one refactor away from crossing 2 MiB too.
# The value is reserved address space, not committed memory.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
RUST_MIN_STACK: '16777216'
run: |
for attempt in 1 2 3; do
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli; then
exit 0
fi
if [ "${attempt}" -lt 3 ]; then
echo "Build attempt ${attempt} failed; retrying in 30s..."
sleep 30
fi
done
echo "Build failed after 3 attempts" >&2
exit 1
- name: Verify static Linux ARM64 binary and launch
if: matrix.target == 'aarch64-unknown-linux-musl' && runner.arch == 'ARM64'
shell: bash
run: |
set -euo pipefail
bin_path="target/${{ matrix.target }}/release/${{ matrix.binary }}"
if readelf -l "${bin_path}" | grep -Fq 'INTERP'; then
echo "Expected a static musl binary, but ${bin_path} has an ELF interpreter" >&2
exit 1
fi
"${bin_path}" --version
- name: Smoke binary on matching native runners
if: >-
(startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') ||
(startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64')
shell: bash
run: |
bin_dir="target/${{ matrix.target }}/release"
"${bin_dir}/${{ matrix.binary }}" --version
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Stage artifact
id: stage
shell: bash
@@ -168,34 +99,17 @@ jobs:
exit 1
fi
stage_copy() {
local artifact="$1"
local dir="$2"
mkdir -p "${dir}"
cp "${bin_path}" "${dir}/${artifact}"
cat > "${dir}/nightly-build-info.txt" <<INFO
mkdir -p nightly
cp "${bin_path}" "nightly/${{ matrix.artifact_name }}"
cat > nightly/nightly-build-info.txt <<INFO
repository=${GITHUB_REPOSITORY}
ref=${GITHUB_REF_NAME}
commit=${GITHUB_SHA}
artifact=${artifact}
profile=release-nightly-no-lto
artifact=${{ matrix.artifact_name }}
INFO
}
stage_copy "${{ matrix.primary_artifact }}" nightly-primary
stage_copy "${{ matrix.alias_artifact }}" nightly-alias
cmp -s \
"nightly-primary/${{ matrix.primary_artifact }}" \
"nightly-alias/${{ matrix.alias_artifact }}"
echo "primary_name=${{ matrix.primary_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
echo "alias_name=${{ matrix.alias_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v7
echo "name=${{ matrix.artifact_name }}-${short_sha}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.stage.outputs.primary_name }}
path: nightly-primary/*
retention-days: 14
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.stage.outputs.alias_name }}
path: nightly-alias/*
name: ${{ steps.stage.outputs.name }}
path: nightly/*
retention-days: 14
-56
View File
@@ -1,56 +0,0 @@
name: OpenHarmony
on:
workflow_dispatch:
workflow_call:
permissions:
contents: read
concurrency:
group: ohos-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
cargo-check:
name: cargo check (aarch64-unknown-linux-ohos)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Install Rust target
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-ohos
- name: Install OpenHarmony native SDK
id: ohos-sdk
# Pin the action: it downloads a published OpenHarmony SDK and verifies
# the archive checksum. This job must never substitute host headers or
# a stub sysroot and report that as target support.
uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad
with:
version: '6.1'
components: native
# Save the ~715 MB SDK cache from main only. The action has no
# restore-only mode, and with `cache: true` every PR ref that missed
# main's entry saved its own 715 MB copy; a handful of open PRs
# pushed the repo past the 10 GiB Actions cache cap and evicted the
# Rust caches CI actually depends on. PRs download the SDK instead
# (checksum-verified from the GitHub mirror below).
cache: ${{ github.ref == 'refs/heads/main' }}
mirror: true
- name: Check Codewhale TUI and CLI with the real SDK/sysroot
shell: bash
env:
OHOS_NATIVE_SDK: ${{ steps.ohos-sdk.outputs.ohos_sdk_native }}
run: |
set -euo pipefail
test -x "${OHOS_NATIVE_SDK}/llvm/bin/clang"
test -d "${OHOS_NATIVE_SDK}/sysroot"
. ./scripts/ohos-env.sh
cargo check --target aarch64-unknown-linux-ohos -p codewhale-tui -p codewhale-cli --locked
-111
View File
@@ -1,111 +0,0 @@
name: Pet conformance
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pet-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
portable:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/[email protected]
with:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- uses: dtolnay/rust-toolchain@stable
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- run: npm --prefix pet run sync
- name: Embedded sources match canonical core
run: git diff --exit-code -- crates/tui/src/tui/pet_watch/pet-native.js pet/ios/Resources
# The authored body is checked in five times and nothing kept the copies
# in step, so the Rust core silently carried a different whale from every
# other surface and conformance never passed. Compare them directly: no
# sync step produces these, so `git diff` cannot catch the drift.
- name: One whale body across every surface
run: |
canonical=crates/tui/src/tui/ambient_life/whale-points.tsv
for copy in pet/whale-points.tsv pet/public/whale-points.tsv pet/rs/whale-points.tsv; do
diff -q "$canonical" "$copy" || {
echo "::error file=$copy::diverged from $canonical; the Rust core is canonical"
exit 1
}
done
- run: cargo fetch --locked --manifest-path pet/tui/Cargo.toml
- name: TypeScript and Rust parity, including reduced motion
run: ./pet/verify.sh --no-swift
recorder:
name: recorder (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/[email protected]
with:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- uses: dtolnay/rust-toolchain@stable
if: runner.os == 'macOS'
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
if: runner.os == 'macOS'
- name: Build the standalone shared owner and verify its transport
if: runner.os == 'macOS'
run: |
cargo build --locked -j 2 -p codewhale-cli --bin codewhale
python3 pet/scripts/check-shared.py target/debug/codewhale
- name: Build and exercise the Apple host and PCM boundary
if: runner.os == 'macOS'
run: PET_OWNER_BINARY="$PWD/target/debug/codewhale" ./pet/scripts/check-apple.sh
- name: Package the locally signed macOS app
if: runner.os == 'macOS'
run: ditto -c -k --keepParent pet/macos/CodewhalePet.app codewhale-pet-macos.zip
- uses: actions/upload-artifact@v7
if: runner.os == 'macOS'
with:
name: codewhale-pet-macos
path: codewhale-pet-macos.zip
if-no-files-found: error
android:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/[email protected]
with:
distribution: temurin
java-version: '17'
- name: Enable emulator acceleration on the disposable runner
run: sudo chmod a+rw /dev/kvm
- uses: reactivecircus/[email protected]
with:
api-level: 35
arch: x86_64
target: google_apis
profile: pixel_7
disable-animations: false
working-directory: pet/android
script: ./gradlew --no-daemon assembleDebug connectedDebugAndroidTest lintDebug
- uses: actions/upload-artifact@v7
if: always()
with:
name: codewhale-pet-android
path: |
pet/android/build/outputs/apk/debug/*.apk
pet/android/build/reports/androidTests/
pet/android/build/reports/lint-results-debug.*
-704
View File
@@ -1,704 +0,0 @@
name: Release artifacts
on:
workflow_call:
inputs:
source_sha:
description: Exact 40-character source commit to build
required: true
type: string
version:
description: Workspace version without a v prefix
required: true
type: string
compiled_host_delivery:
description: Explicitly include qualified optional companions; Node stays default
required: false
default: false
type: boolean
compiled_host_run_id:
description: Green official CI run at this exact SHA containing Native image proof
required: false
default: ''
type: string
compiled_host_targets:
description: Comma-separated matching-native targets with qualified CI receipts
required: false
default: linux-x64,macos-arm64,windows-x64
type: string
retention_days:
description: Retention for Actions-only intermediate and assembled artifacts
required: false
default: 7
type: number
secrets:
CODEWHALE_RELEASE_SIGNING_KEY:
description: Ed25519 release key (PEM). Absent means the release ships unsigned.
required: false
permissions:
contents: read
actions: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
# Build identity is the trusted workflow SHA. Callers pass source_sha only
# so `pin` can refuse a mismatch; it must not retarget checkout or caches.
CODEWHALE_BUILD_SHA: ${{ github.sha }}
jobs:
pin:
name: Pin caller SHA to this run
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Require source_sha equals github.sha
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
if [[ "${#SOURCE_SHA}" -ne 40 || "${SOURCE_SHA}" =~ [^0-9a-fA-F] ]]; then
echo "::error::source_sha must be a full 40-character commit SHA." >&2
exit 1
fi
expected="$(printf '%s' "${SOURCE_SHA}" | tr '[:upper:]' '[:lower:]')"
actual="$(printf '%s' "${GITHUB_SHA}" | tr '[:upper:]' '[:lower:]')"
if [[ "${actual}" != "${expected}" ]]; then
echo "::error::Reusable workflow SHA ${actual} does not match source_sha ${SOURCE_SHA}." >&2
exit 1
fi
- name: Require explicit matching-native qualification selection
if: inputs.compiled_host_delivery
env:
QUALIFICATION_RUN: ${{ inputs.compiled_host_run_id }}
HOST_TARGETS: ${{ inputs.compiled_host_targets }}
run: |
set -euo pipefail
[[ "${QUALIFICATION_RUN}" =~ ^[0-9]+$ ]] || { echo 'Enabled host delivery needs an exact green CI run id' >&2; exit 1; }
[[ -n "${HOST_TARGETS}" ]] || { echo 'No compiled host targets selected' >&2; exit 1; }
declare -A seen
IFS=',' read -ra selected <<< "${HOST_TARGETS}"
for target in "${selected[@]}"; do
case "${target}" in linux-x64|linux-arm64|macos-x64|macos-arm64|windows-x64|windows-arm64) ;; *) echo "Unsupported compiled host target ${target}" >&2; exit 1 ;; esac
[[ -z "${seen[${target}]:-}" ]] || { echo "Duplicate compiled host target ${target}" >&2; exit 1; }
seen["${target}"]=1
done
build:
name: Build ${{ matrix.platform }}
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
# compilation. Keep the full CI build budget; packaging stays capped below.
timeout-minutes: 210
# FreeBSD is a source-build target with no prebuilt asset, no npm binary and no CI check
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd).
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
platform: linux-x64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-linux-x64
shim_artifact: codew-linux-x64
compat_tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
platform: linux-arm64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-linux-arm64
shim_artifact: codew-linux-arm64
compat_tui_artifact: codewhale-tui-linux-arm64
- os: ubuntu-latest
target: aarch64-linux-android
platform: android-arm64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-android-arm64
shim_artifact: codew-android-arm64
compat_tui_artifact: codewhale-tui-android-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-macos-x64
shim_artifact: codew-macos-x64
compat_tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-macos-arm64
shim_artifact: codew-macos-arm64
compat_tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
cli_binary: codewhale.exe
shim_binary: codew.exe
cli_artifact: codewhale-windows-x64.exe
shim_artifact: codew-windows-x64.exe
compat_tui_artifact: codewhale-tui-windows-x64.exe
- os: windows-11-arm
target: aarch64-pc-windows-msvc
platform: windows-arm64
cli_binary: codewhale.exe
shim_binary: codew.exe
cli_artifact: codewhale-windows-arm64.exe
shim_artifact: codew-windows-arm64.exe
compat_tui_artifact: codewhale-tui-windows-arm64.exe
# Native HarmonyOS and OpenHarmony. The artifact is useless unsigned:
# the device refuses to execute an unsigned ELF (see
# docs/HarmonyOS.md), so this leg self-signs the binary before staging.
- os: ubuntu-latest
target: aarch64-unknown-linux-ohos
platform: ohos-arm64
cli_binary: codewhale
shim_binary: codew
cli_artifact: codewhale-ohos-arm64
shim_artifact: codew-ohos-arm64
compat_tui_artifact: codewhale-tui-ohos-arm64
runs-on: ${{ matrix.os }}
needs: pin
steps:
# No ref: — GITHUB_SHA only. CodeQL treats workflow_call checkout-with-ref
# and any ref named *sha* as an untrusted checkout (cache-poisoning).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
echo "SCCACHE_GHA_ENABLED=true"
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- name: Add swap so the aarch64 dist build is not killed for memory
if: runner.os == 'Linux' && contains(matrix.target, 'aarch64')
shell: bash
run: |
set -euo pipefail
dir=/mnt
if ! sudo test -w /mnt; then dir=/; fi
avail=$(df -Pk "$dir" | awk 'NR==2 {print int($4 / 1048576)}')
if [ "$avail" -ge 30 ]; then size=16; elif [ "$avail" -ge 14 ]; then size=8; else size=0; fi
if [ "$size" -gt 0 ]; then
sudo fallocate -l "${size}G" "$dir/cw-swapfile"
sudo chmod 600 "$dir/cw-swapfile"
sudo mkswap "$dir/cw-swapfile" >/dev/null
sudo swapon "$dir/cw-swapfile"
fi
free -g
df -h "$dir"
- name: Build static Linux binaries (musl)
if: endsWith(matrix.target, '-unknown-linux-musl')
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable ${{ matrix.target }}
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
shell: bash
env:
ANDROID_NDK_VERSION: 27.2.12479018
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y libclang-dev
ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
linker=""
if [[ -n "${ndk}" ]]; then
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
if [[ -z "${linker}" || ! -x "${linker}" ]]; then
if ! command -v sdkmanager >/dev/null 2>&1; then
echo "sdkmanager is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "${android_home}" ]]; then
echo "ANDROID_HOME or ANDROID_SDK_ROOT is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install "ndk;${ANDROID_NDK_VERSION}"
ndk="${android_home}/ndk/${ANDROID_NDK_VERSION}"
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
ar="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
if [[ ! -x "${linker}" ]]; then
echo "Android linker not found: ${linker}" >&2
exit 1
fi
if [[ ! -x "${ar}" ]]; then
echo "Android archiver not found: ${ar}" >&2
exit 1
fi
{
echo "ANDROID_NDK_ROOT=${ndk}"
echo "ANDROID_NDK_HOME=${ndk}"
echo "CC_aarch64_linux_android=${linker}"
echo "AR_aarch64_linux_android=${ar}"
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${linker}"
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot"
} >> "${GITHUB_ENV}"
- name: Build
if: ${{ !endsWith(matrix.target, '-unknown-linux-musl') && matrix.platform != 'ohos-arm64' }}
shell: bash
run: cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
- name: Install OpenHarmony native SDK
if: matrix.platform == 'ohos-arm64'
id: ohos-sdk
# Pin the action: it downloads a published OpenHarmony SDK and verifies
# the archive checksum, so a broken mirror fails here instead of
# producing a binary linked against host headers.
uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad
with:
version: '6.1'
# `toolchains` carries binary-sign-tool.jar, which signs the binary
# below; a release without it would ship an ELF the device refuses to
# execute.
components: 'native;toolchains'
# One download per release is cheaper than letting a tag save a 715 MB
# cache entry that main's jobs then compete with.
cache: false
mirror: true
- name: Install Java for the OpenHarmony binary signer
if: matrix.platform == 'ohos-arm64'
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
with:
distribution: temurin
java-version: '17'
- name: Cross-compile and code-sign the OpenHarmony binary
if: matrix.platform == 'ohos-arm64'
shell: bash
env:
OHOS_NATIVE_SDK: ${{ steps.ohos-sdk.outputs.ohos_sdk_native }}
run: |
set -euo pipefail
test -x "${OHOS_NATIVE_SDK}/llvm/bin/clang"
test -d "${OHOS_NATIVE_SDK}/sysroot"
# ohos-env.sh also puts the SDK's cmake on PATH, which aws-lc-sys
# needs; without it the build dies inside its CMake probe.
. ./scripts/ohos-env.sh
cargo build --profile dist --locked --target "${{ matrix.target }}" -p codewhale-cli
bin="target/${{ matrix.target }}/dist/${{ matrix.cli_binary }}"
tools="$(dirname "${OHOS_NATIVE_SDK}")/toolchains/lib"
# SDK 6.1 publishes the native `binary-sign-tool` and no jar, while
# DevEco ships the Java build; both take the same arguments.
if [[ -f "${tools}/binary-sign-tool.jar" ]]; then
signer=(java -jar "${tools}/binary-sign-tool.jar")
elif [[ -f "${tools}/binary-sign-tool" ]]; then
chmod +x "${tools}/binary-sign-tool"
signer=("${tools}/binary-sign-tool")
else
echo "no binary-sign-tool in ${tools}; the device refuses an unsigned ELF" >&2
ls -la "${tools}" >&2
exit 1
fi
"${signer[@]}" sign -inFile "${bin}" -outFile "${bin}" -selfSign 1
# Capture then match. `tool | grep -q` cannot be used under
# `pipefail`: grep exits at the first match, the tool takes SIGPIPE,
# and llvm-readelf reports that as exit 74, which reads as a failed
# verification of a good binary.
signature="$("${signer[@]}" display-sign -inFile "${bin}")"
case "${signature}" in
*'code signature is self-sign'*) ;;
*)
echo 'the staged binary is not self-signed; the device would refuse to execute it' >&2
exit 1
;;
esac
kind="$(file -b "${bin}")"
case "${kind}" in
*'ARM aarch64'*) ;;
*)
echo "not an aarch64 ELF: ${kind}" >&2
exit 1
;;
esac
headers="$("${OHOS_NATIVE_SDK}/llvm/bin/llvm-readelf" -l "${bin}")"
case "${headers}" in
*'/lib/ld-musl-aarch64.so.1'*) ;;
*)
echo "unexpected ELF interpreter for ${bin}" >&2
exit 1
;;
esac
- name: Materialize codew command alias
shell: bash
run: |
bin_dir="target/${{ matrix.target }}/dist"
cp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}"
cmp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}"
- name: Verify static Linux binaries and launch on matching native runners
if: >-
endsWith(matrix.target, '-unknown-linux-musl') &&
((startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') ||
(startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64'))
shell: bash
run: |
set -euo pipefail
bin_dir="target/${{ matrix.target }}/dist"
for binary in "${{ matrix.cli_binary }}" "${{ matrix.shim_binary }}"; do
bin_path="${bin_dir}/${binary}"
if readelf -l "${bin_path}" | grep -Fq 'INTERP'; then
echo "Expected a static musl binary, but ${bin_path} has an ELF interpreter" >&2
exit 1
fi
"${bin_path}" --version
done
- name: Smoke binaries on matching native runners
if: >-
matrix.target != 'aarch64-linux-android' &&
((startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') ||
(startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64'))
shell: bash
run: |
bin_dir="target/${{ matrix.target }}/dist"
"${bin_dir}/${{ matrix.cli_binary }}" --version
"${bin_dir}/${{ matrix.shim_binary }}" --version
- name: Windows Terminal clipboard-to-draft acceptance
if: matrix.platform == 'windows-x64'
timeout-minutes: 5
shell: pwsh
run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard"
- name: Retain Windows Terminal clipboard-to-draft evidence
if: ${{ !cancelled() && matrix.platform == 'windows-x64' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-windows-clipboard-${{ github.sha }}
path: ${{ runner.temp }}/windows-clipboard/
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
- name: Stage binaries
shell: bash
run: |
stage_binary() {
local binary="$1"
local artifact="$2"
local bin_path="target/${{ matrix.target }}/dist/${binary}"
if [[ ! -f "${bin_path}" ]]; then
echo "Binary not at ${bin_path}; searching target/ for ${binary}:" >&2
find target -name "${binary}" -type f
exit 1
fi
cp "${bin_path}" "${artifact}"
}
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}"
stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}"
# Compatibility bridge for v0.9.4's hard-coded release
# completeness/updater contract. This is the same runtime, not a
# separately compiled or installed TUI command.
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.compat_tui_artifact }}"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
with:
bun-version: 1.4.0
- name: Stage exact qualified optional host image
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
shell: bash
env:
GH_TOKEN: ${{ github.token }}
QUALIFICATION_RUN: ${{ inputs.compiled_host_run_id }}
TARGET_PLATFORM: ${{ matrix.platform }}
RELEASE_VERSION: ${{ inputs.version }}
RUNTIME_CLOSURE_DIR: ${{ vars.CODEWHALE_COMPILED_HOST_CLOSURE_DIR }}
run: |
set -euo pipefail
proof_dir="${RUNNER_TEMP}/compiled-host-proof-${TARGET_PLATFORM}"
node scripts/release/fetch-compiled-host-proof.js --repo "${GITHUB_REPOSITORY}" --run-id "${QUALIFICATION_RUN}" --source-sha "${GITHUB_SHA}" --target "${TARGET_PLATFORM}" --output "${proof_dir}"
test -n "${RUNTIME_CLOSURE_DIR}" || { echo 'Enabled compiled-host delivery requires reviewed complete local runtime notices/relink-source closure' >&2; exit 1; }
bun_binary="$(bun -p 'process.execPath')"
extension=""
if [[ "${TARGET_PLATFORM}" == windows-* ]]; then extension=.exe; fi
node scripts/release/stage-compiled-host.mjs --bun "${bun_binary}" --compiled-image "${proof_dir}/codewhale-extension-host${extension}" --native-receipt "${proof_dir}/native-receipt.json" --runtime-closure "${RUNTIME_CLOSURE_DIR}/${TARGET_PLATFORM}.json" --target "${TARGET_PLATFORM}" --version "${RELEASE_VERSION}" --source-sha "${GITHUB_SHA}" --output-dir "compiled-host-${TARGET_PLATFORM}"
- name: Check every staged Windows executable import
if: startsWith(matrix.platform, 'windows-')
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$binaries = @('${{ matrix.cli_artifact }}', '${{ matrix.shim_artifact }}', '${{ matrix.compat_tui_artifact }}')
$hostDirectory = 'compiled-host-${{ matrix.platform }}'
if (Test-Path $hostDirectory) {
$binaries += @(Get-ChildItem -Path $hostDirectory -Filter *.exe -Recurse -File | ForEach-Object { $_.FullName })
}
python scripts/release/check-windows-imports.py --expected-machine '${{ matrix.platform }}' --output '${{ runner.temp }}/windows-imports-${{ matrix.platform }}' @binaries
if ($LASTEXITCODE -ne 0) { throw 'Windows static import qualification failed' }
- name: Retain Windows import receipts
if: always() && startsWith(matrix.platform, 'windows-')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: windows-imports-${{ matrix.platform }}-${{ github.sha }}
path: ${{ runner.temp }}/windows-imports-${{ matrix.platform }}/
if-no-files-found: warn
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
with:
name: codewhale-compiled-host-${{ matrix.platform }}
path: |
compiled-host-${{ matrix.platform }}/codewhale-extension-host-*
compiled-host-${{ matrix.platform }}/codewhale-extension-hosts.json
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.cli_artifact }}
path: ${{ matrix.cli_artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.shim_artifact }}
path: ${{ matrix.shim_artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.compat_tui_artifact }}
path: ${{ matrix.compat_tui_artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
bundle:
timeout-minutes: 15
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
path: artifacts
pattern: '*'
- name: Collect only qualified requested compiled hosts
if: inputs.compiled_host_delivery
shell: bash
run: node scripts/release/stage-compiled-host.mjs --collect artifacts --output-dir artifacts/codewhale-compiled-hosts
- name: Create and checksum platform archives
shell: bash
env:
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
source_date_epoch="$(git show -s --format=%ct "${SOURCE_SHA}")"
if [[ ! "${source_date_epoch}" =~ ^[0-9]+$ ]]; then
echo "Could not read a Unix timestamp for source commit ${SOURCE_SHA}" >&2
exit 1
fi
SOURCE_DATE_EPOCH="${source_date_epoch}" \
bash scripts/release/create-release-bundles.sh artifacts bundles
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: codewhale-bundles
path: |
bundles/*.tar.gz
bundles/*.zip
bundles/codewhale-bundles-sha256.txt
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: inputs.compiled_host_delivery
with:
name: codewhale-compiled-hosts
path: artifacts/codewhale-compiled-hosts/*
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
windows-installer:
timeout-minutes: 15
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
path: artifacts
pattern: '*windows-x64*'
- name: Install NSIS
shell: pwsh
run: ./scripts/installer/install-nsis.ps1
- name: Build NSIS installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe"
Copy-Item "artifacts\codew-windows-x64.exe\codew-windows-x64.exe" "scripts\installer\codew.exe"
$makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
if (!(Test-Path $makensis)) {
$makensis = "${env:ProgramFiles}\NSIS\makensis.exe"
}
if (!(Test-Path $makensis)) {
throw "makensis.exe not found after NSIS install"
}
$hostDefine = @()
$hostCatalog = "artifacts\codewhale-compiled-host-windows-x64\codewhale-extension-hosts.json"
if (Test-Path $hostCatalog) {
node -e "const h=require('./npm/codewhale/scripts/compiled-hosts'); const fs=require('fs'); const p=require('path'); const f=process.argv[1]; h.verifyDirectory(p.dirname(f), h.parseCatalog(fs.readFileSync(f),'${{ inputs.version }}'));" $hostCatalog
if ($LASTEXITCODE -ne 0) { throw 'Compiled host payload/catalog verification failed' }
$catalog = Get-Content -Raw $hostCatalog | ConvertFrom-Json
$hostEntry = @($catalog.hosts | Where-Object { $_.target -eq 'windows-x64' })
if ($hostEntry.Count -ne 1) { throw 'No unique qualified Windows host' }
$hostDir = Split-Path $hostCatalog
Copy-Item "$hostDir\$($hostEntry[0].asset)" "scripts\installer\codewhale-extension-host.exe"
Copy-Item "$hostDir\$($hostEntry[0].notices_asset)" "scripts\installer\codewhale-extension-host.LICENSES.txt"
Copy-Item "$hostDir\$($hostEntry[0].source_asset)" "scripts\installer\codewhale-extension-host.relink-source.tar.gz"
Copy-Item $hostCatalog "scripts\installer\codewhale-extension-host.release.json"
$hostDefine = @('/DCOMPILED_HOST=1')
}
Push-Location scripts\installer
& $makensis "/DVERSION=${{ inputs.version }}" @hostDefine "codewhale.nsi"
Pop-Location
if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) {
throw "CodeWhaleSetup.exe was not produced"
}
- name: Check Windows installer and embedded executables
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$evidenceDirectory = '${{ runner.temp }}/windows-installer-imports'
$embeddedDirectory = Join-Path $evidenceDirectory 'embedded'
New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null
& 7z l 'scripts/installer/CodeWhaleSetup.exe' | Out-File (Join-Path $evidenceDirectory 'archive-listing.txt') -Encoding utf8
if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded payload inventory failed' }
& 7z x 'scripts/installer/CodeWhaleSetup.exe' "-o$embeddedDirectory" '$PLUGINSDIR/*.dll' | Out-File (Join-Path $evidenceDirectory 'archive-extraction.txt') -Encoding utf8
if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded plugin extraction failed' }
$plugins = @(Get-ChildItem -Path $embeddedDirectory -Filter *.dll -Recurse -File)
if ($plugins.Name -notcontains 'System.dll' -or $plugins.Name -notcontains 'nsDialogs.dll') { throw 'Expected MUI2 System and nsDialogs plugin payloads' }
$binaries = @(Get-ChildItem -Path scripts/installer -Filter *.exe -File | ForEach-Object { $_.FullName })
if ($binaries.Count -lt 3) { throw 'Expected installer, CLI and shim executables' }
$binaries += @($plugins | ForEach-Object { $_.FullName })
python scripts/release/check-windows-imports.py --output $evidenceDirectory @binaries
if ($LASTEXITCODE -ne 0) { throw 'Windows installer static import qualification failed' }
- name: Retain Windows installer import receipts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: windows-installer-imports-${{ github.sha }}
path: ${{ runner.temp }}/windows-installer-imports/
if-no-files-found: warn
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: CodeWhaleSetup.exe
path: scripts/installer/CodeWhaleSetup.exe
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
assemble:
timeout-minutes: 15
needs: [bundle, windows-installer]
if: ${{ !cancelled() && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
package-manager-cache: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
path: intermediate-artifacts
pattern: '*'
- name: Assemble exact authoritative release inventory
run: node scripts/release/assemble-release-assets.js intermediate-artifacts release-assets
- name: Sign the checksum manifest when the release key is configured
env:
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ -z "${CODEWHALE_RELEASE_SIGNING_KEY}" ]]; then
echo "CODEWHALE_RELEASE_SIGNING_KEY is not configured; this release is unsigned and automatic updates wait for each user's OK."
exit 0
fi
bash scripts/release/sign-release-manifest.sh "${VERSION}" \
release-assets/codewhale-artifacts-sha256.txt \
release-assets/codewhale-artifacts-sha256.txt.sig
node scripts/release/assemble-release-assets.js --verify release-assets
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: codewhale-release-assets
path: release-assets/*
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
compression-level: 0
overwrite: true
smoke:
timeout-minutes: 15
needs: assemble
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
package-manager-cache: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: codewhale-release-assets
path: release-assets
- name: Verify 34-asset bridge inventory and checksum manifests (single binary)
run: node scripts/release/assemble-release-assets.js --verify release-assets
- name: Test release inventory contracts
run: |
node --test scripts/release/assemble-release-assets.test.js
node --test npm/codewhale/test/artifacts.test.js npm/codewhale/test/release-assets.test.js
- name: Render AUR metadata from candidate Linux archives
if: ${{ !contains(inputs.version, '-') }}
run: bash packaging/aur/render.sh release-assets "${RUNNER_TEMP}/codewhale-bin"
- name: Smoke packed npm wrapper against candidate assets
env:
CODEWHALE_SMOKE_ASSETS_DIR: ${{ github.workspace }}/release-assets
run: node scripts/release/npm-wrapper-smoke.js
- name: Record non-public candidate identity
shell: bash
run: |
{
echo "### Release artifact candidate"
echo ""
echo "- Source: \`${{ github.sha }}\`"
echo "- Version metadata: \`${{ inputs.version }}\`"
echo "- Inventory: 7 targets / 34 files (single binary; 7 legacy alias assets)"
echo "- Publication: none (Actions artifact \`codewhale-release-assets\` only)"
} >> "${GITHUB_STEP_SUMMARY}"
-141
View File
@@ -1,141 +0,0 @@
name: Release candidate
# Safe pre-publication artifact proof. This workflow never creates a tag or
# release and never writes to a registry, container repository, tap, or deploy.
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact 40-character commit selected by --ref (must match the dispatch SHA)
required: true
type: string
permissions:
contents: read
concurrency:
group: release-candidate-${{ github.sha }}
cancel-in-progress: false
jobs:
resolve:
name: Resolve exact candidate source
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.source.outputs.sha }}
version: ${{ steps.source.outputs.version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
package-manager-cache: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable 2026-07-18
with:
toolchain: stable
- name: Match dispatch to the requested commit
id: source
shell: bash
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
run: |
set -euo pipefail
if [[ "${#EXPECTED_SHA}" -ne 40 || "${EXPECTED_SHA}" =~ [^0-9a-fA-F] ]]; then
echo "::error::expected_sha must be a full 40-character commit SHA." >&2
exit 1
fi
actual="$(git rev-parse HEAD)"
expected_normalized="$(printf '%s' "${EXPECTED_SHA}" | tr '[:upper:]' '[:lower:]')"
if [[ "${actual}" != "${expected_normalized}" ]]; then
echo "::error::Dispatch resolved to ${actual}, not requested ${EXPECTED_SHA}." >&2
exit 1
fi
workspace_version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
npm_version="$(node -p "require('./npm/codewhale/package.json').version")"
binary_version="$(node -p "require('./npm/codewhale/package.json').codewhaleBinaryVersion")"
if [[ "${workspace_version}" != "${npm_version}" ]]; then
echo "::error::Candidate version drift: workspace=${workspace_version}, npm=${npm_version}, binary=${binary_version}." >&2
exit 1
fi
if [[ "${workspace_version}" != "${binary_version}" ]]; then
echo "::error::Candidate version drift: workspace=${workspace_version}, npm=${npm_version}, binary=${binary_version}." >&2
exit 1
fi
echo "sha=${actual}" >> "${GITHUB_OUTPUT}"
echo "version=${workspace_version}" >> "${GITHUB_OUTPUT}"
- name: Check version and OHOS release contracts
# --require-dated-release, as auto-tag.yml and release.yml run it:
# release.yml only accepts an RC receipt for the exact tag SHA, so an
# RC on an undated "Unreleased candidate" changelog could never
# authorize a release. Fail here, before the long parity and artifact
# builds, instead of at tag time.
run: |
./scripts/release/check-versions.sh --require-dated-release
./scripts/release/check-ohos-deps.sh
- name: Reconfirm clean source snapshot
run: git diff --exit-code
web:
name: Verify exact candidate web surface
timeout-minutes: 15
needs: resolve
if: ${{ !cancelled() && needs.resolve.result == 'success' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
# resolve already proved expected_sha equals GITHUB_SHA. Do not
# interpolate that SHA into checkout or the npm cache key.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# `check:facts` derives each model's `addedAt` from the commit date
# on which its id first appeared in the model declaration paths
# (web/scripts/facts-lib.mjs). A shallow checkout collapses every
# date to the tip commit and the committed facts always read as
# stale; web.yml and ci.yml pin depth 0 for the same reason.
fetch-depth: 0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
run: npm ci
- name: Run web tests
run: npm test
- name: Check exact candidate web surface
env:
GITHUB_TOKEN: ${{ github.token }}
run: npm run check
parity:
# The same gate release.yml runs before publish. release.yml refuses a tag
# unless an RC run for that exact SHA has this job green
# (scripts/release/require-rc-receipt.sh matches the "Parity" name).
name: Parity
needs: resolve
if: ${{ !cancelled() && needs.resolve.result == 'success' }}
uses: ./.github/workflows/release-parity.yml
artifacts:
permissions:
contents: read
actions: read
needs: [resolve, web]
if: ${{ !cancelled() && needs.resolve.result == 'success' && needs.web.result == 'success' }}
uses: ./.github/workflows/release-artifacts.yml
with:
source_sha: ${{ needs.resolve.outputs.sha }}
version: ${{ needs.resolve.outputs.version }}
compiled_host_delivery: ${{ vars.CODEWHALE_COMPILED_HOST_DELIVERY == '1' }}
compiled_host_run_id: ${{ vars.CODEWHALE_COMPILED_HOST_QUALIFICATION_RUN_ID || '' }}
compiled_host_targets: ${{ vars.CODEWHALE_COMPILED_HOST_TARGETS || 'linux-x64,macos-arm64,windows-x64' }}
retention_days: 7
secrets:
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
-133
View File
@@ -1,133 +0,0 @@
name: Release parity
# The one parity gate. release-candidate.yml and release.yml both call this,
# so the SHA an RC validates has passed exactly the gate that publish runs.
# Before this was shared, the RC skipped parity: RC 35707500620 was green on
# 3e8bf29946, Release 35728585975 then failed parity on that SHA, and v0.10.0
# was re-pointed to 1be1a703b, which docs/RELEASE_RUNBOOK.md forbids.
#
# Checks out the caller's GITHUB_SHA. Callers must verify that SHA first.
on:
workflow_call:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
parity:
name: Workspace parity
# Cold check, Clippy, executable and test builds exceeded the old 45m
# limit before the suite started. Retain the full CI test-job budget.
timeout-minutes: 210
runs-on: ubuntu-latest
steps:
# Every caller's resolve job already proved GITHUB_SHA equals the
# candidate or tag commit. Do not interpolate a SHA into checkout or
# cache keys —
# CodeQL treats a *sha* ref as an untrusted checkout on workflow_dispatch
# (default-branch cache write).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
echo "SCCACHE_GHA_ENABLED=true"
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
sh scripts/prepare-linux-test-sandbox.sh
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- uses: taiki-e/install-action@e88e69ecdb9658bd172693dcdc2c84e7f0ab6a11 # nextest
with:
tool: nextest
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
run: cargo check --workspace --all-targets --locked
- name: OHOS dependency graph
run: ./scripts/release/check-ohos-deps.sh
- name: Clippy
# Same lint set as ci.yml's "Run clippy": collapsible_if and
# assertions_on_constants were removed there on purpose, so allowing
# them here made the release gate weaker than the merge gate.
run: |
cargo clippy --workspace --all-targets --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or
- name: Build headroom
# Same fixed ephemeral-runner provisioning as CI Safety, Lint, and Test.
# Resource headroom is preventive; a shutdown alone does not prove OOM.
shell: bash
run: bash scripts/prepare-ubuntu-build-headroom.sh
- name: Build canonical executable for acceptance tests
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
- name: Workspace tests
# Same test binaries as `cargo test`, run by cargo-nextest: one process
# per test. This gate used libtest, where every test shares one process,
# so a test that mutates process-global state leaks into its neighbours.
# It failed on five such tests — deterministically, and on a different
# set on different machines — while CI's nextest lanes were green on the
# same source, and every one of them passes in isolation. Match the lane
# that gates every merge; doctests are the next step, because nextest
# does not run them.
# The runner has been shut down during this step's build on several
# candidates (exit 143, no test result). The same headroom trace as
# ci.yml's test step records whether memory or disk ran out, starting
# with what the earlier check and clippy steps left behind.
shell: bash
run: |
echo "[headroom] before tests: $(free -m | awk '/^Mem:/{print "mem used " $3 "/" $2 " MiB"} /^Swap:/{print "swap used " $3 "/" $2 " MiB"}' | paste -sd ' ') disk free $(df -h / | awk 'NR==2{print $4}') target $(du -sh target 2>/dev/null | cut -f1)"
( while sleep 30; do
echo "[headroom] $(free -m | awk '/^Mem:/{print "mem used " $3 "/" $2 " MiB"} /^Swap:/{print "swap used " $3 "/" $2 " MiB"}' | paste -sd ' ') disk free $(df -h / | awk 'NR==2{print $4}')"
done ) &
monitor=$!
status=0
sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci || status=$?
kill "$monitor" 2>/dev/null || true
exit "$status"
env:
CARGO_BUILD_JOBS: '1'
# Match the CI test lane: test threads get the same stack the product
# gives itself (main.rs CODEWHALE_MAIN_STACK_BYTES). See the note in
# ci.yml's "Run tests" step. Without it this gate runs the deep
# engine/runtime futures on a stack that never ships.
RUST_MIN_STACK: '16777216'
- name: Workspace doctests
run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
env:
RUST_MIN_STACK: '16777216'
- name: Protocol schema parity
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-protocol --test parity_protocol --locked
- name: State persistence parity
run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-state --test parity_state --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
-216
View File
@@ -1,216 +0,0 @@
name: Release Republish Channels
# Recovery for derived distribution channels — the container image and the
# Homebrew tap — when a released tag has them missing or stale.
#
# This is a separate workflow, dispatched from the DEFAULT BRANCH, on purpose.
# `workflow_dispatch` reads its input schema from the workflow file at the ref
# being dispatched, and `release.yml` requires being dispatched from the tag
# itself. Any recovery option added to `release.yml` is therefore unusable on
# tags that predate it — which is every tag that could ever need recovery.
# Taking the tag as an input sidesteps that entirely.
#
# It never writes GitHub Release bytes. `release.yml` owns those, and they stay
# immutable; this only (re)publishes channels derived from an existing release.
on:
workflow_dispatch:
inputs:
version:
description: 'Released version to republish, without v (e.g. 0.9.1). The tag must already exist and have a published GitHub Release.'
required: true
type: string
channels:
description: 'Which derived channels to republish.'
required: true
default: 'docker+homebrew'
type: choice
options:
- docker+homebrew
- docker
- homebrew
concurrency:
group: release-republish-${{ inputs.version }}
cancel-in-progress: false
permissions:
contents: read
jobs:
resolve:
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
sha: ${{ steps.release.outputs.sha }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
- name: Resolve released tag
id: release
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if ! [[ "${INPUT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version '${INPUT_VERSION}' must use X.Y.Z." >&2
exit 1
fi
tag="v${INPUT_VERSION}"
if ! git rev-parse --verify "refs/tags/${tag}^{commit}" >/dev/null 2>&1; then
echo "::error::Tag ${tag} does not exist." >&2
exit 1
fi
sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
{
echo "tag=${tag}"
echo "sha=${sha}"
echo "version=${INPUT_VERSION}"
} >> "${GITHUB_OUTPUT}"
echo "Resolved ${tag} -> ${sha}"
- name: Require a complete published release
# The inverse of release.yml's immutability guard. That one refuses to
# run when assets exist; this one refuses to run unless the published
# release carries exactly the assets its own checksum manifest lists,
# each fully uploaded, because a channel derived from a partial set
# would advertise bytes that are not there. The tag may predate the
# current inventory, so its manifest, not today's list, is the check.
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
run: node scripts/release/verify-release-inventory.js --manifest "${GITHUB_REPOSITORY}" "${TAG}"
- name: Verify the remote tag still points at this commit
env:
EXPECTED_SHA: ${{ steps.release.outputs.sha }}
TAG: ${{ steps.release.outputs.tag }}
run: |
./scripts/release/verify-remote-tag.sh \
"https://github.com/${GITHUB_REPOSITORY}.git" \
"${TAG}" \
"${EXPECTED_SHA}"
docker:
timeout-minutes: 30
needs: resolve
if: ${{ contains(inputs.channels, 'docker') }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
path: source
- name: Checkout release infrastructure
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
path: infra
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image name
id: image
shell: bash
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Revalidate release tag before container publish
env:
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
./infra/scripts/release/verify-remote-tag.sh \
"https://github.com/${GITHUB_REPOSITORY}.git" \
"${TAG}" \
"${EXPECTED_SHA}"
- name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
env:
DOCKER_BUILD_RECORD_UPLOAD: false
DOCKER_BUILD_SUMMARY: false
with:
context: source
file: infra/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CODEWHALE_BUILD_SHA=${{ needs.resolve.outputs.sha }}
tags: |
${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }}
${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.version }}
${{ steps.image.outputs.name }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke published container entrypoints
shell: bash
env:
IMAGE: ${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
docker pull "${IMAGE}"
docker run --rm --entrypoint codewhale "${IMAGE}" --version
docker run --rm --entrypoint codew "${IMAGE}" --version
homebrew:
timeout-minutes: 20
needs: resolve
if: ${{ contains(inputs.channels, 'homebrew') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check Homebrew tap token
id: homebrew-token
env:
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: |
if [[ -z "${TOKEN:-}" ]]; then
echo "::error::No Homebrew tap token configured; cannot republish the tap." >&2
exit 1
fi
# Recovery logic must come from the current protected default branch.
# The released bytes remain pinned by the tag and checksum manifest;
# checking out the old tag here would also restore the bug being repaired.
- name: Checkout release infrastructure
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Download checksum manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${{ needs.resolve.outputs.tag }}" \
--repo "${{ github.repository }}" \
--pattern 'codewhale-artifacts-sha256.txt' \
--dir /tmp
- name: Revalidate release tag before Homebrew tap write
env:
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
./scripts/release/verify-remote-tag.sh \
"https://github.com/${GITHUB_REPOSITORY}.git" \
"${TAG}" \
"${EXPECTED_SHA}"
- name: Update Homebrew tap
env:
TAG: ${{ needs.resolve.outputs.tag }}
MANIFEST: /tmp/codewhale-artifacts-sha256.txt
TAP_REPO: Hmbown/homebrew-deepseek-tui
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: bash .github/scripts/update-homebrew-tap.sh
File diff suppressed because it is too large. Load diff
-90
View File
@@ -1,90 +0,0 @@
name: Security audit
permissions: {}
on:
workflow_dispatch: {}
workflow_call: {}
env:
CARGO_TERM_COLOR: always
jobs:
self-audit:
name: cargo-audit
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
run: cargo audit
npm-audit:
name: npm-audit (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
# Every committed package-lock.json, including build-only tooling.
include:
- { name: root, directory: . }
- { name: web, directory: web }
- { name: telemetry-ingest, directory: telemetry-ingest }
- { name: extension-host, directory: crates/engine/extension-host }
- { name: vscode, directory: extensions/vscode }
- { name: feishu-bridge, directory: integrations/feishu-bridge }
- { name: telegram-bridge, directory: integrations/telegram-bridge }
- { name: wecom-bridge, directory: integrations/wecom-bridge }
- { name: pet, directory: pet }
defaults:
run:
working-directory: ${{ matrix.directory }}
steps:
- uses: actions/checkout@v7
- uses: actions/[email protected]
with:
node-version: '22'
# Audit the complete committed lock, including build dependencies, and
# keep the raw report. Runtime compatibility remains owned by surface CI.
- name: Audit locked dependencies
if: matrix.name != 'web' && matrix.name != 'vscode'
run: |
set +e
npm audit --json > "${{ runner.temp }}/npm-audit.json"
status=$?
set -e
node -e '
const r = require(process.argv[1]);
if (r.error || !r.metadata?.vulnerabilities) throw new Error("Incomplete dependency audit");
console.log(JSON.stringify(r.metadata.vulnerabilities));
' "${{ runner.temp }}/npm-audit.json"
exit "$status"
# The website also verifies its installed, reviewed build dependency
# mitigation (web/docs/DEPENDENCY_SECURITY.md); the raw report keeps every
# upstream finding.
- name: Install and verify website build dependency mitigation
if: matrix.name == 'web'
run: npm ci --no-audit
- name: Audit website with verified build dependency mitigation
if: matrix.name == 'web'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
# The VS Code extension's packager toolchain carries one unfixed high
# advisory that never runs for users (DEPENDENCY_SECURITY.md at the repo
# root). It is admitted by name; anything else fails the job.
- name: Audit extension with reviewed packager advisory acceptance
if: matrix.name == 'vscode'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: npm-audit-${{ matrix.name }}
path: ${{ runner.temp }}/npm-audit.json
if-no-files-found: error
+21 -16
View File
@@ -10,10 +10,9 @@ permissions:
jobs:
lockdown:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Auto-close spam patterns from new accounts
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
@@ -27,6 +26,11 @@ jobs:
const blob = `${issue.title || ''}\n${issue.body || ''}`;
const patterns = [
/\bcrypto\b/i,
/\bairdrop\b/i,
/\bnft\b/i,
/\bpresale\b/i,
/\busdt\b/i,
/\btg\s*@/i,
/\btelegram\s+@/i,
/\bt\.me\//i,
@@ -35,23 +39,24 @@ jobs:
/\bguest\s+post/i,
/\bbacklink/i,
/\bbuy\s+followers/i,
/\bjoin\s+our\s+(community|server|group)/i,
/\bpromot[ei]\s+your\b/i,
];
const hit = patterns.find(p => p.test(blob));
if (!hit) return;
// Label and close; no comment (founder, 2026-09-22). The label
// description tells a false positive how to get the issue back.
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'spam',
color: 'b60205',
description: 'Auto-closed: spam pattern from a <30-day account. Real report? Reopen with project context',
});
} catch (error) {
if (error.status !== 422) throw error; // 422: label already exists
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'This issue was auto-closed because the title or body matches',
'a spam pattern (paid promotion / unrelated link) and the author',
'account is less than 30 days old. If this is a real bug or',
'feature request, please reopen with a clearer description',
'(in English or 中文) of the project-relevant context.',
].join(' '),
});
await github.rest.issues.update({
owner: context.repo.owner,
@@ -66,4 +71,4 @@ jobs:
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam'],
});
}).catch(() => {}); // ignore if label doesn't exist yet
+11 -10
View File
@@ -12,22 +12,23 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Pinned from actions/stale@v10; update deliberately when refreshing the policy.
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93
- uses: actions/stale@v9
with:
days-before-stale: 14
days-before-close: 7
# No stale or close messages (founder, 2026-09-22). An empty message
# makes actions/stale label and close without commenting (it checks
# `staleIssueMessage.length === 0` at the pinned SHA). The `stale`
# label is the signal; any reply removes it before the close.
stale-issue-message: ''
close-issue-message: ''
stale-issue-message: >
This issue has been inactive for 14 days while waiting on
additional information. It will close automatically in 7 days
unless someone responds. If you still need help, drop a
comment with the requested details and a maintainer can
reopen.
close-issue-message: >
Closing for inactivity. Feel free to comment to reopen if
you can share the requested information.
stale-issue-label: 'stale'
only-labels: 'needs-info'
exempt-issue-labels: 'pinned,keep-open,release-blocker,security'
exempt-issue-labels: 'pinned,keep-open,bug,security'
# Don't touch PRs — `actions/stale` defaults can be aggressive
# there. We only want it for `needs-info` issues.
days-before-pr-stale: -1
+50 -46
View File
@@ -4,9 +4,13 @@ name: Sync to CNB
# so users behind GitHub-blocking networks can fetch the source and tagged
# releases from the Tencent-hosted mirror.
#
# Main pushes mirror to CNB unless the repository variable CNB_MIRROR is
# "disabled"; release tags arrive through release.yml; manual dispatch is the
# fallback.
# Triggers:
# * push to main → mirrors that commit to CNB main
# * tag matching v* → mirrors that tag to CNB
# * release work branches → mirrors release-candidate refs for CNB preflight
# * fix/rebrand branches → mirrors first-party heavy Linux CI refs
# * Tencent release branches → mirrors Feishu/Lighthouse setup branches
# * workflow_dispatch → manual fallback if any of the above fails
#
# Why the rewrite (v0.8.31):
# The previous implementation used the opaque tencentcom/git-sync Docker
@@ -21,30 +25,21 @@ name: Sync to CNB
on:
push:
branches: [main]
branches:
- main
- 'work/v*'
- 'fix/*'
- 'rebrand/*'
- 'work/v*-feishu-*'
- 'work/v*-lighthouse*'
tags: ['v*']
workflow_dispatch: {}
workflow_call:
inputs:
release_tag:
required: true
type: string
source_sha:
required: true
type: string
secrets:
CNB_GIT_TOKEN:
required: true
# Serialize runs per ref. One global group was wrong: with
# cancel-in-progress: false GitHub still keeps only ONE pending run per group
# and cancels it when a newer one queues, so a release tag sync waiting behind
# a main push was dropped by the next branch push. Keyed by ref, each tag gets
# its own group (never superseded), and a pending branch sync is only ever
# replaced by a newer push of the same branch, whose force-push supersedes it.
# The main and tag pushes from auto-tag.yml write different CNB refs, so
# running them concurrently cannot conflict.
# Serialize runs so the back-to-back main-push + tag-push from auto-tag.yml
# don't race each other rebasing onto CNB. cancel-in-progress: false so
# every commit actually arrives — we'd rather queue than drop.
concurrency:
group: cnb-sync-${{ github.ref }}
group: cnb-sync
cancel-in-progress: false
permissions:
@@ -52,21 +47,15 @@ permissions:
jobs:
sync:
if: github.repository_owner == 'codewhale-hq' && (github.event_name != 'push' || vars.CNB_MIRROR != 'disabled')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_sha || github.sha }}
- name: Push triggering ref to CNB
env:
CNB_TOKEN: ${{ secrets.CNB_GIT_TOKEN }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
SOURCE_SHA: ${{ inputs.source_sha || github.sha }}
shell: bash
run: |
set -euo pipefail
@@ -84,19 +73,34 @@ jobs:
# Use a masked alias so the token never appears in log lines.
git remote add cnb "${REMOTE_URL}"
if [[ -n "${RELEASE_TAG}" || "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${RELEASE_TAG:-${GITHUB_REF#refs/tags/}}"
# The tag starts CNB's public release pipeline. Require the
# canonical release even for a manual recovery dispatch.
if ! [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
[[ "$(git rev-parse 'HEAD^{commit}')" != "${SOURCE_SHA}" ]]; then
echo "::error::CNB release source does not match the frozen tag input." >&2
exit 1
fi
./scripts/release/verify-remote-tag.sh \
"https://github.com/${GITHUB_REPOSITORY}.git" "${TAG}" "${SOURCE_SHA}"
node scripts/release/verify-release-inventory.js --manifest "${GITHUB_REPOSITORY}" "${TAG}"
scripts/release/mirror-push.sh cnb "tag ${TAG}" "refs/tags/${TAG}:refs/tags/${TAG}"
# Push with retry on transient failures (CNB rate-limits, DNS
# blips, etc.). Args after `kind` are forwarded to `git push`
# so callers can pass `--force-with-lease`, multiple refspecs,
# etc. without quoting them into one string.
push_with_retry() {
local kind="$1"
shift
local attempt
for attempt in 1 2 3; do
echo "Attempt ${attempt}: pushing ${kind} to CNB"
if git push cnb "$@" 2>&1; then
echo "Successfully pushed ${kind} to CNB"
return 0
fi
if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
echo "::error::Failed to push ${kind} to CNB after 3 attempts" >&2
return 1
}
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
# Release tags may be repointed while rebuilding a failed
# publish attempt. CNB is a one-way mirror, so force the tag
# there to match GitHub instead of failing on "already exists".
push_with_retry "tag ${TAG}" "+refs/tags/${TAG}:refs/tags/${TAG}"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
# Plain --force. The CNB mirror is one-way by design —
# nothing else pushes to it, so there's no contributor work
@@ -108,7 +112,7 @@ jobs:
# That made the lease check spuriously fail with
# `! [rejected] HEAD -> main (stale info)` even when CNB
# was actually behind GitHub.
scripts/release/mirror-push.sh cnb "main" HEAD:refs/heads/main --force
push_with_retry "main" HEAD:refs/heads/main --force
else
# First-party fix/rebrand/release branches are first-class CNB
# sources for heavy Linux CI, release preflight, and
@@ -117,5 +121,5 @@ jobs:
# useful before the branch has merged to main or become a release
# tag.
BRANCH="${GITHUB_REF#refs/heads/}"
scripts/release/mirror-push.sh cnb "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force
push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force
fi
-74
View File
@@ -1,74 +0,0 @@
name: Sync to GitCode
on:
push:
branches: [main]
workflow_dispatch: {}
workflow_call:
inputs:
release_tag:
required: true
type: string
source_sha:
required: true
type: string
secrets:
GITCODE_TOKEN:
required: true
concurrency:
group: gitcode-sync-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
if: github.repository_owner == 'codewhale-hq' && (github.event_name != 'push' || vars.GITCODE_MIRROR == 'enabled')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
ref: ${{ inputs.source_sha || github.sha }}
- name: Push to GitCode
env:
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
GITCODE_USER: ${{ vars.GITCODE_USERNAME || 'HunterBown' }}
GITCODE_REPO: ${{ vars.GITCODE_REPOSITORY || 'HunterBown/Codewhale' }}
RELEASE_TAG: ${{ inputs.release_tag }}
SOURCE_SHA: ${{ inputs.source_sha || github.sha }}
shell: bash
run: |
set -euo pipefail
if [ -z "${GITCODE_TOKEN:-}" ]; then
echo "::error::GITCODE_TOKEN secret is not set; cannot push to GitCode." >&2
exit 1
fi
ENCODED_USER="$(printf '%s' "${GITCODE_USER}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
ENCODED_TOKEN="$(printf '%s' "${GITCODE_TOKEN}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
git remote add gitcode "https://${ENCODED_USER}:${ENCODED_TOKEN}@gitcode.com/${GITCODE_REPO}.git"
if [[ -n "${RELEASE_TAG}" ]]; then
if ! [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
[[ "$(git rev-parse 'HEAD^{commit}')" != "${SOURCE_SHA}" ]]; then
echo "::error::GitCode release source does not match the frozen tag input." >&2
exit 1
fi
./scripts/release/verify-remote-tag.sh \
"https://github.com/${GITHUB_REPOSITORY}.git" "${RELEASE_TAG}" "${SOURCE_SHA}"
scripts/release/mirror-push.sh gitcode "tag ${RELEASE_TAG}" "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
elif [[ "${GITHUB_REF}" != refs/heads/main ]]; then
echo "::error::GitCode mirrors main only; dispatch this workflow from main." >&2
exit 1
elif [[ "${GITHUB_EVENT_NAME}" == workflow_dispatch ]]; then
scripts/release/mirror-push.sh gitcode "main" HEAD:refs/heads/main --force
scripts/release/mirror-push.sh gitcode "release tags" "refs/tags/v*:refs/tags/v*"
else
scripts/release/mirror-push.sh gitcode "main" HEAD:refs/heads/main --force
fi
-41
View File
@@ -1,41 +0,0 @@
name: Telemetry Ingest
# Final validation checks the ingest Worker against its published telemetry
# schema and the client's pinned wire format.
#
# There is deliberately NO deploy job. Publishing this Worker is the owner's
# action, run by hand, and `telemetry_endpoint` stays unset until he has
# deployed and verified. See telemetry-ingest/README.md.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
check:
name: Schema weld, IP guard, and types
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: telemetry-ingest
steps:
- uses: actions/checkout@v7
- uses: actions/[email protected]
with:
node-version: 22
cache: 'npm'
cache-dependency-path: telemetry-ingest/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: TypeScript type check
run: npm run typecheck
- name: Bundle check
# `--dry-run` builds the Worker and resolves its bindings without
# touching a Cloudflare account. It is the step that would have caught
# the entrypoint exporting a non-callable named export.
run: npm run check
+22 -85
View File
@@ -1,110 +1,47 @@
name: Issue and PR triage
name: Issue triage
on:
issues:
types: [opened, reopened]
pull_request_target:
types: [opened, reopened, synchronize]
permissions:
issues: write
pull-requests: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Auto-label by title, body and changed paths
uses: actions/github-script@v9
- name: Auto-label by title and body
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const issue = context.payload.issue || pr;
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = `${title}\n${body}`;
const labels = new Set();
if (pr) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const pathRules = [
[/^crates\/(tui|tui-kit|palette)\//, 'area:tui'],
[/^crates\/(tools\/|engine\/src\/tools\/)/, 'area:tools'],
[/^crates\/(engine|core|runtime|agent|agent-contract|protocol)\/(?!src\/tools\/)/, 'area:core'],
[/^crates\/mcp\//, 'area:mcp'],
[/^crates\/(state|snapshot|memory)\//, 'area:state'],
[/^crates\/(sandbox|execpolicy)\//, 'area:sandbox'],
[/^crates\/(plugins|skills|skill-install|hooks)\//, 'area:plugins'],
[/^(crates\/(app-server|app-server-protocol)|npm\/runtime-sdk)\//, 'area:runtime-api'],
[/^crates\/models\//, 'area:providers'],
[/^(crates\/localization|docs\/zh_hans)\//, 'area:localization'],
[/^(npm\/(codewhale|deepseek-tui)|scripts\/release)\//, 'area:install'],
[/^(\.github|scripts\/ci)\//, 'area:ci'],
[/^apps\/desktop\//, 'area:desktop'],
[/^(apps\/web|web)\//, 'area:web'],
[/^apps\/mobile\//, 'area:mobile'],
];
const areas = new Set();
let docsOnly = files.length > 0;
for (const f of files) {
if (!/^(docs\/|[^/]+\.md$)/.test(f.filename)) docsOnly = false;
for (const [re, name] of pathRules) if (re.test(f.filename)) areas.add(name);
}
if (areas.size <= 4) areas.forEach(name => labels.add(name));
if (docsOnly) labels.add('documentation');
if (/^feat(\(|!|:)/.test(title)) labels.add('enhancement');
if (/^fix(\(|!|:)/.test(title)) labels.add('bug');
if (/^docs(\(|!|:)/.test(title)) labels.add('documentation');
if (/^(chore|refactor|style|build)(\(|!|:)/.test(title)) labels.add('cleanup');
if (/^perf(\(|!|:)/.test(title)) labels.add('performance');
if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) && pr.user.type !== 'Bot') {
labels.add('contributor:external');
}
} else {
// Type
if (/\b(bug|crash|panic|broken|stack ?trace|regression|err(?:or)?|fail(?:ed|ure)?)\b/.test(text)) labels.add('bug');
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(text)) labels.add('enhancement');
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(text)) labels.add('documentation');
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(text)) labels.add('question');
// Type — matched against the TITLE only.
//
// These ran against title+body and mislabelled long, well-specified
// issues. Any body containing an acceptance criterion like "fails
// when a pack drifts" matched the bug rule, and a "How to add a
// locale" heading matched the question rule, so planned work landed
// tagged `bug, question`. A title states the type; a body just
// discusses it. `error`/`fail` are dropped entirely — they describe
// what almost every issue talks about, not what any issue *is*.
if (/\b(bug|crash|panic|broken|stack ?trace|regression)\b/.test(title)) labels.add('bug');
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(title)) labels.add('enhancement');
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(title)) labels.add('documentation');
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(title)) labels.add('question');
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
if (/[぀-ヿ㐀-鿿가-힯]/.test(issue.title || '')) labels.add('lang:zh');
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
if (/[぀-ヿ㐀-鿿가-힯]/.test(issue.title || '')) labels.add('lang:zh');
// Areas (path-driven hints)
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:sandbox');
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
if (/\bdesktop app\b|electron/.test(text)) labels.add('area:desktop');
if (/app\.codewhale\.net|\bweb app\b|codewhale web/.test(text)) labels.add('area:web');
if (/\bmobile\b|\bios\b|\bandroid\b/.test(text)) labels.add('area:mobile');
if (/screen ?reader|accessib|a11y|voiceover|\bnvda\b|keyboard navigation/.test(text)) labels.add('accessibility');
if (/windows/.test(text)) labels.add('os:windows');
if (/freebsd/.test(text)) labels.add('os:freebsd');
if (/\bohos\b|harmonyos/.test(text)) labels.add('os:ohos');
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
}
// Areas (path-driven hints)
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:execpolicy');
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
if (/windows/.test(text)) labels.add('os:windows');
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
if (labels.size === 0) return;
-125
View File
@@ -1,125 +0,0 @@
name: Web Frontend
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('web-pr-{0}', github.event.pull_request.number) || format('web-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# `check:facts` derives each model's `addedAt` from the commit date on
# which its id first appeared in the model declaration paths
# (web/scripts/facts-lib.mjs). A shallow checkout cannot see that
# history, so every date collapses to the tip commit and the committed
# facts always read as stale. ci.yml pins depth 0 for the same reason.
fetch-depth: 0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Check web surface
# The shared gate checks committed facts before generating build inputs.
env:
GITHUB_TOKEN: ${{ github.token }}
run: npm run check
deploy-reminder:
name: Deployment approval needed
runs-on: ubuntu-latest
timeout-minutes: 5
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Surface the manual deployment gate
env:
REVISION: ${{ github.sha }}
run: |
echo "::notice title=Web deployment approval needed::Revision ${REVISION} passed the web gates but is not deployed. Dispatch web.yml on main to publish it."
{
echo "## Web deployment approval needed"
echo
echo "Revision \`${REVISION}\` passed the web gates but has **not** been deployed."
echo
echo "A maintainer can publish it with \`gh workflow run web.yml --repo codewhale-hq/CodeWhale --ref main\`."
} >> "$GITHUB_STEP_SUMMARY"
deploy:
name: Deploy to Cloudflare
runs-on: ubuntu-latest
timeout-minutes: 30
needs: lint
# Deploy is MANUAL ONLY: an authorized dispatch on main runs `lint`
# before deploying. Pushes and pull requests do not start this workflow.
# This mirrors scripts/check-cloudflare-deploy-env.mjs, which fails closed
# unless GITHUB_EVENT_NAME is workflow_dispatch, GITHUB_REF is
# refs/heads/main, and GITHUB_SHA is an exact 40-hex revision — a push
# trigger here would only produce a red job after `lint` had already run.
# lib/deploy-preflight.test.ts asserts both halves of that contract.
# `needs: lint` is the gate: facts drift, docs parity, tests, ESLint, tsc,
# and a production build all pass before anything reaches Cloudflare.
if: >-
github.event_name == 'workflow_dispatch'
&& github.ref == 'refs/heads/main'
# Serialize deploys so two dispatches landing close together cannot race and
# leave Cloudflare serving the older bundle. Never cancel in progress: a
# half-finished OpenNext upload is worse than a queued one.
concurrency:
group: deploy-codewhale-web
cancel-in-progress: false
defaults:
run:
working-directory: web
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# Pin the checkout to the exact revision this dispatch resolved, so the
# SHA reported to compare:deployed-facts and asserted on the public
# receipt below is the SHA that was actually built, even if main moves
# while the run is queued behind the concurrency group.
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Record deployed/source drift
# Read-only and credential-free. A mismatch is the normal state here —
# it is the gap this run is about to close — so this step reports
# without gating. The real assertion is the post-deploy verification
# below, which must observe this exact revision on the public receipt.
run: npm run compare:deployed-facts -- --expected-revision "$GITHUB_SHA"
- name: Check Cloudflare deploy environment
run: npm run check:deploy-env
# npm's deploy script performs one OpenNext build, then deploys that exact
# bundle. OpenNext populates the remote cache before cf deploy --prebuilt;
# the bundler has no custom build that could invalidate that cache.
- name: Build and deploy exact OpenNext bundle
run: npm run deploy
- name: Verify exact deployed revision
# The public /api/facts receipt must identify this workflow's exact
# checkout before the manual deployment run can finish green.
run: npm run check:deployed-facts -- --expected-revision "$GITHUB_SHA" --attempts 10 --retry-delay-ms 3000
@@ -1,103 +0,0 @@
name: Windows ARM64 export probe
on:
workflow_dispatch:
inputs:
run_id:
description: Release-artifacts or release-candidate run whose windows-arm64 import receipt is probed
required: true
source_sha:
description: Source commit of that run (names the receipt artifact)
required: true
permissions:
contents: read
actions: read
jobs:
probe:
runs-on: windows-11-arm
timeout-minutes: 15
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: windows-imports-windows-arm64-${{ inputs.source_sha }}
path: receipt
run-id: ${{ inputs.run_id }}
github-token: ${{ github.token }}
- name: Resolve every imported pair in a native ARM64 process
shell: pwsh
env:
SOURCE_SHA: ${{ inputs.source_sha }}
SOURCE_RUN: ${{ inputs.run_id }}
run: |
$ErrorActionPreference = 'Stop'
$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
if ($arch -ne 'Arm64') { throw "probe process is $arch, not Arm64" }
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class ExportProbe {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr LoadLibraryExW(string name, IntPtr file, uint flags);
[DllImport("kernel32.dll", CharSet = CharSet.Ansi, SetLastError = true, ExactSpelling = true)]
public static extern IntPtr GetProcAddress(IntPtr module, string name);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint GetModuleFileNameW(IntPtr module, StringBuilder path, uint size);
}
'@
$receipt = Get-Content -Raw receipt/receipt.json | ConvertFrom-Json
$pairs = @{}
foreach ($binary in $receipt) {
foreach ($pair in $binary.imports) { $pairs["$($pair[0].ToLowerInvariant())|$($pair[1])"] = $pair }
}
$modules = [ordered]@{}
$unresolved = @()
$resolved = 0
foreach ($key in ($pairs.Keys | Sort-Object)) {
$dll = $pairs[$key][0].ToLowerInvariant()
$symbol = $pairs[$key][1]
if (-not $modules.Contains($dll)) {
$handle = [ExportProbe]::LoadLibraryExW($dll, [IntPtr]::Zero, 0x00000800)
if ($handle -eq [IntPtr]::Zero) {
$modules[$dll] = @{ handle = [IntPtr]::Zero; path = $null; sha256 = $null; error = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error() }
} else {
$path = [System.Text.StringBuilder]::new(1024)
[void][ExportProbe]::GetModuleFileNameW($handle, $path, 1024)
$modules[$dll] = @{ handle = $handle; path = $path.ToString(); sha256 = (Get-FileHash -Algorithm SHA256 $path.ToString()).Hash.ToLowerInvariant(); error = 0 }
}
}
$module = $modules[$dll]
if ($module.handle -ne [IntPtr]::Zero -and [ExportProbe]::GetProcAddress($module.handle, $symbol) -ne [IntPtr]::Zero) { $resolved++ } else { $unresolved += ,@($dll, $symbol) }
}
$version = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
$result = [ordered]@{
result = $(if ($unresolved.Count -eq 0) { 'PASS' } else { 'FAIL' })
kind = 'exact native system export resolution'
time = (Get-Date).ToUniversalTime().ToString('o')
osVersion = [System.Environment]::OSVersion.Version.ToString()
osBuild = "$($version.CurrentBuild).$($version.UBR)"
osProduct = $version.ProductName
processArchitecture = 'ARM64'
runner = 'GitHub-hosted windows-11-arm'
source = $env:SOURCE_SHA
sourceRun = $env:SOURCE_RUN
binaries = @($receipt | ForEach-Object { [ordered]@{ binary = $_.binary; sha256 = $_.sha256 } })
resolved = $resolved
unresolved = $unresolved.Count
unresolvedPairs = $unresolved
exportProbeMethod = 'ARM64 process: LoadLibraryExW per imported DLL with LOAD_LIBRARY_SEARCH_SYSTEM32 (0x00000800), then GetProcAddress for every imported symbol; module path and SHA256 recorded'
systemModules = [ordered]@{}
}
foreach ($dll in $modules.Keys) { $result.systemModules[$dll] = [ordered]@{ path = $modules[$dll].path; sha256 = $modules[$dll].sha256; loadError = $modules[$dll].error } }
New-Item -ItemType Directory -Force probe | Out-Null
$result | ConvertTo-Json -Depth 6 | Set-Content -Encoding utf8 probe/export-probe.json
Get-Content probe/export-probe.json
if ($unresolved.Count -ne 0) { throw "$($unresolved.Count) imported pairs did not resolve" }
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: windows-arm64-export-probe-${{ inputs.source_sha }}
path: probe/
if-no-files-found: warn
-40
View File
@@ -1,40 +0,0 @@
name: Windows focus
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact 40-character commit selected by --ref
required: true
type: string
filter:
description: nextest expression, for example 'test(compaction_lock) | test(persistence_actor)'
required: true
default: all()
type: string
permissions:
contents: read
jobs:
focused:
name: Focused tests (windows-latest)
runs-on: windows-latest
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
RUST_MIN_STACK: 8388608
RUST_BACKTRACE: full
FILTER: ${{ inputs.filter }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.expected_sha }}
fetch-depth: 1
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@nextest
- name: Run focused tests
shell: bash
run: cargo nextest run -p codewhale-engine -p codewhale-plugins -p codewhale-tui -p codewhale-app-server --lib --locked --profile ci -E "$FILTER"
+18 -96
View File
@@ -1,14 +1,5 @@
# Build artifacts
/target
/target-*
/vendor/codewhale-ratatui/target/
.entire/
.cursor/
.plans/
.codewhale-worktrees/
docs/superpowers/
codewhale-inference/
/extensions/vscode/out/
*.pdb
*.exe
*.dll
@@ -23,9 +14,6 @@ codewhale-inference/
!.env.example
node_modules/
.vscode/
# The extension ships its own dev-loop config (F5 Extension Development Host).
# Without this negation the bare `.vscode/` above swallows it silently.
!/extensions/vscode/.vscode/
.idea/
*.swp
*.swo
@@ -49,11 +37,8 @@ dist/
*.log
# Generated
# Local design critiques and operator receipts do not ship with the product.
/.impeccable/
outputs/
tmp/
backup/
# Reference papers / large research blobs (keep locally if needed, don't ship)
docs/DeepSeek_V4.pdf
@@ -63,23 +48,13 @@ docs/*.pdf
# Local dev scripts and temp files
*.sh
*.cmd
!scripts/**
!.github/scripts/**
!web/public/install.sh
!packaging/winget/**
# Bridge launchers are shipped entry points (`npm run bridge`), not local dev
# scripts; `!scripts/**` above only re-includes the repo-root scripts/ dir.
!integrations/*/scripts/**
test.txt
TODO*.md
todo*.md
CLAUDE.md
AGENTS.md
# …except the deliberate, maintained agent-guidance files:
!/AGENTS.md
!crates/tui/AGENTS.md
!crates/tui/locales/AGENTS.md
!web/AGENTS.md
NEXT_SESSION.md
AI_HANDOFF.md
result.json
@@ -90,35 +65,30 @@ project_overhaul_prompt.md
.wrangler/
# Local runtime state
# Ignore everything under any .codewhale/ (snapshots, auto-generated
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy
# and this repository's own project hooks.
**/.codewhale/*
!**/.codewhale/constitution.json
!/.codewhale/hooks.toml
.codewhale/
.deepseek/
**/session_*.json
*.db
# Chat bridge local state. `npm run bridge` writes the Weixin bot login token
# (account.json) and the long-poll cursor here; these are live credentials.
integrations/*-bridge/.state/
npm/*/bin/downloads/
# Companion app (tracked separately)
apps/
# Claude Code runtime artifacts
.claude/scheduled_tasks.lock
.claude/worktrees/
.worktrees/
.ace-tool/
# NOTE: the blanket `.claude/` rule further down already ignores everything
# under .claude/ — `git check-ignore -v` attributes every path below to that
# line, not to these. The handoff/codemap patterns are kept as defense in depth:
# those notes are working state, not user-facing artifacts, and they leaked into
# the public repo through .claude/ before (HANDOFF_v0.8.28, CODEMAP_v0.8.25).
# They live under .private/handoffs/ now. If anyone ever narrows the blanket
# rule to track part of .claude/, these must keep holding.
# Local-only Claude / ralph notes
.claude/*.local.md
.claude/*.local.json
# Maintainer handoff + codemap notes are working-state, not user-facing
# artifacts. They've leaked into the public repo via .claude/ in the past
# (HANDOFF_v0.8.28, CODEMAP_v0.8.25) — those files now live under
# .private/handoffs/ instead. Block the patterns here so a future accidental
# add doesn't silently land on main.
.claude/HANDOFF_*
.claude/CODEMAP_*
.claude/handoff_*
@@ -127,8 +97,11 @@ apps/
# Maintainer-internal design notes (trade-secret material, never published)
.private/
# Coverage reports (build artifacts)
web/coverage/
# Maintainer-local SWE-bench scratch (instance workspaces, venvs, predictions,
# Docker harness logs). Never published.
.swebench/
deep-swe/
all_preds.jsonl
# Agent handoffs and version-specific setup plans are working-state notes, not
# public docs. Keep durable setup guidance in docs/runbooks instead.
@@ -141,54 +114,3 @@ docs/*_PLAN.md
.envrc
.direnv
scripts/run_deep_swe.py
.claude/
# Local run artifacts and caches re-included by !scripts/**
results/
scripts/**/__pycache__/
# Maintainer-local verification artifacts
.uv-bin/
.uv-cache/
.uv-tools/
issues/
logs/
notes/
# Agent-local roadmap and scratch (never commit — may hold unreleased plans)
.agents/
scratchpad/
# Exploratory operations spec / prompt — never track (canonical copy lives at CW root)
operations9.md
OPERATIONS_0_9_0.md
operations_0_9_0.md
CODEWHALE_0_9_0_CUTOVER.md
# Hepha project metadata (local only, not upstream)
.hepha/
# Ralph agent scratch (local only)
.ralph/
# Local playwright MCP artifacts
.playwright-mcp/
# computer-use plugin smoke receipts (per-run local evidence)
crates/tui/plugins/computer-use/receipts/
# OrcaRouter provider screenshot evidence (per-run local evidence)
orca-evidence/
# Portable pet build entry points are source.
!pet/**/*.sh
!pet/verify.sh
# Private keys and certificates never belong in the tree.
*.pem
*.key
*.p12
*.p8
id_rsa*
id_ed25519*
!docs/cloud-facts/fixtures/test-only-signing-key.pem
-45
View File
@@ -1,45 +0,0 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.12.0.schema.json
PackageIdentifier: HunterBown.CodeWhale
PackageVersion: 0.10.2
PackageLocale: en-US
ManifestType: singleton
ManifestVersion: 1.12.0
Publisher: Hmbown
PublisherUrl: https://github.com/Hmbown
PublisherSupportUrl: https://github.com/codewhale-hq/CodeWhale/issues
PackageName: CodeWhale
PackageUrl: https://github.com/codewhale-hq/CodeWhale
License: MIT
LicenseUrl: https://github.com/codewhale-hq/CodeWhale/blob/main/LICENSE
Copyright: Copyright (c) Hmbown
ShortDescription: Terminal coding agent for supported hosted and local models
Description: |
CodeWhale runs in your terminal, connects to hosted and local models, and edits your workspace with approval controls.
Author: Hmbown
Moniker: codewhale
Tags:
- codewhale
- deepseek
- cli
- tui
- terminal
- ai
- coding-agent
- rust
ReleaseNotesUrl: https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.2
Documentations:
- DocumentLabel: Install guide
DocumentUrl: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md
InstallerType: portable
Commands:
- codewhale
Dependencies:
PackageDependencies:
- PackageIdentifier: Microsoft.VCRedist.2015+.x64
Installers:
- Architecture: x64
InstallerUrl: https://github.com/codewhale-hq/Codewhale/releases/download/v0.10.2/codewhale-tui-windows-x64.exe
InstallerSha256: 578280bec8d6e0ee9cfe97ee29de817535689f4078d8afd47384ff1941b1ee6c
-271
View File
@@ -1,271 +0,0 @@
# Codewhale agent guidance
Keep this file durable. Derive changing release, provider, branch, and flake
state from the repository, tests, CI, and current issue tracker rather than from
instructions or memory. The nearest scoped `AGENTS.md` adds path-specific rules.
## The ponytail method
From [dietrichgebert/ponytail](https://github.com/dietrichgebert/ponytail) —
"the laziest senior dev in the room." *He says nothing. He writes one line. It
works.* The best code is the code you never wrote.
Before writing code, walk the decision ladder in order and stop at the first
rung that answers:
1. **Does this need to exist?** → Skip it.
2. **Already in this codebase?** → Reuse it.
3. **Stdlib does it?** → Use it.
4. **Native platform feature?** → Use it.
5. **Installed dependency?** → Use it.
6. **One line?** → One line.
7. **Only then:** the minimum that works.
The ladder runs *after* understanding the problem. Lazy about solutions, never
about reading the code first — a short diff written without reading the call
sites is not ponytail, it is a guess.
**Never cut, at any rung:** trust-boundary validation, data-loss handling,
security, accessibility. Brevity is not a reason to drop a guard.
Rung 2 is the one this repository keeps failing. The `model_*` / `*_config` /
`provider_*` grep rule below is rung 2 with a name; so is "one turn loop, one
base prompt". Two more corollaries earned here:
- **An abstraction must delete caller code.** If adopting it is pure
obligation — required methods, no default bodies that do work — it gets
built, adopted once, and abandoned.
- **Migrate the last consumer, or do not start.** Framework, one caller,
ticket the rest, silence the warning: that ships two systems and a comment
that is no longer true. If the migration will not fit, narrow the slice —
never the adoption. The standing `#[allow(dead_code)]` count is the running
receipt; `scripts/check-dead-code-budget.py` prints it.
## Working rules
- Inspect status and existing consumers before editing. Preserve unrelated,
dirty, and untracked work.
- Before adding a module named `model_*`, `*_config`, `provider_*`, or
anything that "bridges", "mirrors", or "stages" an existing thing, grep
for the existing thing and edit it. A new layer must name the predecessor
it replaces in its commit message; otherwise edit the original.
- Prefer the simplest implementation that preserves observable contracts. A
rewrite is acceptable when justified by product intent and observed behavior,
not as a shortcut around understanding existing code.
- Search for behavior and symbols before reviving work from an old branch. If a
lane is obsolete, preserve its intent and evidence rather than merging stale
code mechanically.
- Agent-owned work lands on current `main`. Each writing agent works in its
own short-lived git worktree cut from `origin/main`, rebases and pushes
`main` the same day, then removes the worktree and its build directory. No
long-lived branches, nested clones or integration lanes, and never a new
worktree to hide dirty, stale or conflicting work: that work is still yours
to finish. In a shared checkout, stage by explicit path and retry an
`index.lock` failure. Verified main slices may
be committed and pushed normally under the rule below; tag, release,
deployment and destructive cleanup still require their explicit
authorization.
- **Uncommitted work found in the checkout is the agent's own** (founder,
2026-10-22). Read it, work out what it was for, prove it with the smallest
check that covers it, fix what is broken, and commit it in coherent groups
by explicit path. Do not route around another writer's files or leave them
dirty. Ownership is not a blind sweep:
- A file changed in the last 15 minutes, or one a live session or agent is
editing, is mid-edit: ask that writer to commit now, and commit what
remains once it goes quiet. Never commit a half-written edit.
- Never commit secrets, credentials, `.env` files, build output or caches;
add them to `.gitignore` instead.
- Check a repository's visibility before pushing. Inference business and
infrastructure detail stays out of public repositories.
- Commit by explicit path; never `git add -A`.
- Work that fails its check and cannot be fixed now is not pushed to `main`.
Record it and report it instead.
- **Maintainers land small verified slices directly on `main`.** Run the
focused local check (`scripts/preflight.sh --check`), put the real counts in
the commit message, fetch and rebase onto `origin/main` and retry on
rejection. Never force-push or rewrite a shared ref. Contributor PRs are
always welcome and merge as themselves (founder, 2026-11-07).
- When the task is local-only, stay fully offline: no browsing, GitHub or remote
Git operations, downloads, dependency installation, provider calls, or
source/diff transmission. Record the missing external receipt and keep working
locally.
- Public name is **Codewhale**. Compatibility identifiers such as `CodeWhale`,
`codew`, protocol names, and storage keys change only through an explicit
migration.
- Keep providers and models first-class and provider-neutral.
- Never rewrite published history, retag a release, force-push a shared ref, or
publish without explicit authorization. Preserve human contributor credit.
- **Model-visible means logged.** Anything that reaches a model request must be
reconstructable from the session log, and a new model-visible input needs a
session event. Live presentation and the persisted record must agree; when they
disagree the record is right.
- **Misconfiguration fails loud**, at load when it is self-contained, otherwise
at the earliest point it can be resolved. Never silently skip a missing
referent.
- **Write down what a design does not do**, beside the behaviour it owns — a
short known-limitations note in the owning module. A stated limit stops the
next reader from assuming a capability that was never built.
- **Be generous in public** (founder, 2026-11-07). Thank people specifically
for their reports, diagnosis, fixes and testing; link the carrying commit or
PR and distinguish candidate work from a shipped release. Status, answers
and useful questions are welcome; repetitive progress posts and bot noise
are not. Evidence stays in commits and PR bodies; ownership stays in Linear.
A contributor PR is never closed because CI is slow, flaky or red for a
reason the PR did not cause; when a PR is closed, say why in one specific,
thankful sentence. Review bots stay off unless the founder enables them.
- **A user feature lands with its registry row.** A new command, `[features]`
flag, provider or user-visible feature adds or updates its row in
`docs/features.toml` in the same change;
`cargo test -p codewhale-tui --test feature_registry` fails on flag drift.
- **Write `close`/`fix`/`resolve #N` only when you mean it.** GitHub closes the
issue on merge even inside "does not close #N"; use `Refs #N` otherwise.
## Landing other people's work
An external contributor's branch goes stale because *we* land things, not
because they did anything wrong. Treat their time as more expensive than ours.
**The goal is the contributor's PR merging as itself.** Review it, run or
rerun only the checks it needs, fix small problems on their branch, and resolve
conflicts that `main` caused. Landing their commits as ours (harvesting) is the
rare fallback for a branch we cannot push to and that cannot merge in
reasonable time; ask on the PR first, because done casually it reads as taking
the work even when credit is preserved.
- **Never make a contributor rebase around our churn.** If their PR conflicts
only because main moved, a maintainer resolves it.
- Landing mechanics — merge-base diffing, mid-function conflicts, fork-push
refusal, runs awaiting first-contributor approval — live in `cw-land`.
Follow them instead of improvising.
- **Preserve credit in the mechanical sense, not just the polite one.** Commit
authorship and `Co-authored-by` trailers must use the contributor's own
GitHub-linked address. `AUTHOR_MAP` and `.mailmap` are project conventions —
GitHub reads neither for the contribution graph.
## Merging under a gate
- **A gate is its artifact.** When a rail says a PR merges only on a passing
acceptance record, the record must literally say PASS at merge time. "I
re-ran it and the failures are rows this PR does not own" is a judgement to
write into the artifact first, not a reason to merge past it.
- **Read the review thread, not the check rollup.** Green checks and an unread
review with confirmed findings are a merge that ships known bugs.
- **When the artifact is ambiguous, resolve the ambiguity — never the merge.**
## Claiming a test passed
- Quote the real `test result: N passed; M failed` line, and confirm `N > 0`
for the tests that cover the change. `cargo test <filter>` exits 0 having run
zero tests when the filter matches nothing, and an exit code alone has
already been mistaken for a pass here.
- Prefer proving a regression test fails without the fix. A test that passes
either way pins the implementation, not the defect.
- Audit any hand-rolled scorer before trusting its score: quote the counts it
actually evaluated, not the verdict line alone.
- Match the evidence to the surface. Run the tests that cover the change, not the
whole suite, and do not repeat a check that already passed in order to commit.
CI owns exhaustive coverage; a full local run is for CI diagnosis or for an
irreducibly repository-wide change.
## Current contracts
- The model-facing subagent tool is `agent`; `agent_open`/`agent_eval`/
`agent_close`/`delegate_to_agent` are removed surfaces. If the shape must
move, move the code and its existing guard test with it.
- `BASE_PROMPT` in `crates/engine/src/prompts/text.rs` is the sole base prompt
by convention. Same rule: move the code, not the prose, if that changes.
- There is exactly one turn loop: `Engine::run_turn` in
`crates/engine/src/core/engine/turn_loop.rs`. Note that `crates/engine/src/core/`
is a module inside the engine crate (`codewhale-engine`) — it is not `crates/core`, which owns
request construction, bounded fragments, and thread/session types and
runs no turns. A guard test (`crates/core/tests/single_turn_loop.rs`)
fails on a second loop; changing the shape means changing the guard
with it.
- The system prompt + tool catalog are a session-pinned KV-cache prefix
(`docs/CACHE.md`). Any new session-context contributor must state its
KV-cache effect: frozen prefix vs. append-only history. Never splice a
volatile fact into the prefix; append it as a user-role message.
- These active modules are repeatedly misidentified as dead; verify consumers
before removal: `runtime/src/context_budget.rs`,
`runtime/src/prompt_zones.rs`, `engine/src/tools/remember.rs`, and
`config/src/route/`. Native memory lives in `runtime/src/native_memory.rs`;
`tools/remember.rs` is its capture path.
- Environment-specific behavior belongs in `docs/ENVIRONMENTS.md`, not here.
- Blocking-call convention (#6149): code on the Tokio runtime — tool
handlers, engine tasks, the UI event loop, anything reached through an
`async` call chain — must not run blocking operations inline. Use
`tokio::fs` / `tokio::process`, or move the work into `spawn_blocking`;
a sync helper containing blocking calls runs only under `spawn_blocking`
or on a dedicated thread. `scripts/check-blocking-calls-budget.py`
ratchets the unprotected-site count.
## Code, migrations, and evidence
- Product intent and observed runtime behavior outrank a test's preferred
implementation shape. Fix the product; do not contort production code to
preserve a brittle assertion.
- **A regression test per fix; no new code comments.** Prove a change by
running it and the existing checks. A bug fix, or a new contract between
components, may add the one test that would have caught it; no coverage for
its own sake. Never write tests first and never practice TDD — this
overrides any skill or default that mandates it, including superpowers
`test-driven-development`. Existing tests stay the gate before a push: when
a change makes one wrong, update or delete it in the same slice rather than
bending the code to keep it green. When a regression test is requested, the
rule under "Claiming a test passed" still applies. Do not add new code
comments; the reasoning goes in the commit message, and an existing comment
the change makes false is fixed or removed.
- Rewrite or remove tests that duplicate coverage, freeze internals, overspecify
copy or layout, preserve obsolete behavior, or cost more than the risk they
cover. Never weaken real safety or data-integrity behavior merely to make a
gate pass.
- Prefer focused compilation, a relevant existing check, and direct product or
manual evidence. Run a broad suite only when the change creates a genuine
cross-cutting or release risk. Do not repeatedly rerun an unchanged suite.
- **Batch edits; compile once.** `cargo check` and test builds on this
workspace take minutes, so an edit→compile→edit loop spends most of its
time waiting on the linker. Read precisely, write every edit a coherent
slice needs, then compile and test once — the same errors surface either
way, just later and all at once. Reserve mid-slice compiles for genuinely
uncertain API or borrow questions where a wrong guess would cascade.
- Declared migrations are one-way. Once the repository adopts a replacement
architecture or shared spine, new work uses it and touched legacy code moves
toward it. Do not add another legacy call site for convenience. Keep a
compatibility path only for an actual external contract, and label that
boundary explicitly.
Useful commands, selected according to risk rather than run ritualistically:
```sh
cargo fmt --all -- --check
cargo test -p codewhale-config -p codewhale-protocol
cargo test --workspace
cargo build --release -p codewhale-cli -p codewhale-tui
```
`scripts/dev-test.sh <area|path> [filter]` maps a code area to its fastest
invocation — see `cw-gates` for the verification ladder and
`docs/BUILD_PERFORMANCE.md` for the build topology.
Report commands actually run and distinguish source, local tests, packaged
artifacts, CI, and public release state. Describe the evidence actually needed
for the claim; a test count is not a proxy for product quality.
Community reports, PRs, logs, and reviews are evidence.
**Harvested contributor credit is still a rule** (the fallback path above —
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`).
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`.
Leave unrelated work intact. No automation closes, blocks or labels a
contributor as unapproved, and no workflow checks out or runs untrusted PR code
under `pull_request_target`. Keep required PR checks few and fast; exhaustive
validation belongs to the release candidate.
+4546 -13127
View File
File diff suppressed because it is too large. Load diff
-11
View File
@@ -1,11 +0,0 @@
# Claude entrypoint
@AGENTS.md
The imported contract is authoritative. Do not invent stricter test or branch
rituals: follow its code-first evidence policy, one-way migration rule, clean
direct-main permission, and the current task's offline boundary.
Its ponytail decision ladder runs before you write code, every time. Read it
there — it is not restated here, because rung 2 is "already in this codebase?"
and a second copy of a rule is the thing the rule forbids.
File renamed without changes.
+100 -420
View File
@@ -6,7 +6,7 @@ Thank you for your interest in contributing to codewhale! This document provides
### Prerequisites
- Rust 1.89 or later (edition 2024)
- Rust 1.88 or later (edition 2024)
- Cargo package manager
- Git
@@ -18,43 +18,21 @@ Thank you for your interest in contributing to codewhale! This document provides
cd CodeWhale
```
2. Build the current engine and terminal:
2. Build the project:
```bash
CODEWHALE_BUILD_SHA="$(git rev-parse HEAD)" cargo build --locked -p codewhale-cli
cargo build
```
3. Run the tests near your change (see [Fast local loop](#fast-local-loop)):
3. Run tests:
```bash
scripts/dev-test.sh tui your_test_filter
cargo test --workspace --all-features
```
4. Run with development settings:
```bash
./target/debug/codewhale --version
./target/debug/codewhale
cargo run --bin codewhale
```
### Testing the latest source
The canonical source is [`codewhale-hq/Codewhale`'s `main` branch](https://github.com/codewhale-hq/Codewhale/tree/main).
Release candidates land there after their CI gates pass, so contributors can
test and build on the same source. Tagged downloads remain the latest published
release; their version can lag the development version on `main`.
For a fresh checkout of the current source:
```bash
git clone --branch main https://github.com/codewhale-hq/Codewhale.git
cd Codewhale
CODEWHALE_BUILD_SHA="$(git rev-parse HEAD)" cargo build --release --locked -p codewhale-cli
./target/release/codewhale --version
./target/release/codewhale
```
Include the commit shown by `--version` when reporting a problem. If you work
from a fork, add the canonical repository as `upstream` and fetch `upstream/main`
before starting a change; preserve any local work when updating your branch.
## Development Workflow
### Code Style
@@ -67,126 +45,12 @@ before starting a change; preserve any local work when updating your branch.
### Testing
- Write tests for new functionality
- Run the tests near your change (see [Fast local loop](#fast-local-loop));
your pull request runs the Linux workspace suite and a Windows build for
you (see [What happens after you open a pull request](#what-happens-after-you-open-a-pull-request))
- Ensure all existing tests pass: `cargo test --workspace --all-features`
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
modules), and add integration tests under the owning crate's `tests/`
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
repository root `tests/` directory is not used
### Pre-push verification
These are the Rust commands your pull request runs on Linux (the `Lint` and
`Test (ubuntu-latest)` checks). You do not need all of them before every
push: run `cargo fmt`, then check and test the crates you touched (see
[Fast local loop](#fast-local-loop)). Run the full set locally when a change
spans many crates, or let the pull request run it for you:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or
cargo test --workspace --all-features --locked
```
Clippy lints test, bench and example targets too (`--all-targets`), and the
three allowances above are the only ones CI and the release lane accept.
#### Fast local loop
The full gate above is what CI enforces, but you do not need it for every
edit. `crates/tui` is a ~750k-line crate, so the loop that stays fast is
the one that avoids rebuilding it more than necessary (numbers and the
reasoning are in [`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md)):
```bash
# 1. Type-check first (seconds after the first build; no codegen, no link).
scripts/dev-cargo.sh check -p codewhale-tui
# 2. Run only the tests near your change (one crate, one filter).
scripts/dev-test.sh tui fleet_setup
# or: scripts/dev-test.sh crates/command-contract/src/elapsed.rs
# 3. Run a whole crate's unit suite. scripts/dev-test.sh uses nextest when
# it is installed (one process per test, all cores busy, slow tests
# named; ~100 s here vs ~270 s with libtest).
cargo install cargo-nextest --locked # once
scripts/dev-test.sh tui
scripts/dev-cargo.sh nextest run --workspace --all-features --locked
# 4. The Linux suite your PR runs:
cargo test --workspace --all-features --locked
```
`.config/nextest.toml` already serializes the PTY suite and bounds the
integration tests that spawn the real binary, so `cargo nextest run` is
safe to use on the whole workspace (nextest does not run doctests; the
authoritative `cargo test` gate does). Tests must not depend on running in
the same process as another test (nextest gives every test its own
process); if a test needs the rustls crypto provider, install it in that
test as production does at startup.
On a machine with less than 16 GB of RAM (or when cross-compiling, e.g.
for OHOS), build one rustc at a time: `CARGO_BUILD_JOBS=1` (or `-j1`), one
crate at a time, `--lib` for tests, never `--workspace`/`--all-targets`.
The tui library needs ~6 GB for its own rustc and its unit-test build ~8 GB;
`cargo test --workspace` runs both at once. Numbers and the full recipe:
[`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md#low-memory-build-recipe-machines-with--16-gb-cross-builds).
If you work in several worktrees, do **not** share one `CARGO_TARGET_DIR`
by default: two cargos on the same target flock and serialize. Use
`scripts/dev-cargo.sh` / `scripts/dev-test.sh`, which give each workspace
its own Cargo `build-dir` (`{workspace-path-hash}` under
`${CODEWHALE_CACHE_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/codewhale}`).
`CODEWHALE_DEV_CACHE=local` keeps `./target` if you want that.
`sccache` wraps rustc only when incremental compilation is already off
(`CARGO_INCREMENTAL=0` or `CODEWHALE_SCCACHE=1`) and `sccache` is on
`PATH`; a missing binary is a printed fallback, not an error. Override
the cache root with `CODEWHALE_CACHE_ROOT` — there is no machine-specific
default. A single shared `CARGO_TARGET_DIR` remains valid only for
serialized trunk work. See
[`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md).
Some checks are platform-bound or intentionally excluded from an ordinary
change. Choose them for the risk they answer rather than treating every
available suite as ritual. Visible TUI behavior is accepted in the actual
terminal at the sizes and interaction path affected by the change; the former
full-screen PTY assertion suite was removed because it froze layout and copy
while missing product quality.
- **Long-running process acceptance** should use a sealed local home, local
fixtures, and the real binary. Record the terminal size, inputs, visible
result, and any filesystem side effect instead of adding a full-screen
golden.
- **OCR** (`image_ocr`) uses the macOS Vision framework or a locally
installed `tesseract`; its platform-specific paths are
`cfg(target_os = "macos")`-gated and depend on host tooling.
- **Seatbelt sandbox** tests are macOS-only (`cfg(target_os =
"macos")` at the module level) and do not run elsewhere.
#### Local git hooks are optional
This repository does not install git hooks, and no hook installer
exists; CI is the enforced gate. If you want a local `pre-push` hook
that runs the commands above, add it yourself (`.git/hooks/pre-push` or
`git config core.hooksPath`). Constraints for any local hook:
- A hook must never push, tag, publish, deploy, mutate credentials, or
rewrite the working tree (no auto-fix commits or silent file
modification). It may only verify and report.
- To bypass your own hook for a knowingly documented reason (for
example, pushing work-in-progress to your own fork branch), use
`git push --no-verify` and say so in the PR description. Bypassing a
local hook does not make the gates pass — CI still runs them, and a
bypassed gate must never be reported as a passing one.
- Release publication (tags, GitHub Releases, crates/npm artifacts) is a
separate, owner-approved gate. Neither local hooks nor a green local
run authorize any publication step.
### Commit Messages
Use clear, descriptive commit messages following conventional commits:
@@ -200,220 +64,71 @@ Use clear, descriptive commit messages following conventional commits:
Example: `feat: add doctor subcommand for system diagnostics`
**Changelog entries are written on `main` at merge time, not in PRs.** Do not
edit `CHANGELOG.md` or `crates/tui/CHANGELOG.md` on a branch: every PR
touching them re-conflicts with every other PR touching them. The release
manager writes one batched "receipts" commit per merge session, and
`./scripts/sync-changelog.sh` keeps the packaged slice in sync. A PR that
carries changelog hunks will be asked to strip them
(`git checkout origin/main -- CHANGELOG.md crates/tui/CHANGELOG.md`).
One exception is enforced by CI: a `feat:` commit whose message mentions an
issue (`#N`) must add `#N` to `CHANGELOG.md` in the same PR
(`scripts/release/check-feature-release-notes.sh`). To avoid touching the
changelog, put issue numbers in the PR description instead of in `feat:`
commit messages; the maintainer writes the entry at merge time.
**AI-assistant co-author trailers are fine.** Using an assistant is welcome and
needs no disclosure, and CI no longer rejects an auto-appended
`Co-authored-by: <some tool>` line. What we do care about is that the humans who
did the work are named — `Co-authored-by` feeds the GitHub contribution graph.
Remove an auto-appended line only if you want to:
```bash
git rebase -i origin/main # reword each commit, delete the Co-authored-by line
```
Co-author a *person* freely; the address must be their GitHub-linked one
(`id+login@users.noreply.github.com`) or the credit does not register.
When a commit harvests code from a community PR (see "How Your Contribution
Lands" below), include a `Harvested from PR #N by @author` line in the commit
body. A maintainer then closes the referenced PR by hand with a thank-you and
a link to the commit, so the contributor gets a clear signal that their work
shipped.
## What happens after you open a pull request
Opening a pull request against `main` starts the checks below. They run on
GitHub's hosted runners with no repository secrets. GitHub may ask a
maintainer to approve the first run for a brand-new GitHub account.
| Check | What it runs | When it is red |
| --- | --- | --- |
| `Lint` (required) | `cargo fmt --check`, clippy on every target, and the fast drift checks (locales, READMEs, provider registry, dead-code and blocking-call budgets) | Run `cargo fmt --all` and the clippy command in [Pre-push verification](#pre-push-verification); the log names the file and the check |
| `Test (windows-latest)` (required) | Compiles every workspace target for Windows with warnings denied, and the installer PATH helper tests | Usually a `cfg(windows)` path or an import only Windows sees; the log has the compiler error |
| `Version drift` | Version files, the `feat:` release-note rule, release helper tests and contributor credit | The credit step only reports: a maintainer adds your credit when the PR merges |
Smaller checks cover the VS Code extension, integrations, the extension host
and plugin conversion, plus workflow lint when you change `.github/workflows`.
A documentation-only change skips the Rust builds but still reports the
required checks, so it can merge.
Your pull request does not run the test suites, the safety gate, MSRV, the npm
wrapper smoke, the slow runtime-contract and persistence-backlog measurements,
the dependency audits or the OpenHarmony build. Maintainers run those once per
release, on the exact commit being released. If that run finds a problem your
change introduced, we fix it on `main`; we will not ask you to chase it.
A red check that your change did not cause is not your job to fix. We rerun it
or fix it on `main`, and we never close a PR because CI is slow or flaky. Your
PR may be a draft, and the issue line in its description is optional.
body. An auto-close workflow watches for this pattern and closes the
referenced PR with credit so the contributor gets a clear signal that
their work shipped.
## How Your Contribution Lands
**Your pull request merges as itself.** That is the default for every
contribution: a maintainer, or an agent working for one, reviews it and merges
it, so your commits reach `main` with you as their author.
We follow a deliberate "land what's useful, credit the contributor" model
that occasionally surprises new contributors. Two paths:
Before merging, a maintainer may:
### Path 1 — Direct merge
- ask questions or request changes in review;
- push small fixes, or a merge of current `main`, to your branch when GitHub
allows edits by maintainers, instead of asking you to rebase around our churn;
- write the `CHANGELOG.md` entry and your credit in `docs/CONTRIBUTORS.md` on
`main` when the PR merges. You do not edit those files in your PR.
If your PR is well-scoped, passes CI, doesn't touch the trust-boundary
surface (auth / sandbox / publishing / branding), and doesn't conflict
with main, a maintainer merges it directly. This is the most common
outcome for small bug fixes and well-tested feature additions.
### Fallback — harvest
### Path 2 — Harvest
Rarely, a branch cannot merge in reasonable time, for example when it is in a
fork we cannot push to and the conflicts cannot be resolved there. We will ask
on your PR first. If it still cannot merge, a maintainer may **harvest** the
useful commits into `main` instead. This is **not a rejection**: your code
landed, and your credit is kept mechanically.
If your PR is large, mixes scope, conflicts with main, or needs polish
that's faster for the maintainer to apply than to round-trip with the
contributor, the maintainer may **harvest** the useful commits or hunks
into a new commit on `main` rather than merging the PR directly. This is
**not a rejection** — it means your code landed.
- You stay the author of the harvested commit, with your GitHub-linked address
from `.github/AUTHOR_MAP` or
`gh api users/<login> --jq '"\(.id)+\(.login)@users.noreply.github.com"'`,
never a raw, `.local` or old-style noreply email from your machine.
- The commit body includes `Harvested from PR #N by @your-handle`. That line is
the contract: it is your credit and the signal that your work shipped.
When this happens:
- The harvested commit's message includes `Harvested from PR #N by
@your-handle`. This is the contract: that line is your credit and the
signal that your contribution shipped.
- If the maintainer copies or adapts your code, the harvested commit also
keeps attribution with the original author identity when possible: either by
preserving the commit author on a cherry-pick or by adding a
`Co-authored-by: Name <email>` trailer from the original PR commit. This is
what lets GitHub's contribution surfaces recognize more than prose credit.
- The `CHANGELOG.md` entry for the next release credits you by handle.
- A maintainer closes your PR by hand with a thank-you and a link to the commit
on `main`.
- The auto-close workflow closes your PR with a templated thank-you and
a link to the commit on `main`.
When a maintainer closes a harvested PR by hand, the closing comment
follows this template (the pattern set on PR #2634):
```text
Closing with harvest credit, @handle — <what landed> landed via
<commit sha(s) or PR #N>. <If work remains:> The remainder is tracked
in #NNN — follow-ups welcome there.
Thank you for <one specific thing the contribution got right>.
```
Three required elements: the contributor's handle, the exact commits or
PRs where their work landed, and — when the PR contained more than what
landed — a tracking issue for the remainder. A harvested PR is never
closed with a bare "superseded".
To help your PR merge quickly as itself:
To make a future contribution land via the faster Direct-Merge path
instead of the Harvest path, the highest-leverage things you can do are:
1. **Keep PRs single-purpose.** One bug fix per PR; one feature per PR.
Don't mix a refactor with a feature.
2. **Start from current `main`.** If `main` moves while your PR is in review,
you do not need to rebase; we resolve conflicts our changes caused.
3. **Include tests** with new behavior. A PR with tests needs fewer review
round trips.
2. **Rebase onto current `main` before opening the PR**, and after CI
feedback. Conflicts force the harvest path even when the change is
small.
3. **Include tests** with new behavior. The maintainer often harvests
PRs without tests because adding the test is faster than asking the
contributor for one.
4. **Avoid the trust-boundary surface** without prior maintainer
sign-off. That includes auth/credential flows, sandbox policy,
publishing/release plumbing, and `prompts/` content. PRs that touch
these without prior discussion are unlikely to merge directly even
when the change is well-implemented.
## Layered and EPIC-Sized Work
Some architecture work is too large for one PR but still needs to be built in
dependent layers. For those changes, use this workflow:
1. Start with a tracking issue or EPIC when the work spans multiple PRs. Name
the intended slices and state what each slice is not trying to close yet.
2. Keep each implementation PR focused on one behavior boundary.
3. Later layers may stay in your fork or open as draft PRs while the lower
layer is still moving. Draft stacked PR titles or descriptions should say
`Draft / depends on #NNNN`.
4. A dependent PR is not ready for merge review until the lower layer has
landed, the branch has been rebased onto current `main`, and the PR targets
`main`.
5. The PR body should identify which earlier PR it builds on, what is in scope,
what is explicitly out of scope, which issues it references, and which local
commands were run.
6. Use `Closes #...` only when the slice fully satisfies an issue. Use
`Refs #...` with a short `(partial)` note when the PR advances a broad issue
but leaves follow-up work.
7. Structured commits are fine during review. Maintainers merge the PR itself
(merge commit, squash or rebase) with you as the author. If a layer has to
be harvested instead, the [fallback](#fallback--harvest) rules apply.
Before asking for merge review on a layered PR, check that it is:
- rebased onto current `main`
- marked ready for review, not draft
- focused to one behavior boundary
- backed by local command evidence in the PR body
- green in its pull-request checks, or has any remaining red check clearly
explained
- covered by round-trip or migration-preservation tests when it changes config
or schema behavior
- referencing broad issues as partial unless it really closes them
For layered work, a useful PR description shape is:
```text
Summary:
Scope:
Not in this slice:
Builds on:
Issues:
Validation:
```
## Which branch to target
**`main`, for everything.** There is no separate staging branch. An earlier
version of this guide pointed layered refactors at `codex/v0.9.0-stewardship`;
that branch no longer exists, so please ignore any instruction you find
elsewhere to base work on it.
**We do not expect you to rebase around our churn.** If your PR conflicts only
because `main` moved while it was in review, say so and a maintainer resolves
it on your branch. If your branch is in a fork we cannot push to, we ask on the
PR first; harvesting is the last resort.
## Labels
Issues and pull requests are labelled by `.github/workflows/triage.yml`, which
only ever adds labels that already exist. Pull requests get their labels from
the paths they change and their conventional-commit title prefix; issues get
them from the title and form answers.
| Family | Meaning |
| --- | --- |
| `area:*` | The subsystem or client: `tui`, `core`, `tools`, `mcp`, `state`, `sandbox`, `plugins`, `providers`, `runtime-api`, `install`, `localization`, `ci`, `desktop`, `web`, `mobile`, `remote-control`, `subagents` |
| `os:*` | Platform-specific behaviour: `windows`, `macos`, `linux`, `freebsd`, `ohos` |
| `priority:p0` / `p1` / `p2` | Set by a maintainer; `release-blocker` still marks work that must land before a release |
| `bug`, `enhancement`, `documentation`, `question`, `cleanup`, `performance`, `security` | What kind of change it is |
| `accessibility` | Screen readers, keyboard use, contrast, motion |
| `contributor:external` | Opened by someone outside the maintainer team |
| `lang:zh` | Written in Chinese |
| `needs-triage`, `needs-info`, `agent-ready`, `agent-in-progress`, `needs-human`, `keep-open`, `pinned`, `stale` | Workflow state; other workflows read these names |
Release targeting uses milestones, not version labels.
## Agent-Assisted Improvements
Codewhale is allowed to help improve Codewhale, but the contribution still has
to be shaped for human review. The recommended workflow is the recursive self-improvement prompt
in the private `codewhale-ops` repo: run it
CodeWhale is allowed to help improve CodeWhale, but the contribution still has
to be shaped for human review. The recommended workflow is the
[recursive self-improvement prompt](docs/RECURSIVE_SELF_IMPROVEMENT.md): run it
from a fresh fork or branch, let the agent find exactly one small friction point,
and stop after one patch. DeepSeek V4 Pro is the reference path for this loop
today, but any configured provider works — the review shape matters more than
the provider.
Agents and maintainers should follow the stewardship posture in
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence,
verification, and narrow patches while keeping the final community decision
human-reviewed.
and stop after one patch. DeepSeek V4 Pro is the first-class path for this loop
today, but the review shape matters more than the provider.
The useful output is not "ideas for improvement." The useful output is a
specific reproduction, a minimal diff, focused checks, and a PR description that
@@ -421,71 +136,31 @@ explains the trade-off. Do not use an agent to touch auth, credentials, sandbox
policy, publishing/release plumbing, provider policy, telemetry, sponsorship,
branding, or global prompts without prior maintainer sign-off.
## How maintainers and agents ship
Maintainers, and the agents working for them, land small verified slices
directly on `main` with the local checks they ran and their counts in the
commit message. A push to `main` starts no GitHub build or test workflow:
GitHub only mirrors `main` to CNB and GitCode. CNB then runs its own Linux
gates on the mirror and reports them on CNB, not on GitHub. Do not write
`[skip ci]`; nothing needs skipping, and it would also skip the mirrors.
The exhaustive gate runs once, at the final release step, from one dispatch on
the frozen candidate commit:
```bash
git fetch origin main
candidate_sha="$(git rev-parse origin/main)"
gh workflow run ci.yml --repo codewhale-hq/CodeWhale --ref main \
-f expected_sha="${candidate_sha}"
```
It stops if `main` no longer resolves to that commit. Otherwise it runs every
CI gate on Linux, macOS and Windows, the budget measurements, the
documentation build, `cargo-deny`, the cargo and npm audits and the
OpenHarmony build. Publishing also needs a green `release-candidate.yml` run
on the same commit, which `release.yml` checks; the full sequence is in
[docs/RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md).
Contributor pull requests still merge as pull requests, as described in
[How Your Contribution Lands](#how-your-contribution-lands).
## Project Structure
Codewhale is a Cargo workspace with one Engine implementation in
`crates/engine/src/core/engine/`. The public `codewhale` executable links the
TUI/runtime library; interactive sessions, noninteractive runs and the Runtime
API share that Engine.
codewhale is a Cargo workspace. The live runtime and the majority of TUI,
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
crates provide shared abstractions that are being extracted incrementally.
| Path | Purpose |
| --- | --- |
| `crates/cli/` | Public command entrypoint, configuration commands and runtime dispatch |
| `crates/tui/` | Interactive terminal, Engine, tools, Runtime API and embedded local web client |
| `crates/core/`, `crates/protocol/`, `crates/state/` | Request construction, session/turn types, protocol framing and persistence |
| Other `crates/` | Shared configuration, credentials, telemetry, hooks, workflow and packaging support; see each Cargo manifest |
| `web/` | Public Next.js website and documentation; separate from the embedded Runtime web client |
| `telemetry-ingest/` | Telemetry service, schemas and service tests |
| `extensions/`, `integrations/` | Editor integration and external-service bridges |
| `npm/`, `packaging/`, `nix/` | npm wrappers/SDK and platform installation definitions |
| `computer/snapshots/` | Cloud Computer image definitions, pinned independently of the source checkout |
| `deploy/` | Deployment templates consumed by setup scripts, including Tencent Lighthouse services |
| `fleets/`, `workflows/` | Distributed Fleet definitions and workflow examples |
| `brand/` | Source artwork and generated brand variants used by the README, website and terminal |
| `docs/` | User/developer documentation, schemas, fixtures and referenced release material |
| `scripts/`, `.github/`, `.cnb.yml` | Development, validation, CI and release tooling |
| `patches/` | Vendored dependency fixes, including their licensing files |
```
crates/
├── tui/ codewhale-tui binary (interactive TUI + runtime API)
├── cli/ codewhale binary (dispatcher facade)
├── app-server/ HTTP/SSE + JSON-RPC transport
├── core/ Agent loop / session / turn management
├── protocol/ Request/response framing
├── config/ Config loading, profiles, env precedence
├── state/ SQLite thread/session persistence
├── tools/ Typed tool specs and lifecycle
├── mcp/ MCP client + stdio server
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
├── execpolicy/ Approval/sandbox policy engine
├── agent/ Model/provider registry
└── tui-core/ Event-driven TUI state machine scaffold
```
Generated files that the product embeds or validates, such as model catalogs,
website facts and schemas, remain tracked with their generators. Platform
mirrors such as `.winget/` are retained when their packaging tools require them.
Keep local critique output, temporary verification reports and personal
operator instructions outside the tracked product tree; describe the change
and its validation in the pull request. Do not copy workspace-level operator
`AGENTS.md` or `CLAUDE.md` files into this repository.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime data flow and
[the build guide](docs/BUILD_PERFORMANCE.md) for crate dependencies and local
verification.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
these crates, including the bottom-up build order.
## Submitting Changes
@@ -496,9 +171,12 @@ verification.
2. Make your changes and commit them
3. Run the pre-push verification commands (see
[Pre-push verification](#pre-push-verification) above for the exact
gate and the stricter release clippy form)
3. Ensure CI passes:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features
cargo test --workspace --all-features
```
4. Push your branch and create a Pull Request
@@ -507,30 +185,24 @@ verification.
## Pull Request Guidelines
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening
a PR — what and why, the issue line, and how you tested it
- An issue line in the PR description is optional: `Closes #N` (or `Fixes` /
`Resolves`) when the PR finishes the issue, `Refs #N` for related or partial
work. Never write a closing keyword inside a negation such as "does not
close #N": GitHub closes the issue anyway
- If you add a new layer, module, or abstraction, say which one it replaces
or deletes
a PR — it includes the Summary, Testing, and Checklist sections reviewers
expect
- Keep PRs focused on a single change
- Update documentation if needed
- Add tests for new functionality
- If a check is red for a reason your change did not cause, a maintainer will
deal with it
- Ensure CI passes before requesting review
## Shape of a Typical PR
A well-structured PR follows a consistent pattern. Recent exemplars include:
- **#386** — `/init` command: new `crates/tui/src/commands/groups/project/init.rs` module, project-type detection,
- **#386** — `/init` command: new `crates/tui/src/commands/init.rs` module, project-type detection,
AGENTS.md generation, command registration in `commands/mod.rs`, localization strings.
- **#389** — Inline LSP diagnostics: LSP subsystem in `crates/lsp/src/`, engine hooks in
`crates/engine/src/core/engine/lsp_hooks.rs`, config toggle, test coverage.
- **#389** — Inline LSP diagnostics: LSP subsystem in `crates/tui/src/lsp/`, engine hooks in
`core/engine/lsp_hooks.rs`, config toggle, test coverage.
- **#387** — Self-update: new `crates/cli/src/update.rs` module, CLI subcommand registration,
HTTP download + SHA256 verification + atomic binary replacement.
- **#393** — `/share` session URL: new `crates/tui/src/commands/groups/project/share.rs`, HTML rendering,
- **#393** — `/share` session URL: new `crates/tui/src/commands/share.rs`, HTML rendering,
`gh gist create` integration, command registration.
- **#343/#346** — (v0.8.5) Runtime thread/turn timeline and durable task manager refactors.
@@ -539,33 +211,41 @@ Typically each PR touches 1–3 new files, modifies 2–5 existing files for wir
are scoped to a single feature or fix — if you discover related work that needs
doing, open a separate issue rather than expanding the PR scope.
Before submitting, run the commands in
[Pre-push verification](#pre-push-verification).
Before submitting, run:
```bash
cargo fmt --check
cargo clippy --workspace --all-targets --all-features 2>&1 | head -50
cargo check
```
## Reporting Issues
When reporting issues, please use one of the issue templates:
- [Bug report](.github/ISSUE_TEMPLATE/bug_report.yml) — for reproducible problems
- [Bug report](.github/ISSUE_TEMPLATE/bug_report.md) — for reproducible problems
or regressions
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.yml) — for ideas and
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) — for ideas and
improvements
The forms ask for what a report needs (`codewhale --version`, OS, how you got
Codewhale, and steps to reproduce). Questions go to
[Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or
[Discord](https://discord.gg/37gfS3ksug).
Issue reports should include:
- Operating system and version
- Rust version (`rustc --version`)
- codewhale version (`codewhale --version`)
- Steps to reproduce the issue
- Expected vs actual behavior
- Relevant error messages or logs
## Security
If you discover a security vulnerability, please do **not** open a public issue.
See [SECURITY.md](.github/SECURITY.md) for the responsible disclosure process and
See [SECURITY.md](SECURITY.md) for the responsible disclosure process and
contact information.
## Code of Conduct
Be respectful and inclusive. We welcome contributors of all backgrounds and
experience levels. See [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md) for the full
experience levels. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full
code of conduct.
## License
Generated
+1692 -4039
View File
File diff suppressed because it is too large. Load diff
+18 -127
View File
@@ -1,162 +1,53 @@
[workspace]
members = [
"crates/account-cli",
"crates/agent",
"crates/agent-contract",
"crates/app-server",
"crates/app-server-protocol",
"crates/build-support",
"crates/cli",
"crates/cloud-facts",
"crates/command-contract",
"crates/commands",
"crates/config",
"crates/core",
"crates/engine",
"crates/error-taxonomy",
"crates/execpolicy",
"crates/hooks",
"crates/lane",
"crates/localization",
"crates/mcp",
"crates/memory",
"crates/models",
"crates/net",
"crates/notify",
"crates/palette",
"crates/paths",
"crates/plugins",
"crates/protocol",
"crates/release",
"crates/runtime",
"crates/sandbox",
"crates/secrets",
"crates/skill-install",
"crates/skills",
"crates/sanitize",
"crates/state",
"crates/telemetry",
"crates/tools",
"crates/tui",
"crates/tui-kit",
"crates/util",
"crates/dependencies",
"crates/snapshot",
"crates/lsp",
"crates/repl",
"crates/workflow",
"crates/workflow-js",
"tests/portable-session",
"tests/portable-config",
"tests/portable-core-navigation",
"crates/tui-core",
]
exclude = ["vendor/codewhale-ratatui"]
default-members = ["crates/cli"]
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.10.3"
version = "0.8.47"
edition = "2024"
# The floor is what CI's MSRV job builds (1.89): Rust 1.88 stabilized the
# `let_chains` the codebase relies on, but a locked dependency
# (serde-saphyr) needs 1.89. Cargo enforces this so users on older toolchains
# get a clear "package requires rustc 1.89+" error.
rust-version = "1.89"
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
# codebase relies on extensively. Cargo enforces this so users on older
# toolchains get a clear "package requires rustc 1.88+" error instead of a
# confusing E0658 from rustc.
rust-version = "1.88"
license = "MIT"
repository = "https://github.com/codewhale-hq/CodeWhale"
# Record the policy CI already applies via `RUSTFLAGS: -Dwarnings`.
# Member crates opt in with `[lints] workspace = true`. This does not add
# new lints; it makes the existing gate visible in the manifest so
# `cargo check` without extra flags matches CI.
[workspace.lints.rust]
warnings = "deny"
repository = "https://github.com/Hmbown/CodeWhale"
[workspace.dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
axum = { version = "0.8.5", features = ["json", "ws"] }
axum = { version = "0.8.5", features = ["json"] }
chrono = { version = "0.4.43", features = ["serde"] }
clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
dirs = "7.0.0"
encoding_rs = "0.8.35"
# Users: crates/workflow-js and the extension host in crates/tui (schemaui is
# gone); keep the graph on one jsonschema/jsonschema-regex/referencing/
# fancy-regex stack.
jsonschema = { version = "0.57", default-features = false }
libc = "0.2"
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] }
# NOT "parallel": the Workflow VM stays single-threaded and bridges to the
# multi-thread engine over channels (see crates/workflow-js).
rquickjs = { version = "0.14", features = ["futures"] }
rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.41.1", features = ["bundled"] }
dirs = "6.0.0"
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] }
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
# DSH bundle import: event-level parsing so aliases and explicit tags can be refused.
yaml-rust2 = { version = "0.13.0", default-features = false }
semver = "1.0.28"
thiserror = "2.0"
tempfile = "3.27"
tokio = { version = "1.50.0", features = ["fs", "io-util", "io-std", "macros", "net", "process", "rt", "rt-multi-thread", "signal", "sync", "time"] }
toml = "1.0.6"
toml_edit = "0.25.12"
sha2 = "0.11"
tower-http = { version = "0.7", features = ["cors"] }
tokio = { version = "1.49.0", features = ["full"] }
toml = "0.9.7"
sha2 = "0.10"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
gix = { version = "=0.90.0", default-features = false, features = ["sha1", "sha256"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.11", features = ["v4"] }
mimalloc = { version = "0.1", default-features = false }
# Off-by-default alternative to mimalloc behind the `rusty-alloc` feature in
# codewhale-tui / codewhale-cli (#5872): the pure-Rust mimalloc v2.4.5
# remake — no C compiler, no build script on that path. `rusty_alloc-api` is
# the crate's documented surface; it pulls the `rusty_alloc` core transitively
# at the same version.
rusty_alloc-api = "1.1.6"
# The everyday `--release` gate (AGENTS.md pre-push build): optimized but
# fast to produce. Shipping artifacts use `--profile dist` below — fat LTO on
# a 680k-line crate belongs in release CI, not in every contributor's loop
# (#5246; community-reported 8–16 minute local release builds, #4991).
# Dev/test carry line tables instead of full debug info. Backtraces still name
# the file and line of every frame — the thing anyone actually reads — but the
# per-variable DWARF that no one inspects without a debugger attached is gone.
# The debug binary was 227 MB and target/debug had grown past 260 GB; those
# links are IO-bound, so the debuginfo is paid on every incremental build.
# Anyone stepping through under lldb can override this locally.
[profile.dev]
debug = "line-tables-only"
[profile.release]
lto = "thin"
strip = true
codegen-units = 16
# Shipping profile: exactly the optimization the released binaries have
# always had. Every workflow that uploads a binary to users builds with
# `--profile dist` and collects from `target/**/dist/`.
# NOTE: no `panic = "abort"` here — the TUI's panic supervision
# (catch_unwind/spawn_supervised) needs unwinding so one panicking tool call
# or task fails gracefully instead of aborting the whole session.
[profile.dist]
inherits = "release"
lto = true
strip = true
codegen-units = 1
# Patch unicode-width so that width() uses CJK tables when the cjk feature
# is enabled. Vanilla unicode-width 0.2.2 provides width_cjk() as a separate
# method but the original width() always uses non-CJK tables, so Ratatui
# (which calls width() internally) measures ambiguous-width characters
# (circled digits, enclosed alphanumerics) as 1 column. CJK terminals render
# them as 2 columns, causing cell-offset rendering glitches. (#4479)
[patch.crates-io]
unicode-width = { path = "patches/unicode-width-0.2.2" }
# Windows Terminal paste dropped every non-BMP character (emoji): upstream pairs a
# surrogate half with its own key-up record. See patches/crossterm-0.29.0 parse.rs.
# Same change as crossterm-rs/crossterm#1073 (open); drop this once a release carries it.
# Also drops one redundant pair of parentheses and allows upstream warnings, because a
# path crate is built under our `-D warnings`.
crossterm = { path = "patches/crossterm-0.29.0" }
-20
View File
@@ -1,20 +0,0 @@
# Extension packager dependency security
The VS Code extension's packaging toolchain uses `@vscode/vsce` →
`@azure/identity` → `@azure/msal-node`. MSAL Node versions before 7.1.0 are
affected by [GHSA-xcx6-jx8p-x3h5](https://github.com/AzureAD/microsoft-authentication-library-for-js/security/advisories/GHSA-xcx6-jx8p-x3h5).
The extension manifest overrides Azure Identity's MSAL Node dependency to
`^7.1.2`; its lockfile resolves 7.1.2. Keep the override until Azure Identity
requires a patched version itself.
`npm run audit:dependencies -- --report <path>` audits the complete lockfile
and keeps the raw report. Every reported vulnerability blocks the check.
Incomplete reports, count mismatches and critical findings also fail. There
is no advisory exception.
After changing this dependency, materialize the lockfile with `npm ci`, run
the extension's existing `npm test` checks and build a local package with
`npm run package`. Packaging uses `vsce package --no-dependencies`, which
excludes development dependencies from the VSIX. A local package build does
not establish that a publishing authentication flow works; verify that flow
only when publication is separately authorized.
+19 -19
View File
@@ -1,11 +1,11 @@
# syntax=docker/dockerfile:1
# Codewhale multi-arch Docker image (#501)
# CodeWhale multi-arch Docker image (#501)
#
# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.codewhale codewhale
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.deepseek codewhale
#
# The image ships the canonical `codewhale` and `codew` command names in a
# minimal runtime layer.
# The image ships the canonical binaries (`codewhale`, `codewhale-tui`) plus
# the legacy `deepseek` / `deepseek-tui` shims in a minimal runtime layer.
#
# API keys MUST be passed at runtime (never baked into the image):
# docker run --rm -it -e DEEPSEEK_API_KEY codewhale
@@ -19,13 +19,13 @@ FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
ARG CODEWHALE_BUILD_SHA
ARG DEEPSEEK_BUILD_SHA
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
CODEWHALE_BUILD_SHA=${CODEWHALE_BUILD_SHA}
DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}
RUN if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
dpkg --add-architecture arm64; \
@@ -53,18 +53,18 @@ RUN rustup target add "$(cat /rust-target)"
WORKDIR /build
COPY . .
# Build the one runtime for the target platform. Expose the same verified
# bytes under both supported command names. --locked keeps the build
# reproducible from the committed lockfile.
# Build both binaries for the target platform. --locked ensures
# reproducible builds from the committed lockfile.
RUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \
--mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \
rustup target add "$(cat /rust-target)" \
&& cargo build --release --locked --target "$(cat /rust-target)" \
-p codewhale-cli \
cargo build --release --locked --target "$(cat /rust-target)" \
-p codewhale-cli -p codewhale-tui \
&& mkdir -p /out \
&& cp target/$(cat /rust-target)/release/codewhale /out/ \
&& cp target/$(cat /rust-target)/release/codewhale /out/codew
&& cp target/$(cat /rust-target)/release/codewhale-tui /out/ \
&& cp target/$(cat /rust-target)/release/deepseek /out/ \
&& cp target/$(cat /rust-target)/release/deepseek-tui /out/
# ── Stage 2: Runtime ──────────────────────────────────────────────────
FROM debian:bookworm-slim
@@ -74,20 +74,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libdbus-1-3 \
&& rm -rf /var/lib/apt/lists/*
# Non-root user with explicit UID/GID for filesystem ownership clarity. Keep
# the legacy state directory for read-fallback migration; v0.9.0 no longer
# ships legacy deepseek command shims.
# Non-root user with explicit UID/GID for filesystem ownership clarity.
RUN groupadd --gid 1000 codewhale \
&& useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \
&& install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.codewhale \
&& install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.deepseek
USER codewhale
WORKDIR /home/codewhale
COPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale
COPY --from=builder --chown=codewhale:codewhale /out/codew /usr/local/bin/codew
COPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui
COPY --from=builder --chown=codewhale:codewhale /out/deepseek /usr/local/bin/deepseek
COPY --from=builder --chown=codewhale:codewhale /out/deepseek-tui /usr/local/bin/deepseek-tui
# `codewhale` and `codew` are two command names for the same runtime.
# The dispatcher expects to find its companion binary next to it.
# Both are in /usr/local/bin — no further path setup needed.
ENTRYPOINT ["codewhale"]
CMD []
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024-2025 DeepSeek-TUI Contributors
Copyright (c) 2024-2025 DeepSeek CLI Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+460
View File
@@ -0,0 +1,460 @@
# 🐳 CodeWhale
> **このターミナルネイティブのコーディングエージェントは、DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。`codewhale` ディスパッチャーと `codewhale-tui` ランタイムの Rust バイナリペアとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。**
[English README](README.md)
[简体中文 README](README.zh-CN.md)
## インストール
`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm、Homebrew、Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。
```bash
# 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは
# GitHub Releases から対応するビルド済みバイナリペアをダウンロードする
# 薄いインストーラーであり、codewhale 本体に Node ランタイム依存を加えるものではありません。
npm install -g codewhale
# 2. Cargo — Node 不要。2 つの crate を両方インストールします。
cargo install codewhale-cli --locked # `codewhale` (エントリーポイント)
cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ)
# 3. Homebrew — macOS パッケージマネージャ。
# tap/formula 名は旧名のままですが、codewhale と codewhale-tui をインストールします。
brew tap Hmbown/deepseek-tui
brew install deepseek-tui
# 4. 直接ダウンロード — GitHub Releases のプラットフォームアーカイブ。
# https://github.com/Hmbown/CodeWhale/releases
# アーカイブには codewhale と codewhale-tui とインストールスクリプトが含まれます。
# 個別バイナリもスクリプト用に添付されています。手動ではペアを同じ場所に置いてください。
# 5. Docker — ビルド済みリリースイメージ。
docker volume create codewhale-home
docker run --rm -it \
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
-v codewhale-home:/home/codewhale/.codewhale \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/hmbown/codewhale:latest
```
> 中国本土では、`--registry=https://registry.npmmirror.com` を指定して npm 経由のダウンロードを高速化するか、下記の[Cargo ミラー](#中国--ミラーフレンドリーなインストール)を利用してください。
既にインストール済みの場合は、インストール方法に合わせて更新してください:
```bash
codewhale update
npm install -g codewhale@latest
brew update && brew upgrade deepseek-tui
cargo install codewhale-cli --locked --force
cargo install codewhale-tui --locked --force
```
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_AI-_.svg?style=flat&color=0052D9&labelColor=000000&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Hmbown/CodeWhale)
<a href="https://www.buymeacoffee.com/hmbown" target="_blank"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-5F7FFF?style=for-the-badge&logo=buymeacoffee&logoColor=white" alt="Buy me a coffee" /></a>
![codewhale スクリーンショット](assets/screenshot.png)
---
## codewhale とは?
モデルは質問に答えます。エージェントはタスクを完了します。その差がハーネス——モデルが迷走しないようにするルール、証拠、フィードバックのシステムです。
CodeWhale はそのハーネスであり、DeepSeek V4 を中心に構築され、3つの原則に導かれています:
| 原則 | 仕組み |
|---|---|
| **信頼から始める** | 毎ターン「A」で始まる——確実性より可能性、便利さより丁寧さ |
| **明確な管轄権** | 9階層の権威を持つ成文憲法。ユーザーの意図が古い指示より優先。検証が自信より優先。 |
| **再帰的改善** | V4 がハーネスの一部を書いた。ハーネスが改善されると V4 はより効果的になり、さらにハーネスを改善する。毎ターンがより強くなる。 |
オープンソース、ターミナルネイティブ、`codewhale` / `codewhale-tui` の Rust バイナリペアとして提供されています。
## ハーネスの仕組み
エージェントモデルは大規模な相反する情報を扱います:ユーザーの意図、プロジェクトルール、システムデフォルト、ツール出力、古いメモリが単一ターンで権威を競い合います。LLM が裁判官として機能するには管轄権が必要です——衝突したとき、どの情報源が勝つのか?
CodeWhale は**憲法**(`prompts/base.md`)でこれに答えます。これは形式化された法の階層です——第七条は憲法自体の条項から前セッションのハンドオフまで、9 つの情報源をランク付けします。ユーザーの現在のメッセージは古いプロジェクト指示より上。ライブのツール出力は仮定より上。検証は自信より上。モデルは毎ターン明確な権威チェーンを継承し、どの指示に従うべきか推測する必要がありません。
7 つの条項が階層の上にあり、モデルのアイデンティティ、義務、エージェンシーを定義します:検証義務(第5条——すべての行動は証拠を残し、信念で成功を宣言しない)、協調の遺産(第6条——次の知性のためにワークスペースを可読に保つ)、真実優先条項(第2条——下位のルールで上書きできない)。
DeepSeek V4 のプレフィックスキャッシュがこれを実用的にします。憲法は長く詳細ですが、一度キャッシュされるとコールドリードの約 100 分の 1 のコストになります。モデルはそれを再帰的に参照し——RLM セッションを通じて覗き、スキャンし、クエリし——単一の暗記パスに頼るのではなく、必要に応じて情報を再訪します。それは閉じた本のテストよりも、開いた本のテストのように機能します。
権威構造が明示的であるため、失敗は隠されません。非ゼロの終了コード、ターン間に届く rust-analyzer からの型エラー、サンドボックス拒否——これらは修正ベクトルとしてフィードバックされます。モデルは自身のドリフトを使って自己修正します。
3 つのモードが行動空間を制御します。Plan は読み取り専用。Agent は破壊的操作を承認ゲートの背後に置きます。YOLO は信頼済みワークスペースで自動承認します。macOS Seatbelt はアクティブなサンドボックス;Linux Landlock は検出されるが未適用;Windows サンドボックスは未公開。
Fin——thinking off の安価な Flash 呼び出し——がターンごとにモデル自動ルーティングを処理します。`--model auto` がデフォルトです。
毎ターン side-git スナップショットをリポジトリの `.git` 外に記録。`/restore` と `revert_turn` がワークスペースを即座にロールバックします。
サブエージェントは並行実行(最大 20)。`agent_open` は即座に戻り;結果は完了センチネルとしてインラインで到着し、サマリー付き。完全なトランスクリプトは `agent_eval` を通じて境界付きハンドルに保持されます。[docs/SUBAGENTS.md](docs/SUBAGENTS.md) を参照。
その他の機能面:編集ごとの LSP 診断(rust-analyzer、pyright、typescript-language-server、gopls、clangd)、バッチ分析用 RLM セッション、MCP プロトコル、HTTP/SSE ランタイム API、永続タスクキュー、Zed 向け ACP アダプター、SWE-bench エクスポート、キャッシュヒット/ミス内訳付きライブコスト追跡。
---
## ハーネス
`codewhale`(ディスパッチャー CLI)→ `codewhale-tui`(コンパニオンバイナリ)→ ratatui インターフェース ↔ 非同期エンジン ↔ OpenAI 互換のストリーミングクライアント。ツール呼び出しは型付きレジストリ(シェル、ファイル操作、Git、Web、サブエージェント、MCP、RLM)を経由してルーティングされ、結果はトランスクリプトへとストリーム返送されます。エンジンはセッション状態、ターン管理、永続タスクキューを管理し、LSP サブシステムは編集後の診断を次の推論ステップ前にモデルのコンテキストへ供給します。
詳しくは [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) を参照してください。
---
## クイックスタート
```bash
npm install -g codewhale
codewhale --version
codewhale --model auto
```
ビルド済みバイナリペアとプラットフォームアーカイブは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。
初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。
事前に設定することもできます:
```bash
codewhale auth set --provider deepseek # ~/.codewhale/config.toml に保存
export DEEPSEEK_API_KEY="YOUR_KEY" # 環境変数による代替方法。非対話シェルでは ~/.zshenv を使用
codewhale
codewhale doctor # セットアップを検証
```
> 保存済みキーをローテーション/削除するには: `codewhale auth clear --provider deepseek`。
### Linux ARM64(Raspberry Pi、Asahi、Graviton、HarmonyOS PC)
`npm i -g codewhale` は v0.8.8 以降、glibc ベースの ARM64 Linux で動作します。[Releases ページ](https://github.com/Hmbown/CodeWhale/releases) からビルド済みバイナリをダウンロードし、`PATH` 上に並べて配置することもできます。
### 中国 / ミラーフレンドリーなインストール
中国本土から GitHub または npm のダウンロードが遅い場合は、Cargo レジストリのミラーを利用してください:
```toml
# ~/.cargo/config.toml
[source.crates-io]
replace-with = "tuna"
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
```
その後、両方のバイナリをインストールしてください(ディスパッチャーは実行時に TUI へ委譲します):
```bash
cargo install codewhale-cli --locked # `codewhale` を提供
cargo install codewhale-tui --locked # `codewhale-tui` を提供
codewhale --version
```
ビルド済みバイナリは [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases) からもダウンロードできます。ミラーされたリリースアセットには `DEEPSEEK_TUI_RELEASE_BASE_URL` を使ってください。
### Windows(Scoop)
[Scoop](https://scoop.sh) は Windows のパッケージマネージャです。`codewhale`
パッケージは Scoop main bucket にありますが、manifest は GitHub/npm/Cargo
リリースより遅れることがあります。先に更新し、インストール後に
`codewhale --version` で確認してください:
```bash
scoop update
scoop install codewhale
codewhale --version
```
<details id="install-from-source">
<summary>ソースからのインストール</summary>
任意の Tier-1 Rust ターゲット — musl、riscv64、FreeBSD、古い ARM64 ディストロを含む — で動作します。
```bash
# Linux のビルド依存関係 (Debian/Ubuntu/RHEL):
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
git clone https://github.com/Hmbown/CodeWhale.git
cd CodeWhale
cargo install --path crates/cli --locked # Rust 1.88+ が必要。`codewhale` を提供
cargo install --path crates/tui --locked # `codewhale-tui` を提供
```
両方のバイナリが必要です。クロスコンパイルとプラットフォーム固有の注意事項: [docs/INSTALL.md](docs/INSTALL.md)。
</details>
### その他の API プロバイダー
```bash
# NVIDIA NIM
codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
codewhale --provider nvidia-nim
# AtlasCloud
codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"
codewhale --provider atlascloud
# Wanjie Ark
codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"
codewhale --provider wanjie-ark --model deepseek-reasoner
# OpenRouter
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
# Novita
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
codewhale --provider novita --model deepseek/deepseek-v4-pro
# Fireworks
codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
codewhale --provider fireworks --model deepseek-v4-pro
# 汎用 OpenAI 互換エンドポイント
codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5
# セルフホスト SGLang
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
# セルフホスト vLLM
VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
# セルフホスト Ollama
ollama pull codewhale-coder:1.3b
codewhale --provider ollama --model codewhale-coder:1.3b
```
TUI 内では `/provider` でプロバイダーピッカー、`/model` でローカルのモデル/思考モードピッカーを開けます。`/provider openrouter` や `/model <id>` で直接切り替え、`/models` で対応プロバイダーのライブモデル一覧を明示的に取得できます。
---
## リリースノート
バージョンごとの変更点は [CHANGELOG.md](CHANGELOG.md) にまとめています。この README は、現在のインストール方法、主要ワークフロー、プロバイダー設定、ランタイムインターフェース、拡張ポイントに絞っています。
---
## 使い方
```bash
codewhale # インタラクティブ TUI
codewhale "explain this function" # ワンショットプロンプト
codewhale exec --auto --output-format stream-json "fix this bug" # NDJSON バックエンドストリーム
codewhale exec --resume <SESSION_ID> "follow up" # 非対話セッションを継続
codewhale --model deepseek-v4-flash "summarize" # モデルの上書き
codewhale --model auto "fix this bug" # モデルと推論強度を自動選択
codewhale --yolo # ツールを自動承認
codewhale auth set --provider deepseek # API キーの保存
codewhale doctor # セットアップと接続性のチェック
codewhale doctor --json # 機械可読の診断
codewhale setup --status # 読み取り専用のセットアップ状態
codewhale setup --tools --plugins # ツール/プラグインディレクトリの雛形作成
codewhale models # ライブ API モデル一覧
codewhale sessions # 保存済みセッション一覧
codewhale resume --last # 最新セッションを再開
codewhale resume <SESSION_ID> # UUID 指定で特定セッションを再開
codewhale fork <SESSION_ID> # 保存済みセッションを兄弟パスに fork
codewhale serve --http # HTTP/SSE API サーバー
codewhale serve --acp # Zed/カスタムエージェント向け ACP stdio アダプター
codewhale run pr <N> # PR を取得しレビュープロンプトに先行投入
codewhale mcp list # 設定された MCP サーバー一覧
codewhale mcp validate # MCP の設定/接続性を検証
codewhale mcp-server # ディスパッチャー MCP stdio サーバーを実行
codewhale update # バイナリ更新の確認と適用
```
### キーボードショートカット
| キー | 動作 |
|---|---|
| `Tab` | `/` または `@` のエントリ補完。実行中はドラフトをフォローアップとしてキューに追加。それ以外はモード切替 |
| `Shift+Tab` | 推論努力の切替: off → high → max |
| `F1` | 検索可能なヘルプオーバーレイ |
| `Esc` | 戻る/閉じる |
| `Ctrl+K` | コマンドパレット |
| `Ctrl+R` | 以前のセッションを再開 |
| `Alt+R` | プロンプト履歴を検索し、消去したドラフトを復元 |
| `Ctrl+S` | 現在のドラフトを退避(`/stash list`、`/stash pop` で復元) |
| `@path` | コンポーザーにファイル/ディレクトリのコンテキストを添付 |
| `↑`(コンポーザー先頭で) | 添付ファイル行を選択して削除 |
| `Alt+↑` | キュー済みの最後のメッセージを編集 |
ショートカット完全版: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md)。
---
## モード
| モード | 動作 |
| --- | --- |
| **Plan** 🔍 | 読み取り専用の調査 — 変更を加える前に、モデルが探索して計画を提案(`update_plan` + `checklist_write`) |
| **Agent** 🤖 | デフォルトのインタラクティブモード — 承認ゲート付きのマルチステップなツール利用。モデルは `checklist_write` で作業を概説 |
| **YOLO** ⚡ | 信頼できるワークスペースですべてのツールを自動承認。可視性のための計画とチェックリストは引き続き維持 |
---
## 設定
ユーザー設定: `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)。プロジェクトオーバーレイ: `<workspace>/.codewhale/config.toml`(旧 `<workspace>/.deepseek/config.toml`)(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。
主な環境変数:
| 変数 | 用途 |
|---|---|
| `DEEPSEEK_API_KEY` | API キー |
| `DEEPSEEK_BASE_URL` | API ベース URL |
| `DEEPSEEK_HTTP_HEADERS` | 任意のモデルリクエストヘッダー |
| `DEEPSEEK_MODEL` | デフォルトモデル |
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | ストリームのアイドルタイムアウト秒数 |
| `DEEPSEEK_PROVIDER` | `codewhale`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
| `DEEPSEEK_PROFILE` | 設定プロファイル名 |
| `DEEPSEEK_MEMORY` | `on` に設定するとユーザーメモリを有効化 |
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 信頼できるネットワークで非ローカル `http://` API ベース URL を許可 |
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 |
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | 汎用 OpenAI 互換エンドポイントとモデル ID |
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud エンドポイントとモデル上書き |
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark エンドポイントとモデル上書き |
| `OPENROUTER_BASE_URL` | OpenRouter エンドポイント上書き |
| `NOVITA_BASE_URL` | Novita エンドポイント上書き |
| `FIREWORKS_BASE_URL` | Fireworks エンドポイント上書き |
| `SGLANG_BASE_URL` | セルフホスト SGLang のエンドポイント |
| `SGLANG_MODEL` | セルフホスト SGLang のモデル ID |
| `VLLM_BASE_URL` | セルフホスト vLLM のエンドポイント |
| `VLLM_MODEL` | セルフホスト vLLM のモデル ID |
| `OLLAMA_BASE_URL` | セルフホスト Ollama のエンドポイント |
| `OLLAMA_MODEL` | セルフホスト Ollama のモデルタグ |
| `NO_ANIMATIONS=1` | 起動時にアクセシビリティモードを強制 |
| `SSL_CERT_FILE` | 企業プロキシ向けのカスタム CA バンドル |
UI のロケールはモデルの言語とは別です。`settings.toml` で `locale` を設定するか、`/config locale zh-Hans` を使うか、`LC_ALL`/`LANG` に依存させてください。詳しくは [docs/CONFIGURATION.md](docs/CONFIGURATION.md) と [docs/MCP.md](docs/MCP.md) を参照してください。
---
## モデルと料金
| モデル | コンテキスト | 入力(キャッシュヒット) | 入力(キャッシュミス) | 出力 |
|---|---|---|---|---|
| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M |
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
レガシーエイリアス `deepseek-chat` / `deepseek-reasoner` は `deepseek-v4-flash` にマップされます。NVIDIA NIM のバリアントはあなたの NVIDIA アカウント条件に従います。
> [!Note]
> 上記の V4 Pro レートは恒久的な料金になりました。DeepSeek は、2026 年 5 月 31 日 15:59 UTC に 75% 期間限定割引が終了するタイミングで、元の料金を 4 分の 1 に正式に調整しました。TUI のコスト見積もりはすでにこれらの値を使用しているため、コード上の変更は不要です。今後の価格変更については、公式の [DeepSeek 価格ページ](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) を参照してください。
---
## 自分のスキルを公開する
codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.codewhale/skills`(旧 `~/.deepseek/skills` も互換性維持)からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです:
```text
~/.codewhale/skills/my-skill/
└── SKILL.md
```
必要なフロントマター:
```markdown
---
name: my-skill
description: DeepSeek にカスタムワークフローを実行させたいときに利用する。
---
# My Skill
ここにエージェント向けの指示を記述します。
```
コマンド: `/skills`(一覧)、`/skill <name>`(有効化)、`/skill new`(雛形)、`/skill install github:<owner>/<repo>`(コミュニティ)、`/skill update` / `uninstall` / `trust`。GitHub からのコミュニティインストールにバックエンドサービスは不要です。インストール済みのスキルはモデルに見えるセッションコンテキストに表示され、タスクが説明文にマッチした場合はエージェントが `load_skill` ツールを通じて関連スキルを自動選択できます。
---
## ドキュメント
| ドキュメント | トピック |
|---|---|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | コードベース内部 |
| [CONFIGURATION.md](docs/CONFIGURATION.md) | 設定の完全リファレンス |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO モード |
| [MCP.md](docs/MCP.md) | Model Context Protocol 統合 |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API サーバー |
| [INSTALL.md](docs/INSTALL.md) | プラットフォーム別インストールガイド |
| [DOCKER.md](docs/DOCKER.md) | GHCR イメージ、ボリューム、Docker 利用方法 |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB ミラーと中国向けインストールメモ |
| [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Tencent/CNB/Lighthouse/Feishu のリモート優先パス |
| [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Tencent Lighthouse 香港インスタンス設定 |
| [MEMORY.md](docs/MEMORY.md) | ユーザーメモリ機能ガイド |
| [SUBAGENTS.md](docs/SUBAGENTS.md) | サブエージェントの役割分類とライフサイクル |
| [KEYBINDINGS.md](docs/KEYBINDINGS.md) | ショートカット完全カタログ |
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | リリースプロセス |
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | 運用とリカバリ |
完全な変更履歴: [CHANGELOG.md](CHANGELOG.md)。
---
## 謝辞
このプロジェクトは、増え続けるコントリビューターのコミュニティから助けを得て出荷されています:
- **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 機能、修正、VS Code 拡張のスキャフォールドにまたがる 28 件の PR (#645–#681)
- **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — 表、太字/斜体、水平線の Markdown レンダリング (#579)
- **[loongmiaow-pixel](https://github.com/loongmiaow-pixel)** — Windows と中国向けインストールドキュメント (#578)
- **[20bytes](https://github.com/20bytes)** — ユーザーメモリのドキュメントとヘルプの磨き込み (#569)
- **[staryxchen](https://github.com/staryxchen)** — glibc 互換性のプリフライト (#556)
- **[Vishnu1837](https://github.com/Vishnu1837)** — glibc 互換性の改善 (#565)
- **[shentoumengxin](https://github.com/shentoumengxin)** — シェル `cwd` の境界バリデーション (#524)
- **[toi500](https://github.com/toi500)** — Windows 貼り付け修正の報告
- **[xsstomy](https://github.com/xsstomy)** — ターミナル起動時の再描画報告
- **[melody0709](https://github.com/melody0709)** — スラッシュ接頭辞の Enter アクティベーション報告
- **[lloydzhou](https://github.com/lloydzhou)** と **[jeoor](https://github.com/jeoor)** — コンパクションコストの報告
- **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README の明瞭化対応 (#685)
- **[woyxiang](https://github.com/woyxiang)** — Windows Scoop インストールドキュメント (#696)
- **[wangfeng](mailto:[email protected])** — 料金/割引情報の更新 (#692)
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
- **Hafeez Pizofreude** — `fetch_url` の SSRF 保護と Star History チャート
- **Unic (YuniqueUnic)** — スキーマ駆動の設定 UI(TUI + Web)
- **Jason** — SSRF セキュリティの強化
- **[Fire-dtx](https://github.com/Fire-dtx)** — 並行サブエージェントのデッドロック修正 (#1856)
- **[imkingjh999](https://github.com/imkingjh999)** — コンポーザーのテキスト選択とコピー/カット (#2228)
- **[harvey2011888](https://github.com/harvey2011888)** — ループガード停止結果の修正 (#1859)
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO 環境変数マージ修正 (#1870)
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] 完了マーカー (#1935)
- **[PurplePulse](https://github.com/PurplePulse)** — macOS オンボーディングレイアウト修正 (#1837)
- **[cyq1017](https://github.com/cyq1017)** — /config ビューでのベース URL 表示 (#1967)
- **[knqiufan](https://github.com/knqiufan)** — 文字折り返しなしのトランスクリプトコピー (#1906)
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro 価格の恒久化 (#1937)
- **[hongqitai](https://github.com/hongqitai)** — Clippy 警告の修正 (#2237)
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md プロジェクトコンテキスト (#1852)
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi MiMo プロバイダーサポート (#2240)
- **[mvanhorn](https://github.com/mvanhorn)** — グローバル AGENTS.md ベンダーニュートラルフォールバック (#2156)
---
## コントリビューション
[CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。プルリクエストを歓迎します。良い初コントリビューションは [Open Issues](https://github.com/Hmbown/CodeWhale/issues) を確認してください。
> [!Note]
> *DeepSeek Inc. とは関係ありません。*
## ライセンス
[MIT](LICENSE)
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date&logscale=&legend=top-left)
+625 -214
View File
@@ -1,278 +1,689 @@
<div align="center">
# CodeWhale
<picture>
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark-inverted.svg">
<img src="brand/wordmark.svg" alt="Codewhale" width="320">
</picture>
> Terminal coding agent for DeepSeek V4. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and includes an auto mode that chooses both model and thinking level per turn.
**An open-source coding agent for your terminal that works with any model.**
Give Codewhale a task and it reads the repository, edits files, and runs
commands until the task is done or it needs input from you. It works with more
than 40 built-in providers, DeepSeek V4.1 Flash by default, and with models running on
your own machine. `/receipts` lists the files it changed, the commands it ran,
and the web and MCP calls it made.
[![CI](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[Website](https://codewhale.net) · [Documentation](docs/README.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
[简体中文](docs/i18n/README.zh-CN.md) · [日本語](docs/i18n/README.ja-JP.md) · [Tiếng Việt](docs/i18n/README.vi.md) · [Bahasa Indonesia](docs/i18n/README.id.md) · [한국어](docs/i18n/README.ko-KR.md) · [Español](docs/i18n/README.es-419.md) · [Português](docs/i18n/README.pt-BR.md) · [Русский](docs/i18n/README.ru.md) · [Українська](docs/i18n/README.uk.md) · [Français](docs/i18n/README.fr.md) · [Deutsch](docs/i18n/README.de.md) · [繁體中文](docs/i18n/README.zh-TW.md) · [हिन्दी](docs/i18n/README.hi.md) · [Türkçe](docs/i18n/README.tr.md) · [Italiano](docs/i18n/README.it.md) · [Polski](docs/i18n/README.pl.md) · [العربية](docs/i18n/README.ar.md) · [Català](docs/i18n/README.ca.md)
<img src="web/public/codewhale-tui-0102-demo.png" alt="A Codewhale terminal session" width="760">
<sub>A 0.10.2 development build with a scripted test model, in Full Access inside a throwaway workspace. The edit, three tests and a sub-agent review ran for real.</sub>
</div>
<p align="center">
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">
<img src="web/public/codewhale-x-orcarouter.webp" alt="Codewhale × OrcaRouter — +30% DeepSeek v4.1 Flash usage" width="760">
</a>
</p>
[View the Codewhale × OrcaRouter offer](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67). Codewhale earns a referral fee from OrcaRouter.
[简体中文 README](README.zh-CN.md)
[日本語 README](README.ja-JP.md)
## Install
macOS and Linux:
`codewhale` installs as a matched pair of self-contained Rust release binaries:
the `codewhale` dispatcher command and the sibling `codewhale-tui` runtime it
launches for interactive sessions. npm, Homebrew, and Docker install both for
you; Cargo and manual installs must put both binaries in the same directory
(normally a directory on your `PATH`). The npm package is only an
installer/wrapper for those release binaries; the agent does not run on Node.
```bash
curl -fsSL https://codewhale.net/install.sh | sh
# 1. npm — easiest if you already use Node. The package downloads the
# matching prebuilt Rust binaries from GitHub Releases.
npm install -g codewhale
# 2. Cargo — no Node needed. Requires Rust 1.88+ (the crates use the
# 2024 edition; older toolchains fail with "feature `edition2024` is
# required"). Run `rustup update` first, or use a non-Cargo path below.
cargo install codewhale-cli --locked # `codewhale` (entry point)
cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary)
# 3. Homebrew — macOS package manager.
# The tap/formula name is legacy; it installs codewhale and codewhale-tui.
brew tap Hmbown/deepseek-tui
brew install deepseek-tui
# 4. Direct download — platform archive from GitHub Releases.
# https://github.com/Hmbown/CodeWhale/releases
# Archives include both codewhale and codewhale-tui plus an install script.
# Individual binaries are also attached for scripts; keep the pair together.
# 5. Docker — prebuilt release image.
docker volume create codewhale-home
docker run --rm -it \
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
-v codewhale-home:/home/codewhale/.codewhale \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/hmbown/codewhale:latest
```
The installer puts checksum-verified `codewhale` and its short alias `codew`
in `~/.local/bin`. If your shell then says `command not found`, run the PATH
line the installer prints ([details](docs/INSTALL.md#put-it-on-your-path)).
`codewhale update` upgrades it. These commands install the latest release.
This page follows `main`, so a feature marked with a version arrives in that
release; the [changelog](CHANGELOG.md) lists what each release adds.
> In mainland China, speed up the npm path with
> `--registry=https://registry.npmmirror.com`, or use the
> [Cargo mirror](#china--mirror-friendly-installation) below.
>
> Download safety: official release binaries live under
> `https://github.com/Hmbown/CodeWhale/releases`. For manual downloads,
> verify the SHA-256 manifest and avoid look-alike repositories or search-result
> mirrors. See [download safety and checksums](docs/INSTALL.md#2-download-safety-and-checksums).
<details>
<summary><b>Windows, npm, Cargo, and other ways to install</b></summary>
Already installed? Use the updater that matches the install path:
```bash
winget install HunterBown.CodeWhale # Windows x64 (or Scoop, or the installer from GitHub Releases)
npm install -g codewhale # wraps the same release binaries
cargo install codewhale-cli --locked # crates.io; can trail GitHub Releases
codewhale update # release-binary updater
npm install -g codewhale@latest # npm wrapper
brew update && brew upgrade deepseek-tui
cargo install codewhale-cli --locked --force
cargo install codewhale-tui --locked --force
```
Prebuilt binaries cover Linux, macOS, and Windows on x64 and arm64. Nix,
Homebrew on Linux, Android/Termux (preview), and manual downloads are in the
[installation guide](docs/INSTALL.md), and containers in
[Docker](docs/DOCKER.md). Stick to one install method per machine; otherwise
two copies end up on `PATH` and whichever comes first wins.
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale)
</details>
![codewhale screenshot](assets/screenshot.png)
---
## What Is It?
A model answers a question. An agent finishes a task. The difference is
the harness — a system of rules, evidence, and feedback that keeps the
model oriented instead of drifting.
CodeWhale is that harness, built around DeepSeek V4 and guided by three ideas:
| Principle | How it works |
|---|---|
| **Start with trust** | Every turn begins with "A" — possibility before certainty, craft before convenience |
| **Clear jurisdiction** | A written Constitution with nine tiers of authority. User intent outranks stale instructions. Verification outranks confidence. |
| **Recursive improvement** | V4 helped write the harness. As the harness improves, V4 becomes more effective — and helps improve the harness further. Each turn starts stronger. |
It's open source, terminal-native, and packaged as a matched `codewhale` /
`codewhale-tui` Rust binary pair.
## How the Harness Works
Agentic models deal with conflicting information at scale: user intent,
project rules, system defaults, tool output, and stale memory all compete
for authority in a single turn. LLM-as-a-judge needs jurisdiction — which
source wins when they disagree?
CodeWhale answers this with a **Constitution** (`prompts/base.md`). It's a
formal hierarchy of law — Article VII ranks nine sources from the
Constitution's own articles down to prior-session handoffs. The user's
current message outranks stale project instructions. Live tool output
outranks assumptions. Verification outranks confidence. The model inherits
a clear chain of authority every turn and never has to guess which
directive to follow.
Seven articles sit above the hierarchy, defining the model's identity,
duties, and agency: a verification mandate (Article V — every action leaves
evidence, never declare success on faith), a coordination legacy (Article
VI — leave the workspace legible for the next intelligence), and a
primacy-of-truth clause (Article II — no lower rule may override it).
Article V is now structural. A closed-loop verification gate runs after every
side-effect tool call — write, edit, patch, shell — and re-checks the claim
before the result enters the session stream. Results carry `[VERIFY PASS]` or
`[VERIFY FAIL]` annotations. No more taking the model's word for it.
DeepSeek V4's prefix caching makes this practical. The Constitution is long
and detailed, but once cached it costs roughly 100× less per turn than a
cold read. The model references it recursively — peeking, scanning, and
querying through RLM sessions — revisiting information on demand rather
than relying on a single memorized pass. It performs more like an
open-book test than a closed one.
Because the authority structure is explicit, failure isn't hidden. Non-zero
exit codes, type errors from rust-analyzer arriving between turns, sandbox
denials — these are fed back as correction vectors. The model uses its own
drift to self-correct.
Three modes control the action space. Plan is read-only. Agent gates
destructive operations behind approval. YOLO auto-approves in trusted
workspaces. macOS Seatbelt is the active sandbox; Linux Landlock is
detected but not yet enforced; Windows sandboxing is not yet advertised.
Fin — a cheap Flash call with thinking off — handles model auto-routing per
turn. `--model auto` is the default.
Every turn records a side-git snapshot outside your repo's `.git`.
`/restore` and `revert_turn` roll back the workspace.
Sub-agents run concurrently (up to 20). `agent_open` returns immediately;
results arrive inline as completion sentinels with a summary. Full
transcripts stay behind bounded handles through `agent_eval`. See
[docs/SUBAGENTS.md](docs/SUBAGENTS.md).
The rest of the surface: LSP diagnostics after every edit (rust-analyzer,
pyright, typescript-language-server, gopls, clangd), RLM sessions for
batched analysis, MCP protocol, HTTP/SSE runtime API, persistent task
queue, ACP adapter for Zed, SWE-bench export, and live cost tracking with
cache hit/miss breakdowns.
---
## The Harness
`codewhale` (dispatcher CLI) → `codewhale-tui` (companion binary) → ratatui interface ↔ async engine ↔ OpenAI-compatible streaming client. Tool calls route through a typed registry (shell, file ops, git, web, sub-agents, MCP, RLM) and results stream back into the transcript. The engine manages session state, turn tracking, the durable task queue, and an LSP subsystem that feeds post-edit diagnostics into the model's context before the next reasoning step.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full walkthrough.
### Sub-agents: Concurrent Background Execution
CodeWhale can dispatch multiple sub-agents that run in parallel — like a concurrent task queue:
- **Non-blocking launch.** `agent_open` returns immediately. The child gets its own fresh context and tool registry and runs independently. The parent keeps working.
- **Background execution.** Sub-agents execute concurrently (default cap: 10, configurable to 20). The engine manages the pool — no polling loop needed.
- **Completion notification.** When a sub-agent finishes, the runtime injects a `<codewhale:subagent.done>` sentinel into the parent's transcript. The human-readable summary — including the child's findings, changed files, and any risks — sits on the line immediately before the sentinel. The parent model reads that summary and integrates findings without an extra tool call.
- **Bounded result retrieval.** The full child transcript lives behind a `transcript_handle` accessible through `agent_eval`. When the summary isn't enough, the parent calls `handle_read` for slices, line ranges, or JSONPath projections — keeping the parent context lean without losing access to the details.
See [docs/SUBAGENTS.md](docs/SUBAGENTS.md) for the full sub-agent reference.
---
## Quickstart
1. Commit or stash your work, then run `codewhale` from the root of a git
repository. By default it asks before each file edit and command; press
`Shift+Tab` once for **Accept Edits** to let it edit files in the
repository without asking.
2. Run `/provider` (or press `F3`), pick a provider, paste its API key, and
choose a model. DeepSeek is the default provider. With no key, pick a local
runtime instead. If no provider is configured and Ollama is already serving
a chat model, Codewhale uses it automatically; from 0.10.2 it offers it when
you send your first message.
3. Type a task:
```text
Fix the failing tests and explain what changed.
```bash
npm install -g codewhale
codewhale --version
codewhale --model auto
```
Read-only commands run straight away. For any other shell command Codewhale
asks first: `y` runs it once, `a` allows commands like it for the rest of the
conversation, `n` refuses. `/undo` takes back your last request and its file
changes. `/help` lists commands and keys.
Prebuilt binary pairs and platform archives are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md).
For scripts and CI, `codewhale exec` runs one task without the interface. By
default, `--auto` uses Full Access: tools run without asking, shell commands
have no Codewhale sandbox, and trust mode lets file tools write outside the
workspace. Add `--sandbox workspace-write` to keep the shell sandbox and
file-tool workspace restrictions, with trust mode off:
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` also supported) so it works from any directory without OS credential prompts.
You can also set it ahead of time:
```bash
codewhale exec --auto --sandbox workspace-write "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # saves to ~/.codewhale/config.toml
codewhale auth status # shows the active credential source
export DEEPSEEK_API_KEY="YOUR_KEY" # env var alternative; use ~/.zshenv for non-interactive shells
codewhale
codewhale doctor # verify setup
```
## Modes and permissions
If `codewhale doctor` says the rejected key came from `DEEPSEEK_API_KEY`, remove
the stale export from your shell startup file, open a fresh shell, or run
`codewhale auth set --provider deepseek`. Use `codewhale auth status` to see the
config, keyring, and env-var source state without printing the key. Saved config
keys take precedence over the keyring and environment and are easier to rotate.
A fresh install starts in Work mode with the Ask permission.
> To rotate or remove a saved key: `codewhale auth clear --provider deepseek`.
| | Switch with | Options |
| --- | --- | --- |
| Mode | `Tab` (when the input is empty) or `/mode` | **Plan** reads and searches but changes nothing. **Work** edits files and runs commands; start a goal with `/goal` when a request needs more than one turn. |
| Permission | `Shift+Tab` | **Ask** asks before every file edit and every shell command that is not read-only. **Accept Edits** applies file edits in your git repository without asking and still asks before commands. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks and turns off Codewhale's sandbox; repository rules and managed policy still apply. |
### Tencent Cloud / CNB Remote-First Path
See the [modes and permissions guide](docs/MODES.md) for the details.
For an always-on workspace you can control from a phone, use the Tencent-native
path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection
bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API
stays bound to localhost; EdgeOne is not used to expose `/v1/*`.
## What it does
Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md),
then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the
server runbook.
### New in 0.10.2
### Auto Mode
- `/pet on` turns your terminal into an animated whale that shows what the
agent is doing, while the message box stays live. Its 980 particles spell
the current step in English, Chinese or Japanese.
- `/workbar terminal` shows the shells the agent is running as they run. Type
into one, or press `Ctrl+N` for a shell of your own (macOS and Linux).
- `/undo` takes back your whole last request in one step, including every file
it changed, and `/diff` works in folders without Git.
- When a plan is ready, Codewhale asks whether to start Work with Ask or
Auto-Review, or keep planning.
- `/color` gives each chat its own composer color, saved with the session.
- `web_search` can use DeepSeek's new Search API: set
`[search] provider = "deepseek"` with a DeepSeek key.
- Outside Full Access, a `curl` or `wget` that would upload a credential never
runs without your approval, and a command that hides invisible Unicode is
refused. `write` no longer replaces a file the agent has not read, or one
that changed after it read it.
Use `codewhale --model auto` or `/model auto` when you want codewhale to decide how much model and reasoning power a turn needs.
### Models
Auto mode controls two settings together:
More than 40 providers are built in, including Anthropic, OpenAI, DeepSeek,
Google, Mistral, Moonshot, OpenRouter, and xAI. Any OpenAI-compatible endpoint
works, and so do local models through Ollama, vLLM, or SGLang. The default is
DeepSeek's `deepseek-flash`, which DeepSeek moved to DeepSeek-V4.1-Flash-1017
on 2026-10-17; 0.10.1 runs it without an update
([details](docs/PROVIDERS.md#deepseek-v41-flash)).
- Model: `deepseek-v4-flash` or `deepseek-v4-pro`
- Thinking: `off`, `high`, or `max`
`/model` switches models between turns, and `/model auto` picks one for each
turn (`/router` sets up how). `/turn inspect` shows which model a turn used,
what it cost and how long it took. `codewhale auth chatgpt` lets you use a
ChatGPT plan instead of an API key (experimental). See
[Providers](docs/PROVIDERS.md).
Before the real turn is sent, the app makes a small `deepseek-v4-flash` routing call with thinking off. That router looks at the latest request and recent context, then selects a concrete model and thinking level for the real request. Short/simple turns can stay on Flash with thinking off; coding, debugging, release work, architecture, security review, or ambiguous multi-step tasks can move up to Pro and/or higher thinking.
### Receipts and undo
`auto` is local to codewhale. The upstream API never receives `model: "auto"`; it receives the concrete model and thinking setting chosen for that turn. The TUI shows the selected route, and cost tracking is charged against the model that actually ran. If the router call fails or returns an invalid answer, the app falls back to a local heuristic. Sub-agents inherit auto mode unless you assign them an explicit model.
`/receipts`, or `codewhale receipts --last` after you quit, lists the files
changed, commands run, web and MCP calls, agents started, approvals and who
gave them, and failures. `/undo` takes back your last request and the files it
changed, in one step and without trust mode. `/restore`
rolls the whole workspace back to an earlier snapshot; because that overwrites
later edits, it needs `/trust on` or Full Access. See [Receipts](docs/RECEIPTS.md).
Use a fixed model or fixed thinking level when you want repeatable benchmarking, a strict cost ceiling, or a specific provider/model mapping.
Exec runs that offer tools are saved in every output format, so
`codewhale receipts --last` works after `exec --auto`, including `--json`.
Answer-only text and `--json` runs are saved only when resuming or when
conversation compaction starts; use `--output-format stream-json` to always
save them for receipts or `exec --continue`.
### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC)
### Longer work
`npm i -g codewhale` works on glibc-based ARM64 Linux from v0.8.8 onward. You can also download prebuilt binaries from the [Releases page](https://github.com/Hmbown/CodeWhale/releases) and place them side by side on your `PATH`.
`/goal` sets an objective that carries across turns. Codewhale can split work
across [sub-agents](docs/SUBAGENTS.md), run a [fleet](docs/FLEET.md) of agents,
or follow a [workflow](docs/WORKFLOW_AUTHORING.md) checked into the
repository.
### China / Mirror-friendly Installation
### Coming from another agent
If GitHub or npm downloads are slow from mainland China, use a Cargo registry mirror:
Codewhale reads `AGENTS.md` on its own. Instruction files written for other
tools, such as `CLAUDE.md`, `.cursorrules`, or
`.github/copilot-instructions.md`, load only when you opt in with
`project_instruction_imports = ["claude"]` (or `"cursor"`, `"cline"`,
`"windsurf"`, `"copilot"`, `"all"`) in your config; see
[config.example.toml](config.example.toml). Codewhale warns when it finds one
it did not load.
```toml
# ~/.cargo/config.toml
[source.crates-io]
replace-with = "tuna"
`/import-claude` prepares an import of your Claude Code settings and MCP
servers for you to review. `/plugin install` accepts Claude Code plugins
containing skills, commands, agent profiles, and MCP servers, and refuses
plugins that need hooks or LSP servers
([details](docs/CLAUDE_PLUGIN_COMPAT.md)).
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
```
### Extensions
Then install both binaries (the dispatcher delegates to the TUI at runtime):
Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
events. Code mode (experimental, on by default) lets the model write one
`execute_tools` program that calls several MCP and plugin tools; each call
still meets your approval rules and keeps its receipt. The independently installed Computer
Use plugin (experimental, macOS only) can observe
and operate other apps. It stays off until you review its access and enable it
([guide](https://github.com/codewhale-hq/codewhale-cu-plugin)).
```bash
cargo install codewhale-cli --locked # provides `codewhale`
cargo install codewhale-tui --locked # provides `codewhale-tui`
codewhale --version
```
## Ways to run it
Prebuilt binaries can also be downloaded from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets.
| Command | What it does |
### Windows (Scoop)
[Scoop](https://scoop.sh) is a Windows package manager. The `codewhale` package is listed
in Scoop's main bucket, but that manifest updates independently and can lag the
GitHub/npm/Cargo release. Run `scoop update` first, then verify the installed
version with `codewhale --version`:
```bash
scoop update
scoop install codewhale
codewhale --version
```
Use npm or direct GitHub release downloads when you need the newest release
before Scoop's manifest catches up.
<details id="install-from-source">
<summary>Install from source</summary>
Works on any Tier-1 Rust target — including musl, riscv64, FreeBSD, and older ARM64 distros.
```bash
# Linux build deps (Debian/Ubuntu/RHEL):
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
git clone https://github.com/Hmbown/CodeWhale.git
cd CodeWhale
cargo install --path crates/cli --locked # requires Rust 1.88+; provides `codewhale`
cargo install --path crates/tui --locked # provides `codewhale-tui`
```
Both binaries are required. Cross-compilation and platform-specific notes: [docs/INSTALL.md](docs/INSTALL.md).
</details>
### Other API Providers
For the full shipped provider registry, including model IDs, auth variables,
base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md).
```bash
# NVIDIA NIM
codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
codewhale --provider nvidia-nim
# AtlasCloud
codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"
codewhale --provider atlascloud
# Wanjie Ark
codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"
codewhale --provider wanjie-ark --model deepseek-reasoner
# OpenRouter
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
# Novita
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
codewhale --provider novita --model deepseek/deepseek-v4-pro
# Fireworks
codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
codewhale --provider fireworks --model deepseek-v4-pro
# Generic OpenAI-compatible endpoint
codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5
# Self-hosted SGLang
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
# Self-hosted vLLM
VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
# Trusted LAN vLLM over HTTP
DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
# Xiaomi MiMo
codewhale auth set --provider xiaomi --api-key "YOUR_MIMO_API_KEY"
codewhale --provider xiaomi --model deepseek-v4-pro
# Self-hosted Ollama
ollama pull codewhale-coder:1.3b
codewhale --provider ollama --model codewhale-coder:1.3b
```
Inside the TUI, `/provider` opens the provider picker and `/model` opens the
local model/thinking picker. `/provider openrouter` and `/model <id>` switch
directly, while `/models` explicitly fetches and lists live API models when the
active provider supports model listing.
---
## Release Notes
Release-specific changes live in [CHANGELOG.md](CHANGELOG.md). This README
stays focused on current install paths, core workflows, provider setup, runtime
interfaces, and extension points.
---
## Usage
```bash
codewhale # interactive TUI
codewhale "explain this function" # one-shot prompt
codewhale exec --auto --output-format stream-json "fix this bug" # NDJSON backend stream
codewhale exec --resume <SESSION_ID> "follow up" # continue a non-interactive session
codewhale --model deepseek-v4-flash "summarize" # model override
codewhale --model auto "fix this bug" # auto-select model + thinking
codewhale --yolo # auto-approve tools
codewhale auth set --provider deepseek # save API key
codewhale doctor # check setup & connectivity
codewhale doctor --json # machine-readable diagnostics
codewhale setup --status # read-only setup status
codewhale setup --tools --plugins # scaffold tool/plugin dirs
codewhale models # list live API models
codewhale sessions # list saved sessions
codewhale resume --last # resume the most recent session in this workspace
codewhale resume <SESSION_ID> # resume a specific session by UUID
codewhale fork <SESSION_ID> # fork a saved session into a sibling path
codewhale serve --http # HTTP/SSE API server
codewhale serve --acp # ACP stdio adapter for Zed/custom agents
codewhale run pr <N> # fetch PR and pre-seed review prompt
codewhale mcp list # list configured MCP servers
codewhale mcp validate # validate MCP config/connectivity
codewhale mcp-server # run dispatcher MCP stdio server
codewhale update # check for and apply binary updates
```
### Branching Conversations
Saved sessions are intentionally branchable. `codewhale fork <SESSION_ID>` copies
an existing saved session into a new sibling session, records the parent session
id in metadata, and opens that fork so you can explore an alternate direction
without polluting the original path. The session picker and `codewhale sessions`
mark forked sessions with their parent id.
Inside the TUI, Esc-Esc backtrack can rewind the active transcript to a prior
user prompt and put that prompt back in the composer for editing. `/restore`
and `revert_turn` are separate workspace rollback tools: they restore files
from side-git snapshots but do not rewrite conversation history.
Docker images are published to GHCR for release builds:
```bash
docker volume create codewhale-home
docker run --rm -it \
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
-v codewhale-home:/home/codewhale/.codewhale \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/hmbown/codewhale:latest
```
See [docs/DOCKER.md](docs/DOCKER.md) for pinned tags, local image builds,
volume ownership notes, and non-interactive pipeline usage.
### Zed / ACP
DeepSeek can run as a custom Agent Client Protocol server for editors that
spawn local ACP agents over stdio. In Zed, add a custom agent server:
```json
{
"agent_servers": {
"DeepSeek": {
"type": "custom",
"command": "codewhale",
"args": ["serve", "--acp"],
"env": {}
}
}
}
```
The first ACP slice supports new sessions and prompt responses through your
existing DeepSeek config/API key. Tool-backed editing and checkpoint replay are
not exposed through ACP yet.
Community-maintained adapter: [acp-codewhale-adapter](https://github.com/rockeverm3m/acp-codewhale-adapter)
bridges `codewhale exec --auto` to `cc-connect` for users who need tool-backed
ACP workflows outside the built-in Zed slice.
### Keyboard Shortcuts
| Key | Action |
|---|---|
| `Tab` | Complete `/` or `@` entries; while running, queue draft as follow-up; otherwise cycle mode |
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
| `F1` | Searchable help overlay |
| `Esc` | Back / dismiss |
| `Ctrl+K` | Command palette |
| `Ctrl+R` | Resume an earlier session |
| `Alt+R` | Search prompt history and recover cleared drafts |
| `Ctrl+S` | Stash current draft (`/stash list`, `/stash pop` to recover) |
| `@path` | Attach file/directory context in composer |
| `↑` (at composer start) | Select attachment row for removal |
Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
---
## Modes
| Mode | Behavior |
| --- | --- |
| `codewhale` | The interactive terminal app |
| `codewhale exec "…"` | One task without the interface. On its own it answers without using tools; `--auto` enables tools with Full Access by default, `--sandbox workspace-write` restricts them, and `--output-format stream-json` streams events as JSON |
| `codewhale web` | A local web UI on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request. The [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) generates a pinned workflow for automatic reviews and `@codewhale review`, using your own model key and optional App identity |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals, and each turn's file changes |
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
| **Plan** 🔍 | Read-only investigation — model explores and proposes a plan before making changes; multi-step investigations use `checklist_write` |
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; substantial work is tracked with `checklist_write` |
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; multi-step work still keeps a visible checklist |
The terminal app, `exec`, `web`, and the Runtime API share one engine
([architecture](docs/ARCHITECTURE.md)). A community-maintained
[VS Code extension](https://marketplace.visualstudio.com/items?itemName=HengQuWorld.brotherwhale-vscode)
connects to it from the sidebar ([source](https://github.com/HengQuWorld/CodeWhale-VSCode)).
A native desktop app built on the Runtime API is in development; it has no
public download yet.
---
## Cost and accounts
## Configuration
Codewhale is free and MIT licensed. With your own API key you pay the provider
directly; local models need no key. The footer and `/cost` show what a session
has cost when the price is known, and an unknown price is shown as unknown,
never as free.
User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `<workspace>/.codewhale/config.toml` (legacy `<workspace>/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option.
You don't need an account. Keys you add with `/provider` stay on your machine.
Key environment variables:
## Safety and privacy
| Variable | Purpose |
|---|---|
| `DEEPSEEK_API_KEY` | API key |
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
| `DEEPSEEK_MODEL` | Default model |
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` |
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `xiaomi`, `sglang`, `vllm`, `ollama` |
| `DEEPSEEK_PROFILE` | Config profile name |
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks |
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth |
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID |
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override |
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override |
| `OPENROUTER_BASE_URL` | OpenRouter endpoint override |
| `NOVITA_BASE_URL` | Novita endpoint override |
| `FIREWORKS_BASE_URL` | Fireworks endpoint override |
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
| `SGLANG_MODEL` | Self-hosted SGLang model ID |
| `VLLM_BASE_URL` | Self-hosted vLLM endpoint |
| `VLLM_MODEL` | Self-hosted vLLM model ID |
| `OLLAMA_BASE_URL` | Self-hosted Ollama endpoint |
| `OLLAMA_MODEL` | Self-hosted Ollama model tag |
| `NO_ANIMATIONS=1` | Force accessibility mode at startup |
| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies |
In Ask and Auto-Review, commands run in an OS sandbox where the system has
one: Seatbelt on macOS, and bubblewrap on Linux when it is installed.
Windows has no OS sandbox yet, and Full Access turns the sandbox off. Inside
the sandbox, shell commands cannot read credential stores such as `~/.ssh`,
`~/.aws` and `.env` files; from 0.10.2 the same rule covers terminal sessions
and more file tools. `/preview-request` shows a redacted preview of the next
request without sending it.
Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG` to choose UI chrome and the fallback language sent to V4 models. The latest user message still wins for natural-language reasoning and replies, so Chinese user turns stay Chinese even on an English system locale. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md).
Telemetry is on by default. It sends aggregate counts (version and platform,
session length and outcome, which built-in providers you used, feature and
error counts) with a random install ID that rotates every 90 days, and never
conversations, code, prompts, model names, file or repository names, or
credentials. `codewhale config set telemetry false` turns
it off. At startup Codewhale also
checks GitHub for a new release; set `CODEWHALE_NO_UPDATE_CHECK=1` to skip
that. More in [authorization order](docs/AUTHORIZATION_ORDER.md),
[sandboxing](docs/SANDBOX.md), and [telemetry](docs/TELEMETRY.md).
---
## Models & Pricing
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|---|---|---|---|---|
| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M |
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
DeepSeek Platform defaults to `https://api.deepseek.com/beta` so beta-gated API features can be tested without extra setup. Set `base_url = "https://api.deepseek.com"` to opt out.
Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` and retire after July 24, 2026. NVIDIA NIM variants use your NVIDIA account terms.
> [!Note]
> DeepSeek's pricing page now lists the V4 Pro rates above as the permanent prices: the previous 75% promotional discount has been folded into a one-quarter base-rate adjustment as the promotion window closes on 15:59 UTC on 31 May 2026. The TUI cost estimator already uses these values, so no behavioural change is required. For any future price changes, consult the official [DeepSeek pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing).
---
## Publishing Your Own Skill
codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file:
```text
~/.agents/skills/my-skill/
└── SKILL.md
```
Frontmatter required:
```markdown
---
name: my-skill
description: Use this when DeepSeek should follow my custom workflow.
---
# My Skill
Instructions for the agent go here.
```
Commands: `/skills` (list), `/skill <name>` (activate), `/skill new` (scaffold), `/skill install github:<owner>/<repo>` (community), `/skill update` / `uninstall` / `trust`. Community installs from GitHub require no backend service. Installed skills appear in the model-visible session context; the agent can auto-select relevant skills via the `load_skill` tool when your task matches their descriptions.
First launch also installs bundled system skills for common workflows:
`skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`,
`skill-installer`, `mcp-builder`, `documents`, `presentations`,
`spreadsheets`, `pdf`, and `feishu`. These live under
`~/.codewhale/skills` (or legacy `~/.deepseek/skills`) and are versioned so new bundles are added on upgrade
without recreating skills the user deliberately deleted.
---
## Documentation
| Start here | Reference |
| --- | --- |
| [Installation](docs/INSTALL.md) | [Configuration](docs/CONFIGURATION.md) |
| [Providers and local models](docs/PROVIDERS.md) | [Architecture](docs/ARCHITECTURE.md) |
| [Modes and permissions](docs/MODES.md) | [Runtime API](docs/RUNTIME_API.md) |
| [Keybindings](docs/KEYBINDINGS.md) | [Plugin authoring](docs/PLUGIN_AUTHORING.md) |
| [GitHub PR review](docs/GITHUB_ACTION.md) | [All documentation](docs/README.md) |
| Doc | Topic |
|---|---|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
| [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide |
| [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes |
| [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Tencent/CNB/Lighthouse/Feishu remote-first path |
| [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Lighthouse Hong Kong server setup |
| [MEMORY.md](docs/MEMORY.md) | User memory feature guide |
| [SUBAGENTS.md](docs/SUBAGENTS.md) | Sub-agent role taxonomy and lifecycle |
| [KEYBINDINGS.md](docs/KEYBINDINGS.md) | Full shortcut catalog |
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
| [LOCALIZATION.md](docs/LOCALIZATION.md) | UI locale matrix & switching |
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
## Community
Full Changelog: [CHANGELOG.md](CHANGELOG.md).
If a provider is missing or a workflow is awkward,
[open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) or
[send a pull request](CONTRIBUTING.md). Contributors keep credit for the work
that lands, and the [repository layout](CONTRIBUTING.md#project-structure)
shows where things live.
---
Chat on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer,
Hunter, on WeChat (`hunterbown`) and ask to join the Whale Brothers group.
## Thanks
## History and license
- **[DeepSeek](https://github.com/deepseek-ai)** — thank you for the models and support that power every turn. 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。
- **[DataWhale](https://github.com/datawhalechina)** 🐋 — thank you for your support and for welcoming us into the Whale Brother family. 感谢 DataWhale 的支持,并欢迎我们加入“鲸兄弟”大家庭。
- **[OpenWarp](https://github.com/zerx-lab/warp)** — thank you for prioritizing codewhale support and for collaborating on a better terminal-agent experience.
- **[Open Design](https://github.com/nexu-io/open-design)** — thank you for support and collaboration around design-forward agent workflows.
Codewhale started as `deepseek-tui` and still reads that project's
configuration and sessions. It is independently maintained and not affiliated
with DeepSeek or any other model provider. Thanks to
[everyone who has contributed](docs/CONTRIBUTORS.md).
This project ships with help from a growing community of contributors:
[![Codewhale contributors](docs/assets/contributors.png)](docs/CONTRIBUTORS.md#everyone)
- **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 PRs spanning features, fixes, and VS Code extension scaffolding (#645–#681)
- **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Markdown rendering for tables, bold/italic, and horizontal rules (#579)
- **[loongmiaow-pixel](https://github.com/loongmiaow-pixel)** — Windows + China install documentation (#578)
- **[20bytes](https://github.com/20bytes)** — User memory docs and help polish (#569)
- **[staryxchen](https://github.com/staryxchen)** — glibc compatibility preflight (#556)
- **[Vishnu1837](https://github.com/Vishnu1837)** — glibc compatibility improvements and terminal restoration on SIGINT/SIGTERM (#565, #1586)
- **[shentoumengxin](https://github.com/shentoumengxin)** — Shell `cwd` boundary validation (#524)
- **[toi500](https://github.com/toi500)** — Windows paste fix report
- **[xsstomy](https://github.com/xsstomy)** — Terminal startup repaint report
- **[melody0709](https://github.com/melody0709)** — Slash-prefix Enter activation report
- **[lloydzhou](https://github.com/lloydzhou)** and **[jeoor](https://github.com/jeoor)** — Compaction cost reports; lloydzhou also contributed deterministic environment context (#813, #922) and KV prefix-cache stabilisation (#1080)
- **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README clarity pass (#685)
- **[woyxiang](https://github.com/woyxiang)** — Windows install documentation (#696)
- **[wangfeng](mailto:[email protected])** — Pricing/discount info update (#692)
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729)
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631)
- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, approval flow simplification, model picker Esc fix, user message highlighting, provider picker scroll, and `/new` session command (#863, #870, #921, #1078, #1603, #1628, #1601, #2143, #2056, #1995, #2241, #2235)
- **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869)
- **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885)
- **[y0sif](https://github.com/y0sif)** — parent turn-loop wakeup after direct child sub-agent completion (#901)
- **[mac119](https://github.com/mac119)** and **[leo119](https://github.com/leo119)** — `codewhale update` command documentation (#838, #917)
- **[dumbjack](https://github.com/dumbjack)** / **浩淼的mac** — command-safety null-byte hardening (#706, #918)
- **macworkers** — fork confirmation with the new session id (#600, #919)
- **zero** and **[zerx-lab](https://github.com/zerx-lab)** — notification condition config and richer OSC 9 notification body (#820, #920)
- **[chnjames](https://github.com/chnjames)** — cached @mention completions, config recovery polish, and Windows UTF-8 shell output (#849, #927, #982, #1018)
- **[angziii](https://github.com/angziii)** — config safety, async cleanup, Docker hardening, and command-safety fixes (#822, #824, #827, #831, #833, #835, #837)
- **[elowen53](https://github.com/elowen53)** — UTF-8 decoding and deterministic test coverage (#825, #840)
- **[wdw8276](https://github.com/wdw8276)** — `/rename` command for custom session titles (#836)
- **[banqii](https://github.com/banqii)** — `.cursor/skills` discovery path support (#817)
- **[junskyeed](https://github.com/junskyeed)** — dynamic `max_tokens` calculation for API requests (#826)
- **Hafeez Pizofreude** — SSRF protection in `fetch_url` and Star History chart
- **Unic (YuniqueUnic)** — Schema-driven config UI (TUI + web)
- **Jason** — SSRF security hardening
- **[axobase001](https://github.com/axobase001)** — snapshot orphan cleanup, npm install guards, session telemetry fixes, model-scope cache clear, symlinked skill support, npm mirror-escape-hatch guidance, and proxy preservation for child tasks (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056, #1608)
- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` command foundation and SSE gzip/brotli decompression (#1057, #1061)
- **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Plan-mode read-only sandbox safety fix (#1077)
- **[bevis-wong](https://github.com/bevis-wong)** — precise paste-Enter auto-submit reproducer (#1073)
- **[Duducoco](https://github.com/Duducoco)** and **[AlphaGogoo](https://github.com/AlphaGogoo)** — skills slash-menu and `/skills` coverage fix (#1068, #1083)
- **[ArronAI007](https://github.com/ArronAI007)** — window-resize artifact fix for macOS Terminal.app and ConHost (#993)
- **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — OpenRouter and custom-endpoint model-ID preservation (#1066)
- **[Jefsky](https://github.com/Jefsky)** — DeepSeek endpoint correction report (#1079, #1084)
- **[wlon](https://github.com/wlon)** — NVIDIA NIM provider API-key preference diagnosis (#1081)
- **[Horace Liu](https://github.com/liuhq)** — Nix package support and install documentation (#1173)
- **[jieshu666](https://github.com/jieshu666)** — terminal repaint flicker reduction (#1563)
- **[gordonlu](https://github.com/gordonlu)** — Windows Enter / CSI-u input fix (#1612)
- **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598)
- **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622)
- **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645)
- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report and clipboard-init fix (#1772, #1773)
- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, runtime log follow-ups, taskbar progress, animated title spinner, and configurable completion sound (#1774, #1776, #1748, #1749, #1782, #1783, #1871, #1910)
- **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744)
- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764)
- **[donglovejava](https://github.com/donglovejava)** — paste @file consolidation, CJK panic fix, user feedback, RLM routing, edit_file retry (#2154–#2168)
- **[encyc](https://github.com/encyc)** — session token breakdown in footer and `/status` (#2152)
- **[saieswar237](https://github.com/saieswar237)** — review pipeline docs (#2178)
- **[sximelon](https://github.com/sximelon)** — paste Enter suppression, key handler extraction (#2174, #2042)
- **[nanookclaw](https://github.com/nanookclaw)** — search provider in doctor output (#2135)
- **[Sskift](https://github.com/Sskift)** — CLI default env override prevention (#2119)
- **[xin1104](https://github.com/xin1104)** — Homebrew codewhale binary install (#2105)
- **[mrluanma](https://github.com/mrluanma)** — Metaso search provider (#2059)
- **[Lellansin](https://github.com/Lellansin)** — skip config merge at home dir (#2055)
- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — update release channels (#2145)
- **[Fire-dtx](https://github.com/Fire-dtx)** — deadlock fix for concurrent sub-agents (#1856)
- **[imkingjh999](https://github.com/imkingjh999)** — composer text selection with copy/cut (#2228)
- **[harvey2011888](https://github.com/harvey2011888)** — loop guard halt outcome fix (#1859)
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO env merge fix (#1870)
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] completion markers (#1935)
- **[PurplePulse](https://github.com/PurplePulse)** — macOS onboarding layout pinning (#1837)
- **[cyq1017](https://github.com/cyq1017)** — configurable base URL in /config view (#1967)
- **[knqiufan](https://github.com/knqiufan)** — copy transcript without visual-wrap newlines (#1906)
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro pricing permanent (#1937)
- **[hongqitai](https://github.com/hongqitai)** — clippy warnings fix (#2237)
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md project context (#1852)
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi MiMo provider support (#2240)
- **[mvanhorn](https://github.com/mvanhorn)** — global AGENTS.md vendor-neutral fallback (#2156)
[MIT](LICENSE). Code adapted from other open-source projects is listed in the
[third-party notices](docs/THIRD_PARTY_NOTICES.md).
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome — check the [open issues](https://github.com/Hmbown/CodeWhale/issues) for good first contributions.
Support: [Buy me a coffee](https://www.buymeacoffee.com/hmbown).
> [!Note]
> *Not affiliated with DeepSeek Inc.*
## License
[MIT](LICENSE)
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date&logscale=&legend=top-left)
+604
View File
@@ -0,0 +1,604 @@
# CodeWhale
> **面向 [DeepSeek V4](https://platform.deepseek.com) 的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。以 `codewhale` 调度器和 `codewhale-tui` 运行时这一组自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。**
[English README](README.md)
[日本語 README](README.ja-JP.md)
## 安装
`codewhale` 以一组自包含 Rust 发布二进制安装:`codewhale` 调度器命令,
以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm、Homebrew 和
Docker 会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录
(通常是 `PATH` 上的某个目录)。运行时不依赖 Node.js 或 Python。
```bash
# 1. npm —— 已装 Node 的最方便方式。npm 包只是一个下载器,
# 会从 GitHub Releases 拉取对应平台的预编译二进制对,
# 并不会让 codewhale 本身依赖 Node 运行时。
npm install -g codewhale
# 2. Cargo —— 无需 Node,两个 crate 都要安装。
cargo install codewhale-cli --locked # `codewhale` 入口
cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制
# 3. Homebrew —— macOS 包管理器。
# tap/formula 名称仍是旧名;实际安装 codewhale 和 codewhale-tui。
brew tap Hmbown/deepseek-tui
brew install deepseek-tui
# 4. 直接下载 —— GitHub Releases 的平台压缩包。
# https://github.com/Hmbown/CodeWhale/releases
# 压缩包包含 codewhale 和 codewhale-tui 以及安装脚本;
# 也提供单独二进制给脚本使用,手动安装时请把这一对放在一起。
# 5. Docker —— 预构建发布镜像。
docker volume create codewhale-home
docker run --rm -it \
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
-v codewhale-home:/home/codewhale/.codewhale \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/hmbown/codewhale:latest
```
> 中国大陆访问较慢时,npm 可加 `--registry=https://registry.npmmirror.com`,
> 或使用下方的 [Cargo 镜像](#中国大陆--镜像友好安装)。
>
> 下载安全:官方二进制只发布在
> `https://github.com/Hmbown/CodeWhale/releases`。手动下载时请校验
> SHA-256 manifest,并避免相似仓库名或搜索结果里的镜像站。详见
> [下载安全与校验](docs/INSTALL.md#2-download-safety-and-checksums)。
已经安装过?按你的安装方式更新:
```bash
codewhale update # release 二进制更新器
npm install -g codewhale@latest # npm 包装器
brew update && brew upgrade deepseek-tui
cargo install codewhale-cli --locked --force
cargo install codewhale-tui --locked --force
```
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale)
![codewhale 截图](assets/screenshot.png)
---
## 这是什么?
模型回答问题。智能体完成任务。区别在于运行框架——一套在模型偏离时保持方向的规则、证据和反馈系统。
CodeWhale 就是这套框架,围绕 DeepSeek V4 构建,基于三个理念:
| 原则 | 如何运作 |
|---|---|
| **从信任开始** | 每一轮以"A"开始——可能性先于确定性,匠心先于便利 |
| **清晰的管辖权** | 成文宪法,九层权威。用户意图优先于陈旧指令。验证优先于自信。 |
| **递归改进** | V4 参与了框架的编写。框架改进 → V4 更高效 → 进一步改进框架。每轮从更强的位置开始。 |
开源、终端原生,并以 `codewhale` / `codewhale-tui` 这一组 Rust 二进制发布。
## 框架如何工作
智能体模型面临大规模的冲突信息:用户意图、项目规则、系统默认值、工具输出和陈旧记忆在单轮对话中争夺权威。LLM 作为裁判需要管辖权——当它们冲突时,哪个来源胜出?
CodeWhale 用一部**宪法**(`prompts/base.md`)来回答这个问题。它是一个形式化的法律层级——第七条将九个来源从宪法本身的条款排到前序会话的交接记录。用户当前消息优先于陈旧的项目指令。实时工具输出优先于假设。验证优先于自信。模型每轮继承清晰的权威链,永远不需要猜测该服从哪条指令。
七条条款位于层级之上,定义模型的身份、职责和能动性:验证强制(第五条——每个行动留下证据,绝不凭信念宣告成功)、协作遗产(第六条——让工作区对下一位智能体保持可读)、以及真相优先条款(第二条——任何下级规则不得覆盖它)。
DeepSeek V4 的前缀缓存使其可行。宪法篇幅长且详细,但一旦缓存,每轮成本约为冷读取的百分之一。模型递归引用它——通过 RLM 会话窥视、扫描和查询——按需重访信息,而非依赖单次记忆读取。它的表现更像是开卷考试而非闭卷考试。
因为权威结构是显式的,失败不会被隐藏。非零退出码、两次轮次间来自 rust-analyzer 的类型错误、沙箱拒绝——这些被作为修正向量反馈。模型用自己的漂移进行自我校正。
三种模式控制行动空间。Plan 只读。Agent 对破坏性操作设审批门控。YOLO 在可信工作区自动批准。macOS Seatbelt 是主动执行的沙箱;Linux Landlock 可检测但未执行;Windows 沙箱尚未开放。
Fin——关闭思考的廉价 Flash 调用——每轮处理模型自动路由。`--model auto` 是默认值。
每轮记录 side-git 快照,在仓库 `.git` 之外。`/restore` 和 `revert_turn` 即刻回滚工作区。
子智能体并发运行(最多 20 个)。`agent_open` 立即返回;结果以内联完成哨兵形式到达,携带摘要。完整对话记录通过 `agent_eval` 的有界句柄保存。详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。
其余功能面:每次编辑后的 LSP 诊断(rust-analyzer、pyright、typescript-language-server、gopls、clangd)、RLM 会话批量分析、MCP 协议、HTTP/SSE 运行时 API、持久化任务队列、Zed 的 ACP 适配器、SWE-bench 导出、以及带缓存命中/未命中明细的实时成本追踪。
---
## 运行框架
`codewhale`(调度器 CLI)→ `codewhale-tui`(伴随二进制)→ ratatui 界面 ↔ 异步引擎 ↔ OpenAI 兼容流式客户端。工具调用通过类型化注册表(shell、文件操作、git、web、子智能体、MCP、RLM)路由,结果流式返回对话记录。引擎管理会话状态、轮次追踪、持久化任务队列和 LSP 子系统——它在下一步推理前将编辑后诊断反馈到模型上下文中。
详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
### 子智能体:并发后台执行
codewhale 可以同时调度多个子智能体并行运行——类似于并发任务队列:
- **非阻塞启动。** `agent_open` 立即返回。子智能体获得独立的上下文和工具注册表,独立运行。父进程继续工作。
- **后台执行。** 子智能体并发运行(默认上限 10,可配置至 20)。引擎管理线程池——无需轮询循环。
- **完成通知。** 子智能体完成后,运行时向父对话注入 `<codewhale:subagent.done>` 哨兵。人类可读的摘要(包含子智能体的发现、变更文件和风险)位于哨兵的紧前一行。父模型读取该摘要并整合结果,无需额外工具调用。
- **按需读取结果。** 完整子对话记录通过 `agent_eval` 获取的 `transcript_handle` 暂存。摘要不够时,父进程通过 `handle_read` 按切片、行范围或 JSONPath 投影读取——保持父上下文精简而不丢失细节。
详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。
---
## 快速开始
```bash
npm install -g codewhale
codewhale --version
codewhale --model auto
```
预构建二进制对和平台压缩包覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。
首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.codewhale/config.toml`(同时兼容旧版 `~/.deepseek/config.toml`),在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。
也可以提前配置:
```bash
codewhale auth set --provider deepseek # 保存到 ~/.codewhale/config.toml
codewhale auth status # 显示当前活跃的凭证来源
export DEEPSEEK_API_KEY="YOUR_KEY" # 环境变量方式;需要在非交互式 shell 中使用请放入 ~/.zshenv
codewhale
codewhale doctor # 验证安装
```
> 轮换或移除密钥:`codewhale auth clear --provider deepseek`。
### 腾讯云 / CNB 远程优先路径
如果你想要一个长期在线、可从手机控制的工作区,推荐使用腾讯云原生路径:
CNB 镜像/源码,腾讯云 Lighthouse 香港实例,飞书/Lark 长连接桥接,
以及可选的 EdgeOne 公网 HTTPS 边缘。运行时 API 必须绑定在 localhost;
不要通过 EdgeOne 暴露 `/v1/*`。
先看 [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md),
再按 [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) 配置服务器。
### Auto 模式
使用 `codewhale --model auto` 或 `/model auto` 让 codewhale 自行决定每轮需要多少模型和推理能力。
Auto 模式同时控制两个设置:
- 模型:`deepseek-v4-flash` 或 `deepseek-v4-pro`
- 推理强度:`off`、`high` 或 `max`
在真实请求发出之前,应用会先用关闭推理的 `deepseek-v4-flash` 进行一次小型路由调用。路由器审视最新请求和最近的上下文,然后为真实请求选定具体的模型和推理强度。简短/简单的轮次保持在 Flash + 关闭推理;编码、调试、发布、架构、安全审查或模糊的多步骤任务可升级到 Pro 和/或更高推理强度。
`auto` 是 codewhale 本地行为。上游 API 永远不会收到 `model: "auto"`,它只会收到为当前轮次选定的具体模型和推理强度设置。TUI 会显示选定的路由,成本跟踪按实际运行的模型计费。如果路由调用失败或返回无效答案,应用会回退到本地启发式规则。子智能体会继承 auto 模式,除非你为它们指定了显式模型。
需要可重复基准测试、严格控制成本上限或特定提供商/模型映射时,请使用固定模型或固定推理强度。
### Linux ARM64(HarmonyOS 轻薄本、openEuler、Kylin、树莓派、Graviton 等)
从 v0.8.8 起,`npm i -g codewhale` 直接支持 glibc 系的 ARM64 Linux。你也可以从 [Releases 页面](https://github.com/Hmbown/CodeWhale/releases) 下载预编译二进制,放到 `PATH` 目录中。
### 中国大陆 / 镜像友好安装
如果在中国大陆访问 GitHub 或 npm 下载较慢,可以通过 Cargo 注册表镜像安装:
```toml
# ~/.cargo/config.toml
[source.crates-io]
replace-with = "tuna"
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
```
然后安装两个二进制(调度器在运行时会调用 TUI):
```bash
cargo install codewhale-cli --locked # 提供推荐入口 `codewhale`
cargo install codewhale-tui --locked # 提供交互式 TUI 伴随二进制
codewhale --version
```
也可以直接从 [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases) 下载预编译二进制。`DEEPSEEK_TUI_RELEASE_BASE_URL` 可用于镜像后的 release 资产。
### Windows (Scoop)
[Scoop](https://scoop.sh) 是一个 Windows 软件包管理器。codewhale 已进入
Scoop main bucket,但该 manifest 独立更新,可能滞后于 GitHub/npm/Cargo
release。先运行 `scoop update`,安装后用 `codewhale --version` 核对版本:
```bash
scoop update
scoop install codewhale
codewhale --version
```
如果需要最新版本,请优先使用 npm 或直接下载 GitHub Release 资产。
<details id="install-from-source">
<summary>从源码安装</summary>
适用于任何 Tier-1 Rust 目标,包括 musl、riscv64、FreeBSD 以及尚无预编译包的 ARM64 发行版。
```bash
# Linux 构建依赖(Debian/Ubuntu/RHEL):
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
git clone https://github.com/Hmbown/CodeWhale.git
cd CodeWhale
cargo install --path crates/cli --locked # 需要 Rust 1.88+;提供 `codewhale`
cargo install --path crates/tui --locked # 提供 `codewhale-tui`
```
两个二进制都需要安装。交叉编译和平台特定说明见 [docs/INSTALL.md](docs/INSTALL.md)。
</details>
### 其他模型提供方
```bash
# NVIDIA NIM
codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
codewhale --provider nvidia-nim
# AtlasCloud
codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"
codewhale --provider atlascloud
# Wanjie Ark
codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"
codewhale --provider wanjie-ark --model deepseek-reasoner
# OpenRouter
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
# Novita
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
codewhale --provider novita --model deepseek/deepseek-v4-pro
# Fireworks
codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
codewhale --provider fireworks --model deepseek-v4-pro
# 通用 OpenAI 兼容端点
codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5
# 自托管 SGLang
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
# 自托管 vLLM
VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
# 自托管 Ollama
ollama pull codewhale-coder:1.3b
codewhale --provider ollama --model codewhale-coder:1.3b
```
在 TUI 内,`/provider` 打开提供方选择器,`/model` 打开本地模型/思考模式
选择器。`/provider openrouter` 和 `/model <id>` 可直接切换;`/models` 会在
当前提供方支持模型列表时显式请求并列出 API 返回的实时模型。
---
## 版本说明
每个版本的具体变更见 [CHANGELOG.md](CHANGELOG.md)。README 只保留当前
安装方式、核心工作流、模型提供方配置、运行时接口和扩展入口。
---
## 使用方式
```bash
codewhale # 交互式 TUI
codewhale "explain this function" # 一次性提示
codewhale exec --auto --output-format stream-json "fix this bug" # 面向后端集成的 NDJSON 流
codewhale exec --resume <SESSION_ID> "follow up" # 继续非交互会话
codewhale --model deepseek-v4-flash "summarize" # 指定模型
codewhale --model auto "fix this bug" # 自动选择模型 + 推理强度
codewhale --yolo # 自动批准工具
codewhale auth set --provider deepseek # 保存 API key
codewhale doctor # 检查配置和连接
codewhale doctor --json # 机器可读诊断
codewhale setup --status # 只读安装状态
codewhale setup --tools --plugins # 创建本地工具和插件目录
codewhale models # 列出可用 API 模型
codewhale sessions # 列出已保存会话
codewhale resume --last # 恢复最近会话
codewhale resume <SESSION_ID> # 按 UUID 恢复指定会话
codewhale fork <SESSION_ID> # 将已保存会话分叉为兄弟路径
codewhale serve --http # HTTP/SSE API 服务
codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器
codewhale run pr <N> # 获取 PR 并预填审查提示
codewhale mcp list # 列出已配置 MCP 服务器
codewhale mcp validate # 校验 MCP 配置和连接
codewhale mcp-server # 启动 dispatcher MCP stdio 服务器
codewhale update # 检查并应用二进制更新
```
Docker 镜像发布在 GHCR 上:
```bash
docker volume create codewhale-home
docker run --rm -it \
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
-v codewhale-home:/home/codewhale/.codewhale \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/hmbown/codewhale:latest
```
固定 tag、本地构建、volume 权限和非交互管道用法见 [docs/DOCKER.md](docs/DOCKER.md)。
### Zed / ACP
DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等编辑器通过 stdio 调用本地 ACP 智能体。在 Zed 中添加自定义智能体服务器:
```json
{
"agent_servers": {
"DeepSeek": {
"type": "custom",
"command": "codewhale",
"args": ["serve", "--acp"],
"env": {}
}
}
}
```
首个 ACP 切片支持通过现有 DeepSeek 配置/API 密钥创建新会话和提示响应。工具支持的编辑和检查点回放尚未通过 ACP 暴露。
### 常用快捷键
| 按键 | 功能 |
|---|---|
| `Tab` | 补全 `/` 或 `@`;运行中则把草稿排队;否则切换模式 |
| `Shift+Tab` | 切换推理强度:off → high → max |
| `F1` | 可搜索帮助面板 |
| `Esc` | 返回 / 关闭 |
| `Ctrl+K` | 命令面板 |
| `Ctrl+R` | 恢复旧会话 |
| `Alt+R` | 搜索提示历史和恢复草稿 |
| `Ctrl+S` | 暂存当前草稿(`/stash list`、`/stash pop` 恢复) |
| `@path` | 在输入框中附加文件或目录上下文 |
| `↑`(在输入框开头) | 选择附件行进行移除 |
完整快捷键目录:[docs/KEYBINDINGS.md](docs/KEYBINDINGS.md)。
---
## 模式
| 模式 | 行为 |
|---|---|
| **Plan** 🔍 | 只读调查;模型先探索并提出计划(`update_plan` + `checklist_write`),然后再做更改 |
| **Agent** 🤖 | 默认交互模式;多步工具调用带审批门禁 |
| **YOLO** ⚡ | 在可信工作区自动批准工具;仍会维护计划和清单以保持可见性 |
---
## 配置
用户配置:`~/.codewhale/config.toml`(兼容旧版 `~/.deepseek/config.toml`)。项目覆盖:`<workspace>/.codewhale/config.toml`(兼容 `<workspace>/.deepseek/config.toml`)(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。
常用环境变量:
| 变量 | 用途 |
|---|---|
| `DEEPSEEK_API_KEY` | DeepSeek API key |
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_HTTP_HEADERS` | 可选模型请求头,例如 `X-Model-Provider-Id=your-model-provider` |
| `DEEPSEEK_MODEL` | 默认模型 |
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | 流式响应空闲超时秒数,默认 `300`,限制在 `1..=3600` |
| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
| `DEEPSEEK_PROFILE` | 配置 profile 名称 |
| `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 |
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL |
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 |
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID |
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 |
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 |
| `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 |
| `NOVITA_BASE_URL` | Novita 端点覆盖 |
| `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 |
| `SGLANG_BASE_URL` | 自托管 SGLang 端点 |
| `SGLANG_MODEL` | 自托管 SGLang 模型 ID |
| `VLLM_BASE_URL` | 自托管 vLLM 端点 |
| `VLLM_MODEL` | 自托管 vLLM 模型 ID |
| `OLLAMA_BASE_URL` | 自托管 Ollama 端点 |
| `OLLAMA_MODEL` | 自托管 Ollama 模型标签 |
| `NO_ANIMATIONS=1` | 启动时强制无障碍模式 |
| `SSL_CERT_FILE` | 企业代理的自定义 CA 包 |
`locale` 会控制界面语言,并作为模型自然语言的兜底设置;最新用户消息的语言优先级更高。也就是说,即使系统 locale 是英文,用户用中文提问时,V4 的 `reasoning_content` 和最终回复也应该使用中文。可在 `config.toml` 中设置 `locale`、使用 `/config locale zh-Hans`、或依赖 `LC_ALL`/`LANG`。详见 [docs/LOCALIZATION.md](docs/LOCALIZATION.md) 和 [docs/CONFIGURATION.md](docs/CONFIGURATION.md)。
### 切换为中文界面
如果界面是其他语言,可以在 TUI 内一键切换为简体中文:
1. 在 Composer 里输入 `/config`,按 Tab 或 Enter 打开配置面板。
2. 选择 **Edit locale**,在 `New:` 字段输入 `zh-Hans`,按 Enter 应用。
可选语言:`auto` | `en` | `ja` | `zh-Hans` | `pt-BR`。
也可以在 `~/.codewhale/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择:
```toml
# ~/.codewhale/config.toml
[tui]
locale = "zh-Hans"
```
或者通过环境变量(中文系统通常已自动生效):
```bash
LANG=zh_CN.UTF-8 codewhale run
```
---
## 模型和价格
| 模型 | 上下文 | 输入(缓存命中) | 输入(缓存未命中) | 输出 |
|---|---|---|---|---|
| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M |
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
旧别名 `deepseek-chat` / `deepseek-reasoner` 映射到 `deepseek-v4-flash`。NVIDIA NIM 变体使用你的 NVIDIA 账号条款。
> [!Note]
> 上表的 V4 Pro 单价现已成为官方长期价格:DeepSeek 已宣布在 75% 限时折扣窗口于 **2026 年 5 月 31 日 23:59(北京时间)** 结束后,正式将原始价格调整为约四分之一。TUI 的成本估算已使用这些数值,因此无需任何代码改动。后续价格变动请参阅官方 [DeepSeek 定价页面](https://api-docs.deepseek.com/zh-cn/quick_start/pricing)。
---
## 创建和安装技能
codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.codewhale/skills`(兼容旧版 `~/.deepseek/skills`)发现技能。每个技能是一个包含 `SKILL.md` 的目录:
```text
~/.codewhale/skills/my-skill/
└── SKILL.md
```
需要 YAML frontmatter:
```markdown
---
name: my-skill
description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技能。
---
# My Skill
这里写给智能体的指令。
```
常用命令:`/skills`(列出)、`/skill <name>`(激活)、`/skill new`(创建)、`/skill install github:<owner>/<repo>`(社区)、`/skill update` / `uninstall` / `trust`。社区技能直接从 GitHub 安装,无需后端服务。已安装技能在模型可见的会话上下文里列出;当任务匹配技能描述时,智能体可通过 `load_skill` 工具自动读取对应的 `SKILL.md`。
---
## 文档
| 文档 | 主题 |
|---|---|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 代码库内部结构 |
| [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 |
| [MCP.md](docs/MCP.md) | Model Context Protocol 集成 |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 |
| [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 |
| [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 |
| [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | 腾讯云/CNB/Lighthouse/飞书远程优先路径 |
| [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | 腾讯云 Lighthouse 香港实例配置 |
| [MEMORY.md](docs/MEMORY.md) | 用户记忆功能指南 |
| [SUBAGENTS.md](docs/SUBAGENTS.md) | 子智能体角色分类与生命周期 |
| [KEYBINDINGS.md](docs/KEYBINDINGS.md) | 完整快捷键目录 |
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | 发布流程 |
| [LOCALIZATION.md](docs/LOCALIZATION.md) | UI 语言矩阵与切换 |
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | 运维和恢复 |
完整更新历史:[CHANGELOG.md](CHANGELOG.md)。
---
## 致谢
- **[DeepSeek](https://github.com/deepseek-ai)** — 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。
- **[DataWhale](https://github.com/datawhalechina)** — 感谢 DataWhale 的支持,并欢迎我们加入“鲸兄弟”大家庭。
- **[OpenWarp](https://github.com/zerx-lab/warp)** — 感谢 OpenWarp 优先支持 codewhale,并一起打磨更好的终端智能体体验。
- **[Open Design](https://github.com/nexu-io/open-design)** — 感谢 Open Design 对面向设计的智能体工作流提供支持与协作。
本项目由不断壮大的贡献者社区共同打造:
- **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 个 PR,涵盖功能、修复和 VS Code 扩展基础架构 (#645–#681)
- **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Markdown 表格、粗体/斜体和水平线渲染 (#579)
- **[loongmiaow-pixel](https://github.com/loongmiaow-pixel)** — Windows + 中国安装文档 (#578)
- **[20bytes](https://github.com/20bytes)** — 用户记忆文档和帮助优化 (#569)
- **[staryxchen](https://github.com/staryxchen)** — glibc 兼容性预检 (#556)
- **[Vishnu1837](https://github.com/Vishnu1837)** — glibc 兼容性改进 (#565)
- **[shentoumengxin](https://github.com/shentoumengxin)** — Shell `cwd` 边界验证 (#524)
- **[toi500](https://github.com/toi500)** — Windows 粘贴修复报告
- **[xsstomy](https://github.com/xsstomy)** — 终端启动重绘报告
- **[melody0709](https://github.com/melody0709)** — 斜杠前缀回车激活报告
- **[lloydzhou](https://github.com/lloydzhou)** 和 **[jeoor](https://github.com/jeoor)** — 压缩成本报告;lloydzhou 还贡献了确定性的环境上下文注入 (#813, #922) 和 KV 前缀缓存稳定化 (#1080)
- **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README 清晰化改进 (#685)
- **[woyxiang](https://github.com/woyxiang)** — Windows 安装文档 (#696)
- **[wangfeng](mailto:[email protected])** — 价格/折扣信息更新 (#692)
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — 模型 ID 大小写兼容性报告 (#729)
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — `working...` 卡死状态 Bug 报告和 Windows 剪贴板兜底修复 (#738, #850)
- **[reidliu41](https://github.com/reidliu41)** — 退出后的恢复提示、工作区信任持久化、Ollama provider 支持、思考块流式终结修复、审批流程简化、模型选择器 Esc 修复、用户消息高亮、provider 选择器滚动、`/new` 会话命令 (#863, #870, #921, #1078, #2143, #2056, #1995, #2241, #2235)
- **[xieshutao](https://github.com/xieshutao)** — 纯 Markdown skill 兜底解析 (#869)
- **[GK012](https://github.com/GK012)** — npm wrapper 的 `--version` 兜底 (#885)
- **[y0sif](https://github.com/y0sif)** — 直接子智能体完成后唤醒父级 turn loop (#901)
- **[mac119](https://github.com/mac119)** 和 **[leo119](https://github.com/leo119)** — `codewhale update` 命令文档 (#838, #917)
- **[dumbjack](https://github.com/dumbjack)** / **浩淼的mac** — shell 命令空字节安全加固 (#706, #918)
- **macworkers** — fork 完成后显示新 session id (#600, #919)
- **zero** 和 **[zerx-lab](https://github.com/zerx-lab)** — 通知条件配置和更完整的 OSC 9 通知正文 (#820, #920)
- **[chnjames](https://github.com/chnjames)** — @mention 补全缓存、配置恢复优化,以及 Windows UTF-8 shell 输出修复 (#849, #927, #982, #1018)
- **[angziii](https://github.com/angziii)** — 配置安全、异步清理、Docker 加固和命令安全修复 (#822, #824, #827, #831, #833, #835, #837)
- **[elowen53](https://github.com/elowen53)** — UTF-8 解码和确定性测试覆盖 (#825, #840)
- **[wdw8276](https://github.com/wdw8276)** — 用于自定义 session 标题的 `/rename` 命令 (#836)
- **[banqii](https://github.com/banqii)** — `.cursor/skills` 发现路径支持 (#817)
- **[junskyeed](https://github.com/junskyeed)** — API 请求动态 `max_tokens` 计算 (#826)
- **Hafeez Pizofreude** — `fetch_url` 的 SSRF 保护和 Star History 图表
- **Unic (YuniqueUnic)** — 基于 schema 的配置 UI(TUI + web)
- **Jason** — SSRF 安全加固
- **[axobase001](https://github.com/axobase001)** — 快照孤儿文件清理、npm 安装守卫、会话遥测修复、模型作用域缓存清理、符号链接技能支持,以及 npm 镜像逃生路径指引 (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056)
- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` 命令基础和 SSE gzip/brotli 解压支持 (#1057, #1061)
- **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Plan 模式只读沙箱安全修复 (#1077)
- **[bevis-wong](https://github.com/bevis-wong)** — 粘贴-回车自动提交问题的精确复现 (#1073)
- **[Duducoco](https://github.com/Duducoco)** 和 **[AlphaGogoo](https://github.com/AlphaGogoo)** — 技能斜杠菜单和 `/skills` 覆盖范围修复 (#1068, #1083)
- **[ArronAI007](https://github.com/ArronAI007)** — macOS Terminal.app 和 ConHost 窗口大小调整残留修复 (#993)
- **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — OpenRouter 和自定义端点模型 ID 保留 (#1066)
- **[Jefsky](https://github.com/Jefsky)** — `deepseek-cn` 官方端点默认值 (#1079, #1084)
- **[wlon](https://github.com/wlon)** — NVIDIA NIM provider API key 优先级诊断 (#1081)
- **[donglovejava](https://github.com/donglovejava)** — paste @file 整合、CJK panic 修复、用户反馈、RLM 路由、edit_file 重试 (#2154–#2168)
- **[encyc](https://github.com/encyc)** — session token 分解显示和 `/status` (#2152)
- **[saieswar237](https://github.com/saieswar237)** — 审查流程文档 (#2178)
- **[sximelon](https://github.com/sximelon)** — paste Enter 抑制、键盘处理提取 (#2174, #2042)
- **[nanookclaw](https://github.com/nanookclaw)** — search provider 显示在 doctor (#2135)
- **[Sskift](https://github.com/Sskift)** — CLI 默认环境变量覆盖防止 (#2119)
- **[xin1104](https://github.com/xin1104)** — Homebrew codewhale 二进制安装 (#2105)
- **[mrluanma](https://github.com/mrluanma)** — Metaso 搜索提供商 (#2059)
- **[Lellansin](https://github.com/Lellansin)** — 主目录下跳过配置合并 (#2055)
- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — 更新发布渠道 (#2145)
- **[Fire-dtx](https://github.com/Fire-dtx)** — 并发子代理死锁修复 (#1856)
- **[imkingjh999](https://github.com/imkingjh999)** — 输入框文本选择与复制/剪切 (#2228)
- **[harvey2011888](https://github.com/harvey2011888)** — 循环守卫停止状态修复 (#1859)
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO 环境变量合并修复 (#1870)
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] 完成标记 (#1935)
- **[PurplePulse](https://github.com/PurplePulse)** — macOS 引导布局优化 (#1837)
- **[cyq1017](https://github.com/cyq1017)** — /config 视图显示 base URL (#1967)
- **[knqiufan](https://github.com/knqiufan)** — 复制文本去除自动换行符 (#1906)
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro 定价永久化 (#1937)
- **[hongqitai](https://github.com/hongqitai)** — Clippy 警告修复 (#2237)
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md 项目上下文 (#1852)
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — 小米 MiMo 提供商支持 (#2240)
- **[mvanhorn](https://github.com/mvanhorn)** — 全局 AGENTS.md 厂商中立回退 (#2156)
---
## 贡献
欢迎提交 pull request——请先查看 [CONTRIBUTING.md](CONTRIBUTING.md) 并留意[开放 issue](https://github.com/Hmbown/CodeWhale/issues) 中的好入门任务。
*本项目与 DeepSeek Inc. 无隶属关系。*
## 许可证
[MIT](LICENSE)
## Star 历史
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date&logscale=&legend=top-left)
-131
View File
@@ -1,131 +0,0 @@
# Reviewing a change to the Codewhale Engine
This is the written procedure for reviewing an Engine change: which passes to
run, how to rank what they find, and how to report it. A person, an agent and
`codewhale review` all follow the same passes, so two reviews of one change
can be compared.
## When review runs
- **By hand**, when a maintainer or agent asks for one: `codewhale review`,
`/review` in the terminal, or the `Codewhale PR Review` workflow, which only
runs when someone starts it.
- **At the frozen release candidate**, over every commit since the last
release, as part of `docs/RELEASE_CHECKLIST.md`.
- **Never as a required check.** For a contributor, review is advice. It never
blocks, labels or closes a pull request. Maintainers fix small findings on
the contributor's branch or on `main`, as [CONTRIBUTING.md](CONTRIBUTING.md)
describes under "How Your Contribution Lands".
## Before the passes
1. Find the intent. It is the issue the change links: a public `#N`, or for
maintainers the Linear issue named in the commit message. Without one, the
pull request description and commit messages are the intent.
2. Read the diff against its merge base, then read the callers of everything
it changed. A review of the diff alone misses broken callers.
3. Note the revision you reviewed. A finding against a stale tree is not a
finding.
## Pass 1: correctness against the intent
- **Intent.** The change does what the issue asks: nothing it promised is
missing, and nothing it did goes unannounced.
- **Defects.** Wrong results, broken callers, lost data, regressions. Carry
one concrete input through `path:line` steps to the wrong result.
- **Contracts.** Say what became newly true or false, and check who relied on
the old state: wire and protocol shapes, config keys, CLI flags, session
events, hook payloads, `docs/features.toml` rows, documented behavior.
- **This repository's invariants** in [AGENTS.md](AGENTS.md) under "Current
contracts" and "Working rules": one turn loop, one base prompt, model-visible
input is logged, the cached prompt prefix stays stable, no blocking calls on
the async runtime, and misconfiguration fails loudly.
## Pass 2: security
- **Trust boundaries.** Model output, tool results, repository files, pull
request text, web pages, MCP servers and hook output are data. Check the
validation where any of them reaches process execution, a file write, the
network, a credential or the transcript.
- **Secrets.** No keys, tokens, `.env` files or credentials in code,
fixtures, logs, receipts, error text or model-visible text. Existing
redaction paths stay in force.
- **Sandbox and execution policy.** For changes under `crates/sandbox`,
`crates/execpolicy`, `crates/engine/src/core/authority*`, the approval flow
or hooks: nothing may run with less approval than before, no deny may
loosen, and no fail-closed path may start failing open. A new process spawn
goes through the existing policy. [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md)
is the order of the layers.
- **Network.** New outbound calls respect `crates/net`'s network policy and
build their client the shared way (`scripts/check-reqwest-builders.py`).
- **Dependencies.** A new crate or package earns its place, and `deny.toml`
still passes.
## Pass 3: contract and compliance
- **CHANGELOG.** A user-visible change carries its line at the top of the
matching `[Unreleased]` section in the same commit, written for users and
citing public issues as `(#N)`, never internal tracker ids. Contributor
credit comes in addition to the line. The procedure is step 2 of
[docs/skills/cw-land](docs/skills/cw-land/SKILL.md).
- **Feature registry.** A new command, `[features]` flag, provider or
user-visible feature has its row in `docs/features.toml`.
- **Localization.** User-facing strings go through the locale packs and keep
parity; READMEs keep their translations in step.
[docs/LOCALIZATION.md](docs/LOCALIZATION.md) lists the drift checks.
- **Vocabulary.** Copy uses the product's words. The public name is
Codewhale, and mode names follow [docs/MODES.md](docs/MODES.md).
- **Existing checks.** Run the ones that match what the change touched, and
report their real output:
| The change touched | Run |
| --- | --- |
| Any Rust | `cargo fmt --all -- --check`, then `scripts/dev-test.sh <area or path>` |
| Generated files and ratchets | `scripts/preflight.sh --check` |
| `#[allow(dead_code)]` | `python3 scripts/check-dead-code-budget.py` |
| Async code | `python3 scripts/check-blocking-calls-budget.py` |
| TUI strings | `python3 scripts/check-tui-locale-parity.py`, `scripts/check-tui-product-vocabulary.sh` |
| User-facing English copy | `python3 scripts/check-lexicon.py` |
| READMEs | `python3 scripts/check-readme-translations.py`, `scripts/check-readme-locales.sh` |
| The provider registry | `python3 scripts/check-provider-registry.py` |
| `config.example.toml` | `python3 scripts/check-config-example.py` |
| A feature flag or command | `cargo test -p codewhale-tui --test feature_registry` |
| `CHANGELOG.md` or credit | `sh scripts/sync-changelog.sh`, `python3 scripts/check-contributor-credit.py` |
| Versions | `bash scripts/release/check-versions.sh` |
Do not ask for new tests or code comments; this repository adds neither unless
an issue asks. Recommend a regression test only for a specific failure you can
explain.
## Severity
Use the three levels `codewhale review` reports:
- **blocker**: a demonstrated severe failure, such as lost data, a security
hole, a broken build or install, a crash or wrong result on a common path,
or a broken public contract.
- **concern**: a concrete, narrower defect with a named trigger.
- **nit**: a demonstrated low-impact defect, such as a misleading message or a
doc the change made wrong.
Every finding carries its evidence: **reproduced** (a command you ran and its
output), **traced** (one input carried through `path:line` steps) or
**suspected** (anything less). A suspected finding is a question for a person,
never a blocker. Do not raise severity to express uncertainty. A defect the
change did not introduce is labeled pre-existing and does not count against
the change.
## Nits
Group nits into one list instead of one comment each. Leave formatting to
`cargo fmt` and clippy. When nits are all a review found, say the change is
fine to merge and leave the list for the author to take or leave.
## Report
- Findings ranked blocker, then concern, then nit, each with `path:line`, the
trigger, the impact, the smallest fix and its evidence.
- What you verified (commands and results), kept apart from what you only
read, and what you did not review.
- On a contributor's pull request, thank them for the specific thing they did,
keep every finding actionable, and offer to fix small ones on their branch.
+61 -28
View File
@@ -1,36 +1,69 @@
# Security policy
# Security Policy
## Supported versions
codewhale is a coding agent with direct access to file operations, shell execution, and the network. Security disclosures are taken seriously.
Security fixes target the latest published stable Codewhale release. Please
upgrade before reproducing a report when possible.
## Supported Versions
| Version | Security fixes |
| --- | --- |
| Latest published stable release | Supported |
| Earlier releases | Upgrade to the latest stable release |
| Unreleased branches and development builds | Best effort; no release support commitment |
Only the latest stable release receives security patches. No backports to older versions.
## Report a vulnerability privately
| Version | Supported |
|---|---|
| latest stable | :white_check_mark: |
| < latest | :x: |
Use [GitHub's private vulnerability report form](https://github.com/codewhale-hq/Codewhale/security/advisories/new).
Private vulnerability reporting is enabled for this repository. GitHub sends
the report to repository maintainers through a private security advisory.
Do not open a public issue or pull request containing an unpatched
vulnerability or exploit details.
Check the [releases page](https://github.com/Hmbown/CodeWhale/releases) for the current version.
Include the affected version or commit, operating system, a minimal
reproduction, expected and observed behavior, and the security impact.
Remove credentials, tokens, provider keys, personal data, and unrelated
repository contents. If a credential was exposed, rotate it through its
provider before sharing a redacted reproduction.
## Reporting a Vulnerability
## What to expect
**Do not open a public GitHub issue for security vulnerabilities.**
Maintainers review reports and discuss reproduction, impact, and remediation
in the private advisory. Response and fix times depend on severity and
available capacity; there is no guaranteed response deadline. Please keep
exploit details private while a fix and disclosure timing are discussed.
When a fix is ready, maintainers use the advisory to coordinate publication
and affected versions. Reports that are ordinary bugs may be redirected to
the public issue tracker after sensitive details are removed.
Report privately via one of:
- **GitHub private advisory**: [github.com/Hmbown/CodeWhale/security/advisories/new](https://github.com/Hmbown/CodeWhale/security/advisories/new)
- **Email**: [[email protected]](mailto:[email protected]) — include `[SECURITY]` in the subject line
Include in your report:
- A description of the vulnerability and the impact if exploited
- Steps to reproduce or a proof of concept
- Affected versions and configuration details
- Any suggested mitigation (optional)
## Response Timeline
| Phase | Target |
|---|---|
| Acknowledgment | Within 48 hours of receipt |
| Assessment | Within 5 days — triage severity, scope, and fix approach |
| Patch (critical) | Within 14 days from assessment |
| Patch (moderate/low) | Next feature release or per-maintainer timeline |
| Disclosure | After patch is shipped and users have had time to update |
You will receive status updates at each phase. If the timeline slips, we will communicate the reason and the revised estimate.
## Scope
### In scope (what counts)
- Remote code execution through crafted prompts or model responses
- Sandbox escape — breaking out of the YOLO-mode workspace boundary or shell `cwd` confinement
- Credential leak — exfiltration of API keys, tokens, or environment secrets
- Arbitrary file read/write outside the intended workspace (`PathEscape` bypass)
- SSRF via `fetch_url` or `web_search` against internal network endpoints
- Unauthorised MCP server access or tool invocation
### Out of scope
- Social engineering of the maintainer or contributors
- Denial of service / rate-limit exhaustion against the DeepSeek API
- Vulnerabilities in third-party dependencies (report to the upstream project)
- Attacks requiring physical access to the victim's machine
- Theoretical ML-model injection attacks not demonstrated in the codewhale context
If you are unsure whether a bug is in scope, report it anyway. We will triage and respond.
## Hall of Fame
We maintain a hall of fame for reporters who submit verified security vulnerabilities. To be credited, include your preferred name / handle in the report.
*No entries yet — be the first.*
-972
View File
@@ -1,972 +0,0 @@
# Third-party notices
Source vendored or ported into this repository, beyond the crates resolved by
Cargo (whose licences are enforced by `deny.toml`).
## pi (`pi-mono`) — MIT
`crates/config/src/device_code.rs` is a Rust port of pi's OAuth device-code
polling loop and verification-URI check:
- `packages/ai/src/auth/oauth/device-code.ts` (`pollOAuthDeviceCodeFlow`,
the RFC 8628 polling behaviours)
- `packages/ai/src/auth/oauth/xai.ts` (`validateVerificationUri`)
Upstream: <https://github.com/badlogic/pi-mono>
```
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OpenAI Codex (`codex-rs`) — Apache-2.0
`crates/tui/src/tui/frame_rate_limiter.rs` is adapted from Codex's TUI draw-rate
limiter, `codex-rs/tui/src/tui/frame_rate_limiter.rs`. It was simplified for a
poll-based render loop; the file header records the change.
Upstream: <https://github.com/openai/codex>
The App Server request-ID, RPC envelope and initialization contracts in
`crates/app-server-protocol/src/{rpc,lifecycle}.rs` are adapted from official
Codex `rust-v0.166.0`, commit `6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`.
The changes and exact source paths are recorded in that crate's `UPSTREAM.md`.
The crate retains `LICENSE-APACHE`, `LICENSE-MIT` and the upstream `NOTICE`;
its combined license expression is `MIT AND Apache-2.0`.
Upstream NOTICE, retained unchanged:
```text
OpenAI Codex
Copyright 2025 OpenAI
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
Copyright (c) 2016-2022 Florian Dehau
Copyright (c) 2023-2025 The Ratatui Developers
```
```
Copyright 2025 OpenAI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
The full Apache License 2.0 text ships in this repository at
`patches/unicode-width-0.2.2/LICENSE-APACHE`.
## Cordis, schemastery and cosmokit (Shigma, via DeepSeek Harness) — MIT
The embedded extension host bundle
(`crates/engine/extension-host/dist/codewhale-extension-host.mjs`) contains these
packages, as published by DeepSeek Harness:
- `@deepseek-ai/cordis` 4.0.5
- `@deepseek-ai/schemastery` 3.18.5
- `@deepseek-ai/cosmokit` 1.8.6
Upstream: <https://github.com/deepseek-ai/deepseek-harness> (`vendor/cordis`,
`vendor/schemastery`, `vendor/cosmokit`)
```
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`, message compatibility) — MIT
The same bundle contains `@deepseek-ai/dsh-util-values` 0.2.1-rc.5, and
`crates/engine/extension-host/src/dsh/dsh-tools-compat.js` contains verbatim
excerpts of `@deepseek-ai/dsh-tools` 0.2.1-rc.5 (`lib/index.js`), under the
same licence text. `crates/engine/extension-host/src/dsh/dsh-message-compat.js`
contains verbatim excerpts of `@deepseek-ai/dsh-brand` 0.2.1-rc.5,
`@deepseek-ai/dsh-llm` 0.2.1-rc.5, `@deepseek-ai/dsh-session` 0.2.1-rc.5,
`@deepseek-ai/dsh-system-prompt` 0.2.1-rc.5,
`@deepseek-ai/dsh-user-questions` 0.2.1-rc.5 and
`@deepseek-ai/dsh-util-crypto` 0.2.1-rc.5, under the same licence text.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
(`dsh-util-values` is published from `packages/util/values`)
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
from the bundler's metafile; Codewhale writes it beside the bundle it
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
## Model Context Protocol SDK and bundled dependencies
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
contains the following packages. The MCP packages are labeled Apache-2.0 and
carry the upstream licensing-transition statement and full Apache-2.0 text
reproduced below. The other packages are labeled MIT. Their exact distributed
notices are preserved.
### `@modelcontextprotocol/client` 2.4.0
```text
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
```
### `@modelcontextprotocol/core` 2.4.0
```text
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
```
### `eventsource` 3.0.7
```text
The MIT License
Copyright (c) EventSource GitHub organisation
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
### `eventsource-parser` 3.1.1
```text
MIT License
Copyright (c) 2026 Espen Hovlandsdal <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `jose` 6.2.12
```text
The MIT License (MIT)
Copyright (c) 2018 Filip Skokan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `pkce-challenge` 5.0.1
```text
MIT License
Copyright (c) 2019
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `zod` 4.6.7
```text
MIT License
Copyright (c) 2025 Colin McDonnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Native composition and YAML dependencies
The host and its offline composition reviewer also contain these packages.
Vendored composition adaptations are recorded in
`crates/engine/extension-host/src/dsh/upstream/UPSTREAM.json`.
### `@deepseek-ai/cordis-plugin-group` 1.0.5
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `@deepseek-ai/cordis-plugin-include` 1.0.10
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `@deepseek-ai/cordis-plugin-loader` 1.0.6
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `js-yaml` 4.3.2
```text
MIT License)
Copyright (C) 2011-2015 by Vitaly Puzrin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
### `acorn` 8.18.0
Used for nonexecuting syntax preparation of reviewed Bun composition imports.
```text
MIT License
Copyright (C) 2012-2022 by various contributors (see AUTHORS)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## DeepSeek Harness presets, hooks and persona packages — MIT
The embedded extension host includes these pinned DeepSeek Harness packages:
- `@deepseek-ai/dsh-agent-presets` 0.1.6-alpha.1
- `@deepseek-ai/dsh-hook-protocol` 0.2.1-rc.5
- `@deepseek-ai/dsh-hooks-claude-code` 0.2.1-rc.5
- `@deepseek-ai/dsh-hooks-codex` 0.2.1-rc.5
- `@deepseek-ai/dsh-persona` 0.2.1-rc.5
- `@deepseek-ai/dsh-system-prompt` 0.2.1-rc.5
Upstream: <https://github.com/deepseek-ai/deepseek-harness>, tag
`dsh-v0.2.1-rc.5`, commit `b830ec950faba0a46dda564be3e2d854afed94f1`.
The legacy `dsh-agent-presets` adapter alone retains commit
`0d1f50007f9bca3f52b06e1c3074fa14d5fb0720`; the newer preset runtime packages
are not bundled. The reviewed composition loader is refreshed to the rc.5
source: `cordis-plugin-loader` 1.0.6, `cordis-plugin-include` 1.0.10 and
`cordis-plugin-group` 1.0.5 (MIT, Shigma).
Preset discovery and metadata, hook matching and dialect codecs, and additive
persona templates run through receipt-backed inputs. Core retains selection,
prompt assembly, sessions, approvals and writable roots. Original persona source
hashes are recorded in `crates/engine/extension-host/src/dsh/persona.UPSTREAM.json`.
The identical upstream license text for all six packages is also carried in
`crates/engine/extension-host/dist/LICENSES.txt`.
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `semver` 7.8.5
```text
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
```
## Optional compiled extension host
Node remains the default extension-host runtime. An explicitly installed
`codewhale-extension-host` compiled Bun image carries its exact-build runtime
notices in `codewhale-extension-host.LICENSES.txt` and its corresponding source
and relinking inputs in `codewhale-extension-host.relink-source.tar.gz`. These
are required companions, separate from this repository's Rust/JavaScript
dependency notices. No image is eligible for release delivery until both the
matching-native containment evidence and the exact runtime notice/source
closure are complete.
Bun's matching source license identifies statically linked LGPL JavaScriptCore,
WebCore, and TinyCC, as well as other linked and embedded dependencies. The
release preparation contract is documented in
`scripts/release/compiled-host-licenses/README.md`. This file does not replace
the full notices carried with an eligible compiled image.
-76
View File
@@ -1,76 +0,0 @@
name: Codewhale
description: Review pull requests automatically or on an authorized @codewhale review request, using your chosen model.
branding:
icon: code
color: blue
inputs:
version:
description: Exact released CLI tag (vX.Y.Z). Must support review --json.
required: true
provider:
description: Explicit BYOK provider (orcarouter, deepseek, anthropic, openrouter, zai, modelstudio-token-plan), or codewhale for a separately verified account machine key.
required: true
model:
description: Exact model ID. Account mode requires provider/model from the account catalog.
required: true
reasoning-effort:
description: Optional Engine reasoning setting (auto, off, low, medium, high, max).
github-token:
description: GitHub token with contents read and pull-requests read; legacy post:true also requires pull-requests write.
default: ${{ github.token }}
pr-number:
description: PR number for workflow_dispatch; pull_request events use the event snapshot.
trigger-phrase:
description: Bot mention used for a standalone @name review line in a new PR comment.
default: '@codewhale'
allow-forks:
description: Allow inert fork review only on a write-authorized issue_comment or manual run from the default branch.
default: 'false'
max-chars:
description: Maximum diff characters per complete pass.
default: '200000'
max-passes:
description: Maximum number of complete review passes.
default: '1'
max-output-tokens:
description: Optional per-pass output ceiling; at least 8192.
timeout-seconds:
description: Model and publication deadline, in seconds (30–1200).
default: '600'
post:
description: Legacy direct COMMENT publication. Set false to export findings for a separate publisher job.
default: 'true'
outputs:
outcome:
description: reviewed_clean, reviewed_with_findings, incomplete, superseded, not_eligible, configuration_missing, failed, or publication_uncertain.
value: ${{ steps.review.outputs.outcome }}
receipt:
description: Absolute path to the sanitized JSON execution receipt (also written on failures).
value: ${{ steps.review.outputs.receipt }}
findings:
description: Absolute path to validated findings JSON for a complete post:false review; contains repository-sensitive review text.
value: ${{ steps.review.outputs.findings }}
pr-url:
description: URL of the pull request, when resolved.
value: ${{ steps.review.outputs.pr-url }}
runs:
using: composite
steps:
- id: review
shell: bash
env:
CW_ACTION_PATH: ${{ github.action_path }}
CW_VERSION: ${{ inputs.version }}
CW_PROVIDER: ${{ inputs.provider }}
CW_MODEL: ${{ inputs.model }}
CW_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
CW_PR_NUMBER: ${{ inputs.pr-number }}
CW_TRIGGER_PHRASE: ${{ inputs.trigger-phrase }}
CW_ALLOW_FORKS: ${{ inputs.allow-forks }}
CW_MAX_CHARS: ${{ inputs.max-chars }}
CW_MAX_PASSES: ${{ inputs.max-passes }}
CW_MAX_OUTPUT_TOKENS: ${{ inputs.max-output-tokens }}
CW_TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }}
CW_POST: ${{ inputs.post }}
GH_TOKEN: ${{ inputs.github-token }}
run: node "$CW_ACTION_PATH/scripts/github-action/review.mjs"
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Codewhale">
<defs>
<linearGradient id="codewhale-current" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1E8FD8"/>
<stop offset="1" stop-color="#0B48BB"/>
</linearGradient>
</defs>
<path fill="url(#codewhale-current)" fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.43 84.54 238.16C84.08 231.92 84.06 232.63 84.94 222.46C85.57 215.34 87.66 205.86 90.07 199.31C90.45 198.33 91.03 196.7 91.38 195.67C92.13 193.55 93.04 191.26 93.77 189.73C94.02 189.18 94.25 188.65 94.25 188.55C94.25 188.45 94.7 187.36 95.28 186.16C104.84 165.65 118.7 148.84 137.65 134.78C139.74 133.22 145.27 129.5 146.45 128.84C146.73 128.69 148.27 127.81 149.85 126.85C157.6 122.3 167.84 117.85 176.14 115.43C187Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Codewhale">
<path fill="#000000" fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.43 84.54 238.16C84.08 231.92 84.06 232.63 84.94 222.46C85.57 215.34 87.66 205.86 90.07 199.31C90.45 198.33 91.03 196.7 91.38 195.67C92.13 193.55 93.04 191.26 93.77 189.73C94.02 189.18 94.25 188.65 94.25 188.55C94.25 188.45 94.7 187.36 95.28 186.16C104.84 165.65 118.7 148.84 137.65 134.78C139.74 133.22 145.27 129.5 146.45 128.84C146.73 128.69 148.27 127.81 149.85 126.85C157.6 122.3 167.84 117.85 176.14 115.43C187.26 112.21 194.2Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Codewhale">
<path fill="#FFFFFF" fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.43 84.54 238.16C84.08 231.92 84.06 232.63 84.94 222.46C85.57 215.34 87.66 205.86 90.07 199.31C90.45 198.33 91.03 196.7 91.38 195.67C92.13 193.55 93.04 191.26 93.77 189.73C94.02 189.18 94.25 188.65 94.25 188.55C94.25 188.45 94.7 187.36 95.28 186.16C104.84 165.65 118.7 148.84 137.65 134.78C139.74 133.22 145.27 129.5 146.45 128.84C146.73 128.69 148.27 127.81 149.85 126.85C157.6 122.3 167.84 117.85 176.14 115.43C187.26 112.21 194.2Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Codewhale">
<path fill="#142352" fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.43 84.54 238.16C84.08 231.92 84.06 232.63 84.94 222.46C85.57 215.34 87.66 205.86 90.07 199.31C90.45 198.33 91.03 196.7 91.38 195.67C92.13 193.55 93.04 191.26 93.77 189.73C94.02 189.18 94.25 188.65 94.25 188.55C94.25 188.45 94.7 187.36 95.28 186.16C104.84 165.65 118.7 148.84 137.65 134.78C139.74 133.22 145.27 129.5 146.45 128.84C146.73 128.69 148.27 127.81 149.85 126.85C157.6 122.3 167.84 117.85 176.14 115.43C187.26 112.21 194.2Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 160" role="img" aria-label="Codewhale">
<path fill="#FFFFFF" fill-rule="evenodd" d="M893.19 8.33C890.57 9.07 888.43 10.9 887.23 13.53C886.57 14.9 886.57 14.9 886.57 79.84C886.57 144.75 886.57 144.75 887.39 146.47C891.11 154.27 902.44 153.34 905.15 145.02C905.89 142.78 906.05 17.82 905.34 15.09C903.92 9.78 898.58 6.83 893.19 8.33Z M342.45 8.77C339.14 9.13 335.83 11.72 334.51 14.98C333.88 16.54 333.88 16.54 333.8 36.97C333.75 54.12 333.67 57.43 333.34 57.43C333.15 57.43 332.93 57.32 332.87 57.19C332.35 55.85 322.67 50.73 317.06 48.87C281.99 37.11 245.34 62.58 245.28 98.74C245.26 131.1 274.31 155.93 307.13 151.67C328.6 148.85 345.76 134.16 351.8 113.4C354.02 105.79 354.04 105.14 353.93 57.57C353.85 16.26 353.85 16.26 353.22 14.71C351.53 10.47 347.42 8.22 342.45 8.77Z M306.48 66.08C332.52 71.03 342.97 103 324.75 122.07C308.69 138.87 280.57 135.42 269.33 115.29C255.24 90.12 277.56 60.61 306.48 66.08Z M652.37 8.74C649.55 9.23 646.79 11.34 645.34 14.08C644.63 15.44 644.63 15.44 644.63 80.14C644.63 153.31 644.35 145.7 647.09 148.74C650.73 152.79 657.67 152.84 661.59 148.88C664.49 145.9 664.29 148 664.49 117.89C664.62 94.47 664.7 90.97 665.09 89.16C670.64 64.16 703.03 57.27 718.26 77.84C720.83 81.31 722.56 85.06 723.6 89.38C723.82 90.26 723.95 98.98 724.06 117.61C724.23 144.56 724.23 144.56 725.02 146.28C729.01 154.9 742.06 153.14 743.81 143.74C744.41 140.43 743.95 90.37 743.29 86.78C741.57 77.65 737.6 69.33 731.83 62.74C715.83 44.47 687.93 40.66 667.22 53.99C666.13 54.7 665.06 55.22 664.84 55.14C664.54 55.03 664.46 51.66 664.4 35.49C664.32 15.99 664.32 15.99 663.53 14.3C661.64 10.17 657.05 7.89 652.37 8.74Z M47.35 46.25C28.94 48.13 12.83 59.46 4.68 76.17C3.09 79.43 1.34 84.16 0.88 86.48C0.74 87.14 0.41 88.64 0.14 89.85C-0.49 92.66 -0.6 103.39 0 106.81C3.83 129.18 19.56 145.87 41.66 151.04C45.65 152 95.9 152.16 98.34 151.23C104.93 148.77 106.54 140.04 101.32 135.09C98.39 132.3 100.77 132.55 72.79 132.36C49.18 132.22 48.23 132.19 45.76 131.64C25.99 127.13 14.66 107.41 20.98 88.4C24.1 79.02 32.31 70.81 41.71 67.69C46.86 65.99 45.02 66.1 71.97 65.94C96.72 65.78 96.72 65.78 98.56 64.9C107.17 60.82 106.16 48.76 97 46.33C95.41 45.89 51.45 45.83 47.35 46.25Z M165.66 46.35C145.55 48.71 128.51 62.14 121.4 81.23C117.35 92.06 117.29 106.21 121.23 116.52C130.59 140.94 153.92 154.9 179.88 151.56C207.34 148.03 228.65 122.07 226 95.35C225.51 90.56 224.74 86.37 223.84 83.56C215.8 58.86 191.53 43.32 165.66 46.35Z M178.62 66.05C199.17 69.99 211.45 90.56 204.77 109.82C201.35 119.66 192.55 128.2 182.75 131.1C175.09 133.37 166.75 133.12 159.47 130.39C149.27 126.53 141.58 117.72 138.69 106.51C137.92 103.55 137.76 95.1 138.38 92.45C140.68 83.09 146.81 74.39 153.81 70.59C158.02 68.29 159.42 67.69 162.65 66.76C164.32 66.3 165.57 66.08 169.49 65.53C171.05 65.31 176.32 65.61 178.62 66.05Z M420.19 46.35C401.78 48.43 386.18 59.51 377.9 76.44C363.4 106.12 380.44 142.2 412.94 150.57C421.36 152.73 432.17 152.63 440.7 150.25C449.87 147.73 457.06 143.46 464.77 136.02C468.93 132 469.56 130.88 469.56 127.32C469.56 117.94 457.52 113.97 451.53 121.39C438.6 137.36 411.76 136.46 399.31 119.64C396.55 115.89 394.04 110.55 394.72 109.87C394.97 109.62 402.93 109.52 429.98 109.46C469.45 109.41 466.69 109.54 470.19 107.22C474.4 104.4 475.99 101.04 475.99 94.88C475.99 67.23 448.39 43.21 420.19 46.35Z M428.39 65.94C439.25 67.03 448.5 73.46 453.28 83.28C454.87 86.48 455.45 88.48 454.95 88.97C454.46 89.46 395.18 89.44 394.69 88.94C393.73 87.99 397.65 80.49 401.37 76.17C405.85 70.95 414.74 66.62 422.48 65.94C423.85 65.8 425.11 65.69 425.27 65.67C425.47 65.67 426.86 65.78 428.39 65.94Z M503.29 46.38C499.02 47.39 496.26 50.54 495.68 55.03C495.22 58.58 495.33 114.44 495.82 118.16C496.97 127.16 500.11 133.91 506.02 140.07C520.3 155 542.62 155.93 557.97 142.26L560.51 139.99L562.62 141.9C568.23 146.94 574.79 150.27 581.41 151.45C599.77 154.68 618.17 143.32 623.67 125.41C625.51 119.53 625.59 117.45 625.51 83.56C625.42 56.31 625.4 54.21 624.93 52.92C621.95 44.55 610.65 43.95 606.55 51.96C605.78 53.47 605.78 53.47 605.65 85.33C605.51 109.9 605.4 117.45 605.15 118.3C603.32 124.07 600.53 127.57 595.83 130C586.25 135.01 575.01 130.11 571.43 119.39C570.77 117.48 570.77 117.48 570.63 85.74C570.47 50.65 570.63 53.22 568.72 50.48C564.67 44.58 554.85 45.32 551.81 51.74C550.69 54.18 550.66 54.45 550.66 85.91C550.66 120.21 550.77 118.41 548.59 122.54C541.15 136.68 520.85 134.38 516.28 118.84C515.84 117.34 515.79 114.71 515.65 85.33C515.51 53.47 515.51 53.47 514.75 51.91C512.56 47.48 507.94 45.23 503.29 46.38Z M808.07 46.35C775.57 50.32 754.18 82.3 763.1 113.56C767.15 127.81 777.05 139.71 790.42 146.53C807.44 155.14 829.32 153.83 845.24 143.19C848.44 141.03 848.61 141.05 848.91 143.6C850.22 154.62 866.33 154.62 868.25 143.57C868.88 139.93 868.46 94.33 867.75 90.39C862.67 61.92 836.79 42.85 808.07 46.35Z M821.22 66.08C848.5 71.25 858.48 104.13 838.59 123.27C817.01 144.09 780.71 128.88 780.63 99.01C780.58 78 800.19 62.08 821.22 66.08Z M968.77 46.38C950.66 48.79 935.54 59.59 927.58 75.76C913.33 104.7 927.91 139.52 958.51 149Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 160" role="img" aria-label="Codewhale">
<path fill="#142352" fill-rule="evenodd" d="M893.19 8.33C890.57 9.07 888.43 10.9 887.23 13.53C886.57 14.9 886.57 14.9 886.57 79.84C886.57 144.75 886.57 144.75 887.39 146.47C891.11 154.27 902.44 153.34 905.15 145.02C905.89 142.78 906.05 17.82 905.34 15.09C903.92 9.78 898.58 6.83 893.19 8.33Z M342.45 8.77C339.14 9.13 335.83 11.72 334.51 14.98C333.88 16.54 333.88 16.54 333.8 36.97C333.75 54.12 333.67 57.43 333.34 57.43C333.15 57.43 332.93 57.32 332.87 57.19C332.35 55.85 322.67 50.73 317.06 48.87C281.99 37.11 245.34 62.58 245.28 98.74C245.26 131.1 274.31 155.93 307.13 151.67C328.6 148.85 345.76 134.16 351.8 113.4C354.02 105.79 354.04 105.14 353.93 57.57C353.85 16.26 353.85 16.26 353.22 14.71C351.53 10.47 347.42 8.22 342.45 8.77Z M306.48 66.08C332.52 71.03 342.97 103 324.75 122.07C308.69 138.87 280.57 135.42 269.33 115.29C255.24 90.12 277.56 60.61 306.48 66.08Z M652.37 8.74C649.55 9.23 646.79 11.34 645.34 14.08C644.63 15.44 644.63 15.44 644.63 80.14C644.63 153.31 644.35 145.7 647.09 148.74C650.73 152.79 657.67 152.84 661.59 148.88C664.49 145.9 664.29 148 664.49 117.89C664.62 94.47 664.7 90.97 665.09 89.16C670.64 64.16 703.03 57.27 718.26 77.84C720.83 81.31 722.56 85.06 723.6 89.38C723.82 90.26 723.95 98.98 724.06 117.61C724.23 144.56 724.23 144.56 725.02 146.28C729.01 154.9 742.06 153.14 743.81 143.74C744.41 140.43 743.95 90.37 743.29 86.78C741.57 77.65 737.6 69.33 731.83 62.74C715.83 44.47 687.93 40.66 667.22 53.99C666.13 54.7 665.06 55.22 664.84 55.14C664.54 55.03 664.46 51.66 664.4 35.49C664.32 15.99 664.32 15.99 663.53 14.3C661.64 10.17 657.05 7.89 652.37 8.74Z M47.35 46.25C28.94 48.13 12.83 59.46 4.68 76.17C3.09 79.43 1.34 84.16 0.88 86.48C0.74 87.14 0.41 88.64 0.14 89.85C-0.49 92.66 -0.6 103.39 0 106.81C3.83 129.18 19.56 145.87 41.66 151.04C45.65 152 95.9 152.16 98.34 151.23C104.93 148.77 106.54 140.04 101.32 135.09C98.39 132.3 100.77 132.55 72.79 132.36C49.18 132.22 48.23 132.19 45.76 131.64C25.99 127.13 14.66 107.41 20.98 88.4C24.1 79.02 32.31 70.81 41.71 67.69C46.86 65.99 45.02 66.1 71.97 65.94C96.72 65.78 96.72 65.78 98.56 64.9C107.17 60.82 106.16 48.76 97 46.33C95.41 45.89 51.45 45.83 47.35 46.25Z M165.66 46.35C145.55 48.71 128.51 62.14 121.4 81.23C117.35 92.06 117.29 106.21 121.23 116.52C130.59 140.94 153.92 154.9 179.88 151.56C207.34 148.03 228.65 122.07 226 95.35C225.51 90.56 224.74 86.37 223.84 83.56C215.8 58.86 191.53 43.32 165.66 46.35Z M178.62 66.05C199.17 69.99 211.45 90.56 204.77 109.82C201.35 119.66 192.55 128.2 182.75 131.1C175.09 133.37 166.75 133.12 159.47 130.39C149.27 126.53 141.58 117.72 138.69 106.51C137.92 103.55 137.76 95.1 138.38 92.45C140.68 83.09 146.81 74.39 153.81 70.59C158.02 68.29 159.42 67.69 162.65 66.76C164.32 66.3 165.57 66.08 169.49 65.53C171.05 65.31 176.32 65.61 178.62 66.05Z M420.19 46.35C401.78 48.43 386.18 59.51 377.9 76.44C363.4 106.12 380.44 142.2 412.94 150.57C421.36 152.73 432.17 152.63 440.7 150.25C449.87 147.73 457.06 143.46 464.77 136.02C468.93 132 469.56 130.88 469.56 127.32C469.56 117.94 457.52 113.97 451.53 121.39C438.6 137.36 411.76 136.46 399.31 119.64C396.55 115.89 394.04 110.55 394.72 109.87C394.97 109.62 402.93 109.52 429.98 109.46C469.45 109.41 466.69 109.54 470.19 107.22C474.4 104.4 475.99 101.04 475.99 94.88C475.99 67.23 448.39 43.21 420.19 46.35Z M428.39 65.94C439.25 67.03 448.5 73.46 453.28 83.28C454.87 86.48 455.45 88.48 454.95 88.97C454.46 89.46 395.18 89.44 394.69 88.94C393.73 87.99 397.65 80.49 401.37 76.17C405.85 70.95 414.74 66.62 422.48 65.94C423.85 65.8 425.11 65.69 425.27 65.67C425.47 65.67 426.86 65.78 428.39 65.94Z M503.29 46.38C499.02 47.39 496.26 50.54 495.68 55.03C495.22 58.58 495.33 114.44 495.82 118.16C496.97 127.16 500.11 133.91 506.02 140.07C520.3 155 542.62 155.93 557.97 142.26L560.51 139.99L562.62 141.9C568.23 146.94 574.79 150.27 581.41 151.45C599.77 154.68 618.17 143.32 623.67 125.41C625.51 119.53 625.59 117.45 625.51 83.56C625.42 56.31 625.4 54.21 624.93 52.92C621.95 44.55 610.65 43.95 606.55 51.96C605.78 53.47 605.78 53.47 605.65 85.33C605.51 109.9 605.4 117.45 605.15 118.3C603.32 124.07 600.53 127.57 595.83 130C586.25 135.01 575.01 130.11 571.43 119.39C570.77 117.48 570.77 117.48 570.63 85.74C570.47 50.65 570.63 53.22 568.72 50.48C564.67 44.58 554.85 45.32 551.81 51.74C550.69 54.18 550.66 54.45 550.66 85.91C550.66 120.21 550.77 118.41 548.59 122.54C541.15 136.68 520.85 134.38 516.28 118.84C515.84 117.34 515.79 114.71 515.65 85.33C515.51 53.47 515.51 53.47 514.75 51.91C512.56 47.48 507.94 45.23 503.29 46.38Z M808.07 46.35C775.57 50.32 754.18 82.3 763.1 113.56C767.15 127.81 777.05 139.71 790.42 146.53C807.44 155.14 829.32 153.83 845.24 143.19C848.44 141.03 848.61 141.05 848.91 143.6C850.22 154.62 866.33 154.62 868.25 143.57C868.88 139.93 868.46 94.33 867.75 90.39C862.67 61.92 836.79 42.85 808.07 46.35Z M821.22 66.08C848.5 71.25 858.48 104.13 838.59 123.27C817.01 144.09 780.71 128.88 780.63 99.01C780.58 78 800.19 62.08 821.22 66.08Z M968.77 46.38C950.66 48.79 935.54 59.59 927.58 75.76C913.33 104.7 927.91 139.52 958.51 149Line truncated
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

+189 -1401
View File
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
[package]
name = "codewhale-account-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Codewhale account, cloud computer and dispatch commands for the CLI"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
chrono.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
console = "0.16.3"
reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
toml.workspace = true
webbrowser = "1.0"
zeroize = "1.8.2"
-775
View File
@@ -1,775 +0,0 @@
use super::*;
use serde_json::{Value, json};
#[derive(Debug, Default, Args)]
pub(crate) struct ComputerOptions {
#[arg(
long,
value_name = "ID",
help = "Run on this account cloud computer (preview)"
)]
pub computer: Option<String>,
#[arg(
long,
value_name = "ID",
help = "Continue or inspect a desktop/CLI cloud conversation"
)]
pub thread: Option<String>,
#[arg(
long,
requires = "cloud_model",
help = "Provider on the cloud computer"
)]
pub cloud_provider: Option<String>,
#[arg(
long,
requires = "cloud_provider",
help = "Model on the cloud computer"
)]
pub cloud_model: Option<String>,
#[arg(
long,
help = "Read a provider key from stdin into VM memory for this running computer"
)]
pub key_stdin: bool,
#[arg(long, help = "Stop the current turn of --thread")]
pub stop: bool,
#[arg(
long,
value_name = "ID",
conflicts_with = "deny",
help = "Allow one pending tool request in --thread"
)]
pub approve: Option<String>,
#[arg(
long,
value_name = "ID",
help = "Deny one pending tool request in --thread"
)]
pub deny: Option<String>,
#[arg(long, help = "Print the session receipt as JSON")]
pub json: bool,
#[arg(long, help = "Show the configured model available to cloud dispatch")]
pub configured_model: bool,
#[arg(
long,
help = "Connect the configured model to --thread without sending a message"
)]
pub prepare_model: bool,
#[arg(long, hide = true)]
pub expected_account: Option<String>,
#[arg(
long,
value_name = "AGENT_ID",
conflicts_with_all = ["thread", "cloud_provider", "cloud_model"],
help = "Start a cloud conversation with this whale, using its saved model"
)]
pub whale: Option<String>,
}
impl ComputerOptions {
pub fn selected(&self) -> bool {
self.computer.is_some()
|| self.thread.is_some()
|| self.cloud_provider.is_some()
|| self.cloud_model.is_some()
|| self.key_stdin
|| self.stop
|| self.approve.is_some()
|| self.deny.is_some()
|| self.json
|| self.configured_model
|| self.prepare_model
|| self.whale.is_some()
}
}
#[derive(Debug, Args)]
pub(crate) struct SessionArgs {
#[arg(value_name = "PROMPT")]
pub prompt: Vec<String>,
#[command(flatten)]
pub options: ComputerOptions,
#[arg(
long,
help = "Reuse this key and the same prompt after an uncertain send"
)]
pub operation_key: Option<String>,
}
struct SessionClient<'a, 'b, T: CloudTransport> {
client: &'a CloudClient<'b, T>,
account: String,
session: String,
}
impl<T: CloudTransport> SessionClient<'_, '_, T> {
fn snapshot(&self) -> Result<AccountSessionSnapshot> {
let snapshot = self.client.account_store.snapshot()?;
let stored = snapshot
.load()?
.context("Sign in with `codewhale login` first")?;
if stored.bundle.user.as_ref().map(|user| &user.id) != Some(&self.account)
|| stored.bundle.session.as_ref().map(|session| &session.id) != Some(&self.session)
{
bail!("The signed-in account changed. Recover the original session before retrying.");
}
Ok(snapshot)
}
fn request(&self, method: HttpMethod, path: &str, body: Option<Value>) -> Result<Value> {
let snapshot = self.snapshot()?;
let (response, _) = self.client.execute_from_snapshot(
method,
path,
body.as_ref().map(json_body).transpose()?,
snapshot,
)?;
self.snapshot()?;
expect_json(response, &[200, 201, 202])
}
fn operation(
&self,
thread: &str,
computer: &str,
runtime: &str,
kind: &str,
input: Value,
) -> Result<Value> {
let value = self.request(
HttpMethod::Post,
&format!("/v1/threads/{thread}/computer"),
Some(
json!({ "computerId": computer, "operation": { "kind": kind, "request": input } }),
),
)?;
check_binding(&value, thread, computer)?;
if value["runtimeThreadId"].as_str() != Some(runtime)
|| !value.get("result").is_some_and(Value::is_object)
{
bail!("The computer returned a different session. Refresh before continuing.");
}
Ok(value["result"].clone())
}
}
fn check_binding(value: &Value, thread: &str, computer: &str) -> Result<String> {
if value["threadId"].as_str() != Some(thread)
|| value["computerId"].as_str() != Some(computer)
|| value["state"] != "bound"
{
bail!("The cloud conversation is not bound to the selected computer.");
}
let runtime = value["runtimeThreadId"]
.as_str()
.context("The computer did not return its session ID")?;
validate_turn_id(runtime)?;
Ok(runtime.to_string())
}
pub(super) fn run<T: CloudTransport, W: Write>(
args: SessionArgs,
client: &CloudClient<'_, T>,
machine: &machine::MachineKeyEnv,
out: &mut W,
key_reader: &mut dyn FnMut(KeyReadMode) -> Result<String>,
config: &ConfigStore,
secrets: &Secrets,
) -> Result<()> {
let mut local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides::default(),
secrets,
);
if args.options.configured_model {
if !args.prompt.is_empty()
|| args.options.prepare_model
|| args.options.key_stdin
|| args.options.stop
|| args.options.approve.is_some()
|| args.options.deny.is_some()
{
bail!("Use --configured-model without a session action.");
}
let available = portable_model(&local, config).is_ok();
let reason = portable_model(&local, config)
.err()
.map(|error| error.to_string());
writeln!(
out,
"{}",
serde_json::to_string(
&json!({ "provider": local.provider.as_str(), "model": local.model,
"configured": available, "reason": reason })
)?
)?;
return Ok(());
}
if machine.is_present() {
bail!(
"Cloud conversations require an account login; unset CODEWHALE_API_KEY and run `codewhale login`."
);
}
let mut options = args.options;
let mut prompt = args.prompt.join(" ").trim().to_string();
if prompt.is_empty()
&& !io::stdin().is_terminal()
&& !options.key_stdin
&& !options.prepare_model
&& !options.stop
&& options.approve.is_none()
&& options.deny.is_none()
{
let mut piped = String::new();
io::stdin()
.take(512_001)
.read_to_string(&mut piped)
.context("Read the task from standard input")?;
if piped.len() > 512_000 {
bail!("The piped task is too large.");
}
prompt = piped.trim().to_string();
}
if [
!prompt.is_empty(),
options.stop,
options.approve.is_some(),
options.deny.is_some(),
options.prepare_model,
]
.into_iter()
.filter(|x| *x)
.count()
> 1
{
bail!("Choose a prompt, --stop, --approve, or --deny.");
}
if prompt.chars().count() > 128_000 {
bail!("The cloud prompt is too long; keep it under 128,000 characters.");
}
if options.thread.is_none() && prompt.is_empty() {
bail!("Give a task to dispatch, or continue with --thread <id>.");
}
if let Some(value) = &options.whale {
validate_resource_id(value, "Whale")?;
}
let use_local_model = options.cloud_provider.is_none()
&& !options.key_stdin
&& (!prompt.is_empty() || options.prepare_model);
if options.thread.is_none() && options.cloud_provider.is_none() && options.whale.is_none() {
options.cloud_provider = Some(local.provider.as_str().to_string());
options.cloud_model = Some(local.model.clone());
}
if let Some(value) = &options.thread {
validate_resource_id(value, "Conversation")?;
}
if let Some(value) = &options.computer {
validate_resource_id(value, "Computer")?;
}
if let (Some(provider), Some(model)) = (&options.cloud_provider, &options.cloud_model) {
validate_model_route(provider, model)?;
if model.len() > 200 {
bail!("Cloud model names must be at most 200 bytes.");
}
}
let operation_key = args
.operation_key
.unwrap_or_else(|| format!("dispatch-{}", dispatch_stamp(&prompt)));
validate_operation_key(&operation_key)?;
if !operation_key.starts_with(|value: char| value.is_ascii_alphanumeric()) {
bail!("The operation key must start with a letter or number.");
}
let user = client.me()?;
if options
.expected_account
.as_deref()
.is_some_and(|id| id != user.id)
{
bail!("The signed-in account changed. Reopen the original cloud conversation.");
}
let stored = client
.load_auth()?
.context("Sign in with `codewhale login` first")?;
let session = stored
.bundle
.session
.as_ref()
.filter(|session| !session.id.is_empty())
.context("Sign in again to establish a durable account session")?
.id
.clone();
let account = SessionClient {
client,
account: user.id,
session,
};
account.snapshot()?;
let key = if options.key_stdin {
Some(zeroize::Zeroizing::new(key_reader(KeyReadMode::Stdin)?))
} else {
None
};
let mut thread = if let Some(id) = &options.thread {
account.request(HttpMethod::Get, &format!("/v1/threads/{id}"), None)?["thread"].clone()
} else {
Value::Null
};
if let Some(id) = &options.thread
&& (thread["id"].as_str() != Some(id)
|| thread["userId"].as_str() != Some(&account.account)
|| thread["archivedAt"]
.as_str()
.is_some_and(|value| !value.is_empty()))
{
bail!("This account conversation is unavailable.");
}
let whale_id = options.whale.clone().or_else(|| {
thread["agentId"]
.as_str()
.filter(|id| !id.is_empty() && thread["agentFleetRevision"].as_u64().is_some())
.map(str::to_string)
});
let whale_route = match &whale_id {
Some(id) => {
validate_resource_id(id, "Whale")?;
let agent = account.request(HttpMethod::Get, &format!("/api/agents/{id}"), None)?;
let route = &agent["agent"]["configuration"]["model"];
match (route["provider"].as_str(), route["model"].as_str()) {
(Some(provider), Some(model)) if agent["agent"]["id"].as_str() == Some(id) => {
validate_model_route(provider, model)?;
Some((provider.to_string(), model.to_string()))
}
_ => bail!(
"This whale has no saved model. Give it a configured model in Codewhale before dispatching."
),
}
}
None => None,
};
if let Some((provider, model)) = &whale_route
&& (use_local_model || options.prepare_model)
{
let kind = codewhale_config::ProviderKind::parse(provider).with_context(|| {
format!(
"Configure the {provider} provider in Codewhale before dispatching to this whale."
)
})?;
local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides {
provider: Some(kind),
model: Some(model.clone()),
..Default::default()
},
secrets,
);
portable_model(&local, config)?;
} else if use_local_model {
portable_model(&local, config)?;
}
let selected = if options.computer.is_none() && options.thread.is_none() {
let list = account.request(HttpMethod::Get, "/api/computers", None)?;
let machines = list["computers"]
.as_array()
.context("The account computer list is unavailable")?;
let running: Vec<_> = machines
.iter()
.filter(|computer| {
computer["ownerId"].as_str() == Some(&account.account)
&& computer["status"] == "running"
})
.collect();
match running.as_slice() {
[computer] => computer["id"].as_str().map(str::to_string),
[] => bail!(
"No Boat computer is running. Start one in Codewhale Computers, then dispatch your task."
),
_ => bail!(
"More than one computer is running. Choose --computer <id>; `codewhale account computers list` shows their IDs."
),
}
} else {
None
};
let computer = options
.computer
.as_deref()
.or_else(|| thread["computerId"].as_str())
.or(selected.as_deref())
.context("This conversation has no cloud computer")?
.to_string();
validate_resource_id(&computer, "Computer")?;
if options.thread.is_some() && thread["computerId"].as_str() != Some(&computer) {
bail!("The conversation belongs to a different computer.");
}
let machine = account.request(HttpMethod::Get, &format!("/api/computers/{computer}"), None)?;
if machine["computer"]["id"].as_str() != Some(&computer)
|| machine["computer"]["ownerId"].as_str() != Some(&account.account)
{
bail!("This computer does not belong to the signed-in account.");
}
if machine["computer"]["status"] != "running" {
bail!(
"Start this computer with `codewhale account computers start {computer}`, then retry the same session."
);
}
if !prompt.is_empty() {
eprintln!(
"Replay key: {operation_key}. Keep the same target, prompt, and --operation-key when retrying an uncertain send."
);
}
if options.thread.is_none() {
let value = account.request(HttpMethod::Post, "/v1/threads", Some(json!({
"title": prompt.chars().take(120).collect::<String>(), "agentId": whale_id.as_deref().unwrap_or(""), "computerId": computer,
"productMode": "chat", "mode": "chat", "billingMode": "byok_external", "requiresByok": true,
"modelProvider": options.cloud_provider, "model": options.cloud_model, "operationKey": operation_key,
})))?;
thread = value.get("thread").unwrap_or(&value).clone();
if thread["computerId"].as_str() != Some(&computer)
|| thread["userId"].as_str() != Some(&account.account)
{
bail!("Conversation creation is unconfirmed. Retry with the same operation key.");
}
}
let thread_id = thread["id"]
.as_str()
.context("Conversation creation did not return an ID")?;
validate_resource_id(thread_id, "Conversation")?;
let binding_path = format!("/v1/threads/{thread_id}/computer");
let existing_binding = if options.thread.is_some() {
account.request(HttpMethod::Get, &binding_path, None)?
} else {
Value::Null
};
let binding = if existing_binding["state"] == "bound" {
existing_binding
} else if options.stop {
bail!("This conversation has no connected task to stop.");
} else {
account.request(
HttpMethod::Post,
&binding_path,
Some(json!({ "computerId": computer })),
)?
};
let runtime = check_binding(&binding, thread_id, &computer)?;
let detail = account.operation(thread_id, &computer, &runtime, "read", json!({}))?;
if detail["thread"]["id"].as_str() != Some(&runtime)
|| !detail["turns"].is_array()
|| !detail["items"].is_array()
{
bail!("The computer did not confirm this conversation's task history.");
}
let turns = detail["turns"].as_array().unwrap();
if turns
.iter()
.any(|turn| turn["thread_id"].as_str() != Some(&runtime))
{
bail!("The computer returned a task from a different conversation.");
}
let latest_id = detail["thread"]["latest_turn_id"]
.as_str()
.filter(|id| !id.is_empty());
let latest = latest_id.and_then(|id| {
turns
.iter()
.find(|turn| turn["id"] == id && turn["routing_settlement"] != true)
});
let provider = options
.cloud_provider
.as_deref()
.or(whale_route.as_ref().map(|(provider, _)| provider.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_provider"].as_str()))
.or_else(|| {
latest_id
.is_none()
.then(|| detail["thread"]["model_provider"].as_str())
.flatten()
})
.unwrap_or("");
let model = options
.cloud_model
.as_deref()
.or(whale_route.as_ref().map(|(_, model)| model.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_model"].as_str()))
.or_else(|| {
latest_id
.is_none()
.then(|| detail["thread"]["model"].as_str())
.flatten()
})
.unwrap_or("");
let provider_id = if options.cloud_provider.is_some() || whale_route.is_some() {
None
} else {
latest
.and_then(|turn| turn["effective_provider_id"].as_str())
.filter(|id| !id.is_empty())
.or_else(|| {
latest_id
.is_none()
.then(|| {
detail["thread"]["model_provider_id"]
.as_str()
.filter(|id| !id.is_empty())
})
.flatten()
})
};
if !prompt.is_empty() || key.is_some() {
validate_model_route(provider, model).context("The last model route is unconfirmed. Choose --cloud-provider and --cloud-model explicitly")?;
}
if use_local_model {
if provider != local.provider.as_str()
|| model != local.model
|| provider_id.is_some_and(|id| id != provider)
{
bail!(
"This conversation uses a different model from your configured model. Select its model locally or explicitly choose --cloud-provider and --cloud-model to use the computer's existing connection."
);
}
account.operation(
thread_id,
&computer,
&runtime,
"key",
json!({ "provider": provider,
"key": local.api_key.as_deref(), "expectedBaseUrl": local.base_url }),
).context("The computer could not confirm your model connection. Check that its provider endpoint matches your configured model")?;
}
let mut member_connections = Vec::new();
if use_local_model {
let lead = thread["agentId"].as_str().unwrap_or("");
let mut connected = vec![provider.to_string()];
for participant in thread["group"]["participants"]
.as_array()
.into_iter()
.flatten()
.filter(|row| row["state"] == "active" && row["agentId"].as_str() != Some(lead))
{
let id = participant["agentId"]
.as_str()
.context("A group member has no whale ID. Refresh the group.")?;
validate_resource_id(id, "Whale")?;
let name = printable(participant["name"].as_str().unwrap_or("A member"));
let agent = account.request(HttpMethod::Get, &format!("/api/agents/{id}"), None)?;
let route = &agent["agent"]["configuration"]["model"];
let (Some(member_provider), Some(member_model)) =
(route["provider"].as_str(), route["model"].as_str())
else {
bail!("{name} has no saved model. Give it a configured model in Codewhale.");
};
if agent["agent"]["id"].as_str() != Some(id) {
bail!("The account returned a different whale for {name}.");
}
validate_model_route(member_provider, member_model)?;
if connected.iter().any(|done| done == member_provider) {
continue;
}
let kind =
codewhale_config::ProviderKind::parse(member_provider).with_context(|| {
format!("Configure the {member_provider} provider in Codewhale for {name}.")
})?;
let member_local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides {
provider: Some(kind),
model: Some(member_model.to_string()),
..Default::default()
},
secrets,
);
portable_model(&member_local, config)
.with_context(|| format!("{name} uses {member_provider}"))?;
account
.operation(
thread_id,
&computer,
&runtime,
"key",
json!({ "provider": member_provider,
"key": member_local.api_key.as_deref(), "expectedBaseUrl": member_local.base_url }),
)
.with_context(|| {
format!("The computer could not confirm the {member_provider} connection for {name}")
})?;
connected.push(member_provider.to_string());
member_connections.push(json!({ "agentId": id, "provider": member_provider }));
}
}
if let Some(key) = key {
validate_api_key(&key)?;
account.operation(
thread_id,
&computer,
&runtime,
"key",
json!({ "provider": provider_id.unwrap_or(provider), "key": key.as_str() }),
)?;
}
let result = if !prompt.is_empty() {
account.operation(thread_id, &computer, &runtime, "turn", json!({
"prompt": prompt, "operation_key": operation_key, "model": model,
"model_provider": provider, "model_provider_id": provider_id,
})).with_context(|| format!("Session {thread_id}: retry the original request with --operation-key {operation_key}"))?
} else if options.stop {
if let Some(turn) = turns
.iter()
.rev()
.find(|turn| matches!(turn["status"].as_str(), Some("queued" | "in_progress")))
{
let stopped = account.operation(
thread_id,
&computer,
&runtime,
"stop",
json!({ "turnId": turn["id"] }),
)?;
if stopped["id"] != turn["id"] || stopped["thread_id"].as_str() != Some(&runtime) {
bail!("The computer did not confirm which task received the stop request.");
}
json!({ "turn": stopped, "message": "Stop requested. Inspect the conversation to confirm it has stopped." })
} else {
json!({ "status": "idle", "message": "No running turn" })
}
} else if let Some(id) = options.approve.as_ref().or(options.deny.as_ref()) {
let reply = account.operation(thread_id, &computer, &runtime, "approve", json!({ "id": id, "decision": if options.approve.is_some() { "allow" } else { "deny" } }))?;
if reply["delivered"] != true {
bail!("This approval is no longer waiting. Refresh the conversation.");
}
reply
} else if options.prepare_model {
json!({ "prepared": true, "provider": provider, "model": model, "backend": "memory",
"members": member_connections })
} else {
detail
};
if let Some(turn) = result.get("turn")
&& turn["thread_id"].as_str() != Some(&runtime)
{
bail!("The computer returned a different task");
}
if options.json {
writeln!(
out,
"{}",
serde_json::to_string_pretty(&json!({ "threadId": thread_id, "computerId": computer,
"runtimeThreadId": runtime, "operationKey": (!prompt.is_empty()).then_some(&operation_key), "result": result }))?
)?;
} else {
writeln!(out, "Cloud conversation: {thread_id}\nComputer: {computer}")?;
if let Some(turn) = result.get("turn") {
writeln!(
out,
"Task: {} ({})",
printable(turn["id"].as_str().unwrap_or("")),
printable(turn["status"].as_str().unwrap_or("unconfirmed"))
)?;
}
if let Some(message) = result["message"].as_str() {
writeln!(out, "{}", printable(message))?;
}
if result["delivered"] == true {
writeln!(out, "Decision delivered to the running task.")?;
}
if let Some(items) = result["items"].as_array() {
for item in items {
let text = item["detail"]
.as_str()
.filter(|text| !text.is_empty())
.or_else(|| item["summary"].as_str())
.unwrap_or("");
writeln!(
out,
"{}: {}",
printable(item["kind"].as_str().unwrap_or("status")),
printable(text)
)?;
}
}
if let Some(approvals) = result["pending_approvals"].as_array() {
for approval in approvals {
writeln!(
out,
"Approval {}: {}",
printable(approval["id"].as_str().unwrap_or("")),
printable(&serde_json::to_string(approval)?)
)?;
}
}
if result["pending_user_inputs"]
.as_array()
.is_some_and(|rows| !rows.is_empty())
{
writeln!(
out,
"A question is waiting. Open this conversation in desktop to answer it."
)?;
}
writeln!(
out,
"Inspect: codewhale dispatch --thread {thread_id}\nContinue: codewhale dispatch --thread {thread_id} \"your next task\"\nStop: codewhale dispatch --thread {thread_id} --stop"
)?;
}
Ok(())
}
fn portable_model(
local: &codewhale_config::ResolvedRuntimeOptions,
config: &ConfigStore,
) -> Result<()> {
local
.route
.as_ref()
.map_err(|_| anyhow!("Choose a valid model in Codewhale before dispatching."))?;
let endpoint =
Url::parse(&local.base_url).context("The configured model endpoint is invalid")?;
let local_host = endpoint.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost")
|| host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback() || address.is_unspecified())
});
if endpoint.scheme() != "https"
|| local_host
|| endpoint.host_str().is_none()
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| local.insecure_skip_tls_verify
{
bail!(
"Cloud dispatch needs a model endpoint reachable over HTTPS from Boat. A model running only on this computer cannot continue after it disconnects."
);
}
if codewhale_config::auth_mode_disables_api_key(local.auth_mode.as_deref())
|| config
.config
.providers
.for_provider(local.provider)
.external_credentials
.is_some()
|| config
.config
.providers
.for_provider(local.provider)
.oauth_credential_generation
.is_some()
|| !local.http_headers.is_empty()
|| matches!(
local.provider,
codewhale_config::ProviderKind::OpenaiCodex | codewhale_config::ProviderKind::Custom
)
|| local
.auth_mode
.as_deref()
.is_some_and(|mode| !matches!(mode, "api_key" | "auto"))
{
bail!(
"This configured connection cannot be copied to Boat. Use a portable API-key model connection; device-bound sign-ins stay on their credential owner."
);
}
validate_api_key(local.api_key.as_deref().context("Configure a model API key in Codewhale before dispatching. Boat provides the computer, not model credentials.")?)?;
Ok(())
}
-184
View File
@@ -1,184 +0,0 @@
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::Args;
use codewhale_config::ConfigStore;
#[derive(Debug, Args)]
pub struct DispatchArgs {
#[command(flatten)]
computer: crate::computer::ComputerOptions,
/// Task for the remote agent. Required unless inspecting or cancelling a job.
#[arg(value_name = "PROMPT")]
prompt: Vec<String>,
/// Inspect one account job.
#[arg(long, value_name = "ID")]
show: Option<String>,
/// Cancel one account job.
#[arg(long, value_name = "ID")]
cancel: Option<String>,
/// Account dispatch: Agent name or ID (default: the repository-free default Agent).
#[arg(long)]
agent: Option<String>,
/// Account dispatch: task time in seconds (default: what your offer allows).
#[arg(long)]
seconds: Option<u64>,
/// Account dispatch: UTF-8 source context file.
#[arg(long, value_name = "PATH")]
context_file: Option<PathBuf>,
/// Account dispatch: saved account file FILE_ID@VERSION; repeatable.
#[arg(long = "file", value_name = "FILE_ID@VERSION")]
file_refs: Vec<String>,
/// Account dispatch: skip the question (needs --confirm-eu-compute).
#[arg(long)]
yes: bool,
/// Account dispatch: agree that repository code and Work files run on EU compute.
#[arg(long)]
confirm_eu_compute: bool,
/// Account dispatch: replay key for a retry after an uncertain response.
#[arg(long)]
operation_key: Option<String>,
/// Account dispatch: message ID for a retry after an uncertain response.
#[arg(long)]
message_id: Option<String>,
}
fn unknown_job(id: &str) -> anyhow::Error {
anyhow::anyhow!("Unknown job {id}: it is not a Codewhale account job ID.")
}
pub fn run(args: DispatchArgs, profile: Option<&str>, config: &mut ConfigStore) -> Result<()> {
use crate::{AccountDispatch, run_dispatch};
let legacy_work = args.agent.is_some()
|| args.seconds.is_some()
|| args.context_file.is_some()
|| !args.file_refs.is_empty()
|| args.message_id.is_some()
|| args.yes
|| args.confirm_eu_compute
|| args.show.is_some()
|| args.cancel.is_some();
if args.computer.selected() || !legacy_work {
if args.agent.is_some()
|| args.seconds.is_some()
|| args.context_file.is_some()
|| !args.file_refs.is_empty()
|| args.message_id.is_some()
|| args.yes
|| args.confirm_eu_compute
{
bail!(
"Cloud conversation dispatch uses --computer or --thread. Agent Work flags cannot be combined with it."
);
}
if args.show.is_some() && args.cancel.is_some()
|| (!args.prompt.is_empty() && (args.show.is_some() || args.cancel.is_some()))
{
bail!("Choose a prompt, --show, or --cancel.");
}
let mut options = args.computer;
if let Some(id) = args.show.as_ref().or(args.cancel.as_ref()) {
if options.thread.as_ref().is_some_and(|thread| thread != id) {
bail!("Choose one conversation ID.");
}
options.thread = Some(id.clone());
options.stop = args.cancel.is_some();
}
return run_dispatch(
AccountDispatch::Computer(crate::computer::SessionArgs {
prompt: args.prompt,
options,
operation_key: args.operation_key,
}),
profile,
config,
);
}
let modes = [
args.show.is_some(),
args.cancel.is_some(),
!args.prompt.is_empty(),
];
if modes.iter().filter(|mode| **mode).count() > 1 {
bail!("Use one of: a prompt, --show <id>, or --cancel <id>.");
}
if let Some(id) = args.show.as_deref() {
if id.starts_with("cloud_") {
return Err(unknown_job(id));
}
return run_dispatch(AccountDispatch::Status(id.to_string()), profile, config);
}
if let Some(id) = args.cancel.as_deref() {
if id.starts_with("cloud_") {
return Err(unknown_job(id));
}
return run_dispatch(AccountDispatch::Cancel(id.to_string()), profile, config);
}
if args.prompt.is_empty() {
bail!("Give a task to dispatch, or use --show <id> or --cancel <id>.");
}
run_dispatch(
AccountDispatch::Run {
objective: args.prompt.join(" "),
agent: args.agent,
seconds: args.seconds,
context_file: args.context_file,
file_refs: args.file_refs,
operation_key: args.operation_key,
message_id: args.message_id,
yes: args.yes,
confirm_eu_compute: args.confirm_eu_compute,
},
profile,
config,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{Cli, Commands};
use clap::Parser;
fn args(argv: &[&str]) -> DispatchArgs {
let cli = Cli::try_parse_from(argv).unwrap();
let Some(Commands::Dispatch(args)) = cli.command else {
panic!("expected dispatch command");
};
args
}
#[test]
fn parses_the_obvious_dispatch_command() {
let parsed = args(&[
"codewhale",
"dispatch",
"fix",
"the",
"flake",
"--seconds",
"600",
]);
assert_eq!(parsed.prompt, ["fix", "the", "flake"]);
assert_eq!(parsed.seconds, Some(600));
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "github"]).is_err());
}
#[test]
fn rendered_help_carries_no_provider_brand() {
use clap::CommandFactory;
let help = Cli::command()
.find_subcommand_mut("dispatch")
.expect("dispatch subcommand exists")
.render_help()
.to_string();
assert!(help.contains("--show"), "{help}");
assert!(!help.contains("--own-sandbox"), "{help}");
for banned in ["Daytona", "daytona"] {
assert!(
!help.contains(banned),
"--help must not brand the operator: {banned}"
);
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-848
View File
@@ -1,848 +0,0 @@
//! Machine-token contract tests.
//!
//! Every branch the control plane can take is exercised here against the
//! documented `details.code`, because the CLI's whole job in a CI failure is
//! to turn one of those codes into the one sentence that names the fix.
use std::sync::Mutex;
use serde_json::json;
use super::*;
use crate::{CloudResponse, CloudTransport};
const VALID: &str = "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-xQRST";
fn err_body(code: &str, message: &str) -> serde_json::Value {
json!({
"error": "request_failed",
"message": message,
"details": { "code": code },
})
}
fn err_response(status: u16, code: &str, message: &str) -> CloudResponse {
CloudResponse {
status,
body: serde_json::to_vec(&err_body(code, message)).unwrap(),
retry_after: None,
}
}
/// A transport that replays a scripted queue and records every request.
struct ScriptedTransport {
responses: Mutex<Vec<Result<CloudResponse>>>,
seen: Mutex<Vec<(String, Option<String>)>>,
}
impl ScriptedTransport {
fn new(responses: Vec<Result<CloudResponse>>) -> Self {
let mut responses = responses;
responses.reverse();
Self {
responses: Mutex::new(responses),
seen: Mutex::new(Vec::new()),
}
}
fn ok(responses: Vec<CloudResponse>) -> Self {
Self::new(responses.into_iter().map(Ok).collect())
}
fn calls(&self) -> usize {
self.seen.lock().unwrap().len()
}
fn bearers(&self) -> Vec<Option<String>> {
self.seen
.lock()
.unwrap()
.iter()
.map(|(_, bearer)| bearer.clone())
.collect()
}
}
impl CloudTransport for ScriptedTransport {
fn execute(&self, request: crate::CloudRequest) -> Result<CloudResponse> {
self.seen
.lock()
.unwrap()
.push((request.path.clone(), request.bearer.clone()));
self.responses
.lock()
.unwrap()
.pop()
.unwrap_or_else(|| Err(anyhow!("scripted transport exhausted")))
}
}
// -- token format ---------------------------------------------------------
#[test]
fn a_well_formed_token_parses_and_exposes_only_its_non_secret_head() {
let key = MachineKey::parse(VALID).expect("the canonical shape must parse");
assert_eq!(key.head(), "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91");
assert_eq!(key.head().len(), TOKEN_HEAD_LEN);
assert!(key.head().ends_with("3f2a9c1e4b7d8a0f5c6e2b91"));
// The head is the whole key id, not a truncated fingerprint: it is what
// maps a token in a build log to exactly one revocable listing row.
assert!(VALID.starts_with(key.head()));
// Debug is the accident-prone surface: a `{:?}` in a panic must not leak.
let debug = format!("{key:?}");
assert!(
debug.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"),
"{debug}"
);
assert!(!debug.contains(&VALID[TOKEN_HEAD_LEN..]), "{debug}");
}
#[test]
fn ci_whitespace_and_wrapping_quotes_are_stripped_before_matching() {
for raw in [
format!(" {VALID}\n"),
format!("\"{VALID}\""),
format!("'{VALID}'"),
format!(" \" {VALID} \" "),
] {
let key = MachineKey::parse(&raw).unwrap_or_else(|err| {
panic!("secret-paste artifact must be tolerated: {raw:?}: {err}")
});
assert_eq!(key.head(), "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91");
}
}
#[test]
fn a_malformed_token_fails_locally_and_the_message_never_echoes_it() {
let truncated = &VALID[..40];
let cases: Vec<String> = vec![
truncated.to_string(),
format!("{VALID}x"),
VALID.replacen("cwc_key_", "cwc_kex_", 1),
// Uppercase in the id half: the contract pins [0-9a-f].
VALID.replacen("3f2a", "3F2A", 1),
// '+' is base64, not base64url.
format!("{}+{}", &VALID[..74], &VALID[75..]),
"not-a-key".to_string(),
];
for raw in cases {
let err = MachineKey::parse(&raw).expect_err("malformed values must not be sent");
let text = err.to_string();
assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
// Naming the likely cause is the point: a 401 cannot distinguish a
// half-pasted key from a deleted one, but a local check can.
assert!(text.contains("truncated or shell-mangled"), "{text}");
assert!(!text.contains(&raw), "the error echoed the value: {text}");
}
}
#[test]
fn an_exported_but_empty_secret_reads_as_unset_not_as_invalid() {
for raw in [Some(""), Some(" "), None] {
let env = MachineKeyEnv::from_raw(raw);
assert!(!env.is_present(), "{raw:?}");
assert!(env.resolve().unwrap().is_none(), "{raw:?}");
let err = env.require().expect_err("require must fail when unset");
assert!(err.to_string().contains("CODEWHALE_API_KEY"), "{err}");
}
}
#[test]
fn presence_is_independent_of_validity_so_management_can_refuse_a_bad_key() {
let env = MachineKeyEnv::from_raw(Some("cwc_key_truncated"));
assert!(env.is_present());
assert!(env.resolve().is_err());
// A key that is present but unparseable must still trigger the "keys
// cannot manage keys" refusal rather than a format complaint.
let err = reject_machine_key_for_management(&env, false).expect_err("must refuse");
assert!(err.to_string().contains("codewhale login"), "{err}");
}
// -- base URL -------------------------------------------------------------
#[test]
fn base_url_precedence_is_flag_then_machine_env_then_device_env_then_default() {
assert_eq!(
resolve_api_base(
Some("https://flag.example/"),
Some("https://machine.example"),
Some("https://device.example"),
DEFAULT_BASE_FOR_TEST,
),
"https://flag.example"
);
assert_eq!(
resolve_api_base(
None,
Some("https://machine.example/"),
Some("https://device.example"),
DEFAULT_BASE_FOR_TEST,
),
"https://machine.example"
);
assert_eq!(
resolve_api_base(
None,
None,
Some("https://device.example"),
DEFAULT_BASE_FOR_TEST
),
"https://device.example"
);
assert_eq!(
resolve_api_base(None, Some(" "), None, DEFAULT_BASE_FOR_TEST),
DEFAULT_BASE_FOR_TEST
);
}
const DEFAULT_BASE_FOR_TEST: &str = "https://api.codewhale.net";
#[test]
fn plaintext_to_a_remote_host_is_a_hard_error_and_loopback_is_allowed() {
// A warning in CI is a line nobody reads, so this must fail closed.
let err = require_secure_base("http://api.codewhale.net")
.expect_err("cleartext to a remote host must be refused");
let text = format!("{err:#}");
assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
require_secure_base("https://api.codewhale.net").expect("https is fine");
for loopback in [
"http://localhost:8787",
"http://127.0.0.1:8787",
"http://[::1]:8787",
] {
require_secure_base(loopback).unwrap_or_else(|err| panic!("{loopback}: {err:#}"));
}
}
// -- error classification -------------------------------------------------
#[test]
fn every_documented_error_code_maps_to_its_own_actionable_message() {
struct Case {
status: u16,
code: &'static str,
expect: &'static str,
exit: i32,
retryable: bool,
}
let cases = [
Case {
status: 401,
code: "api_key_invalid",
expect: "is not valid",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 401,
code: "api_key_revoked",
expect: "was revoked",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 401,
code: "api_key_expired",
expect: "expired",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 401,
code: "api_key_required",
expect: "Codewhale CLI bug",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 401,
code: "auth_required",
expect: "codewhale login",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 403,
code: "api_key_route_denied",
expect: "cannot be used for this command",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 403,
code: "api_key_scope_denied",
expect: "scope",
exit: EXIT_AUTH,
retryable: false,
},
Case {
status: 409,
code: "account_agent_model_unconfigured",
expect: "no agent model configured",
exit: EXIT_AGENT_UNCONFIGURED,
retryable: false,
},
Case {
status: 409,
code: "api_key_limit_reached",
expect: "25 active API keys",
exit: EXIT_LIMIT,
retryable: false,
},
Case {
status: 400,
code: "api_key_name_invalid",
expect: "server prose",
exit: EXIT_INPUT,
retryable: false,
},
Case {
status: 400,
code: "api_key_expiry_invalid",
expect: "server prose",
exit: EXIT_INPUT,
retryable: false,
},
Case {
status: 400,
code: "api_key_scopes_invalid",
expect: "server prose",
exit: EXIT_INPUT,
retryable: false,
},
Case {
status: 400,
code: "api_key_body_invalid",
expect: "server prose",
exit: EXIT_INPUT,
retryable: false,
},
Case {
status: 404,
code: "api_key_not_found",
expect: "No such Codewhale API key",
exit: EXIT_INPUT,
retryable: false,
},
Case {
status: 503,
code: "api_key_unavailable",
expect: "does not support API keys yet",
exit: EXIT_UNAVAILABLE,
retryable: false,
},
Case {
status: 503,
code: "control_plane_not_attached",
expect: "attached to the account control plane",
exit: EXIT_UNAVAILABLE,
retryable: false,
},
];
for case in cases {
let error = classify(&err_response(case.status, case.code, "server prose"));
assert_eq!(error.code, case.code);
assert_eq!(error.exit_code, case.exit, "exit code for {}", case.code);
assert_eq!(
error.retryable, case.retryable,
"retryability for {}",
case.code
);
assert!(
error.message.contains(case.expect),
"{} message was {:?}",
case.code,
error.message
);
}
}
#[test]
fn a_missing_model_and_a_bad_credential_carry_different_exit_codes() {
// CI logs must tell a configuration problem from a credential problem
// without parsing English.
let config = classify(&err_response(409, "account_agent_model_unconfigured", ""));
let credential = classify(&err_response(401, "api_key_invalid", ""));
assert_ne!(config.exit_code, credential.exit_code);
assert_eq!(config.exit_code, EXIT_AGENT_UNCONFIGURED);
assert_eq!(credential.exit_code, EXIT_AUTH);
}
#[test]
fn the_two_503_codes_do_not_read_as_the_same_problem() {
// They need different fixes from different people.
let unavailable = classify(&err_response(503, "api_key_unavailable", ""));
let detached = classify(&err_response(503, "control_plane_not_attached", ""));
assert_ne!(unavailable.message, detached.message);
assert!(
detached.message.contains("routing/deployment"),
"{}",
detached.message
);
assert!(!unavailable.message.contains("routing/deployment"));
}
#[test]
fn api_key_not_found_does_not_pretend_to_distinguish_the_three_causes() {
let error = classify(&err_response(404, "api_key_not_found", ""));
let text = error.message.to_lowercase();
// Unknown, malformed, and another account's id answer identically, so
// revoke cannot be used to probe for foreign key ids.
for leak in [
"another account",
"belongs to",
"malformed",
"does not exist",
] {
assert!(!text.contains(leak), "{text}");
}
}
#[test]
fn api_key_field_unknown_names_the_fields_the_server_rejected() {
let body = json!({
"error": "bad_request",
"message": "unknown fields in request body",
"details": { "code": "api_key_field_unknown", "fields": ["ttl", "owner"] },
});
let error = classify(&CloudResponse {
status: 400,
body: serde_json::to_vec(&body).unwrap(),
retry_after: None,
});
assert_eq!(error.exit_code, EXIT_INPUT);
assert!(error.message.contains("ttl"), "{}", error.message);
assert!(error.message.contains("owner"), "{}", error.message);
}
#[test]
fn classification_reads_the_code_not_the_status() {
// Three 401s, three different fixes. Only `details.code` separates them.
let invalid = classify(&err_response(401, "api_key_invalid", ""));
let revoked = classify(&err_response(401, "api_key_revoked", ""));
let expired = classify(&err_response(401, "api_key_expired", ""));
assert_ne!(invalid.message, revoked.message);
assert_ne!(revoked.message, expired.message);
assert_ne!(invalid.message, expired.message);
}
#[test]
fn server_prose_cannot_rewrite_the_terminal() {
let error = classify(&err_response(
400,
"api_key_name_invalid",
"bad\u{1b}[2Jname\n\rmore",
));
assert!(!error.message.contains('\u{1b}'), "{}", error.message);
assert!(!error.message.contains('\n'), "{}", error.message);
}
#[test]
fn an_unrecognized_body_still_classifies_by_status() {
let response = CloudResponse {
status: 500,
body: b"<html>oops</html>".to_vec(),
retry_after: None,
};
let error = classify(&response);
assert!(error.retryable);
assert_eq!(error.exit_code, EXIT_TRANSPORT);
assert!(error.message.contains("HTTP 500"), "{}", error.message);
}
// -- retry ----------------------------------------------------------------
#[test]
fn retry_after_wins_over_exponential_backoff_and_is_capped() {
assert_eq!(backoff_delay(1, Some(7)), Duration::from_secs(7));
assert_eq!(backoff_delay(3, Some(2)), Duration::from_secs(2));
// A hostile or broken Retry-After cannot park CI for a day.
assert_eq!(
backoff_delay(1, Some(86_400)),
Duration::from_millis(MAX_BACKOFF_MS)
);
// Without a header the delay still grows.
assert!(backoff_delay(2, None) > backoff_delay(1, None));
}
#[test]
fn a_rate_limited_get_is_retried_and_honors_retry_after() {
let transport = ScriptedTransport::ok(vec![
CloudResponse {
status: 429,
body: serde_json::to_vec(&err_body("rate_limited", "slow down")).unwrap(),
retry_after: Some(3),
},
CloudResponse {
status: 200,
body: serde_json::to_vec(&json!({
"agent": { "configured": true, "modelProvider": "deepseek", "accountId": "user_1" }
}))
.unwrap(),
retry_after: None,
},
]);
let mut slept = Vec::new();
let mut sleeper = |duration: Duration| slept.push(duration);
let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
let agent = client.agent(&mut sleeper).expect("the retry must succeed");
assert!(agent.agent.configured);
assert_eq!(transport.calls(), 2);
assert_eq!(slept, vec![Duration::from_secs(3)]);
}
#[test]
fn a_transport_failure_on_a_get_is_retried_up_to_three_attempts() {
let transport = ScriptedTransport::new(vec![
Err(anyhow!("connection reset")),
Err(anyhow!("connection reset")),
Err(anyhow!("connection reset")),
]);
let mut sleeper = |_: Duration| {};
let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
let err = client
.whoami(&mut sleeper)
.expect_err("exhausted retries must fail");
assert_eq!(transport.calls(), MAX_ATTEMPTS as usize);
let machine_error = err
.downcast_ref::<MachineError>()
.expect("transport failures must carry an exit code");
assert_eq!(machine_error.exit_code, EXIT_TRANSPORT);
}
#[test]
fn a_non_retryable_auth_failure_is_not_retried() {
let transport = ScriptedTransport::ok(vec![err_response(401, "api_key_revoked", "revoked")]);
let mut sleeper = |_: Duration| panic!("a revoked key must never be retried");
let client = MachineClient::new(&transport, MachineKey::parse(VALID).unwrap());
let err = client.whoami(&mut sleeper).expect_err("revoked must fail");
assert_eq!(transport.calls(), 1);
assert_eq!(
err.downcast_ref::<MachineError>().unwrap().exit_code,
EXIT_AUTH
);
}
#[test]
fn create_is_marked_never_retryable_because_a_replay_mints_an_invisible_key() {
// The variant is the guard: a POST that actually succeeded server-side
// would mint a second key whose one-time secret the caller never saw.
assert_ne!(Retry::Never, Retry::Idempotent);
}
// -- request shape --------------------------------------------------------
#[test]
fn exactly_one_credential_reaches_the_wire_and_it_is_the_machine_key() {
let transport = ScriptedTransport::ok(vec![CloudResponse {
status: 200,
body: serde_json::to_vec(&json!({
"account": { "id": "user_1", "displayName": "H", "email": "[email protected]", "region": "us-west", "plan": "free" },
"apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "ci", "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91", "scopes": ["account:read"], "createdAt": "2026-01-01T00:00:00Z" },
"agent": { "configured": true, "modelProvider": "deepseek" }
}))
.unwrap(),
retry_after: None,
}]);
let mut sleeper = |_: Duration| {};
let key = MachineKey::parse(VALID).unwrap();
let client = MachineClient::new(&transport, key);
client.whoami(&mut sleeper).unwrap();
let bearers = transport.bearers();
assert_eq!(bearers.len(), 1);
assert_eq!(bearers[0].as_deref(), Some(VALID));
assert_eq!(
transport.seen.lock().unwrap()[0].0,
"/api/account/api-key/whoami"
);
}
// -- whoami / agent rendering ---------------------------------------------
fn whoami_fixture(configured: bool) -> WhoamiResponse {
let provider = if configured { "deepseek" } else { "" };
serde_json::from_value(json!({
"account": { "id": "user_1", "displayName": "Hunter", "email": "[email protected]", "region": "us-west", "plan": "free" },
"apiKey": {
"id": "3f2a9c1e4b7d8a0f5c6e2b91",
"name": "github-actions",
"displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
"scopes": ["account:read", "agent:run"],
"createdAt": "2026-01-01T00:00:00Z",
"expiresAt": null,
"lastUsedAt": null,
"revokedAt": null
},
"agent": { "configured": configured, "modelProvider": provider }
}))
.unwrap()
}
#[test]
fn whoami_prints_the_account_and_only_the_key_head() {
let mut out = Vec::new();
let key = MachineKey::parse(VALID).unwrap();
write_whoami(
&mut out,
&whoami_fixture(true),
"https://api.codewhale.net",
key.head(),
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("user_1"), "{text}");
assert!(text.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"), "{text}");
assert!(
!text.contains(&VALID[TOKEN_HEAD_LEN..]),
"the secret half leaked: {text}"
);
}
#[test]
fn an_unconfigured_agent_is_a_distinct_line_on_a_successful_whoami() {
// The server answers 200 here: authentication succeeded, and a diagnosis
// surface that failed on unrelated configuration would tell the operator
// nothing about the credential they came to check.
let mut out = Vec::new();
write_whoami(
&mut out,
&whoami_fixture(false),
"https://api.codewhale.net",
"cwc_key_x",
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("user_1"),
"the account must still print: {text}"
);
assert!(text.contains("not configured"), "{text}");
assert!(text.contains("codewhale account keys set"), "{text}");
}
// -- local input validation -----------------------------------------------
#[test]
fn key_names_are_checked_locally_against_the_server_pattern() {
for good in [
"github-actions",
"a",
"CI runner 2",
"team/ci",
"a.b_c:d@e-f",
&"n".repeat(64),
] {
validate_key_name(good).unwrap_or_else(|err| panic!("{good:?}: {err}"));
}
for bad in [
"",
" leading-space",
"-leading-dash",
"bad\nname",
"bad*name",
&"n".repeat(65),
] {
assert!(validate_key_name(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn scopes_are_normalized_against_the_closed_set() {
// Omitting --scope means every scope, sent explicitly rather than left
// to whatever the control plane defaults to.
assert_eq!(
validate_scopes(&[]).unwrap(),
Some(vec![
"account:read".to_string(),
"agent:run".to_string(),
"models:infer".to_string(),
])
);
assert_eq!(
validate_scopes(&["models:infer".into()]).unwrap(),
Some(vec!["models:infer".to_string()])
);
assert_eq!(
validate_scopes(&[
"agent:run".into(),
"account:read".into(),
"agent:run".into()
])
.unwrap(),
Some(vec!["agent:run".to_string(), "account:read".to_string()])
);
let err = validate_scopes(&["billing:write".into()]).expect_err("closed set");
assert!(err.to_string().contains("account:read"), "{err}");
}
#[test]
fn key_ids_are_checked_as_a_paste_check_not_an_existence_check() {
validate_key_id("3f2a9c1e4b7d8a0f5c6e2b91").unwrap();
validate_key_id(" 3f2a9c1e4b7d8a0f5c6e2b91 ").unwrap();
for bad in [
"3f2a",
"3F2A9C1E4B7D8A0F5C6E2B91",
"3f2a9c1e4b7d8a0f5c6e2b9z",
] {
assert!(validate_key_id(bad).is_err(), "{bad}");
}
}
// -- management refusal ---------------------------------------------------
#[test]
fn a_machine_key_alone_cannot_manage_keys_and_is_refused_before_the_wire() {
let env = MachineKeyEnv::from_raw(Some(VALID));
let err = reject_machine_key_for_management(&env, false)
.expect_err("a key must not be able to mint a successor");
let text = err.to_string();
assert!(
text.contains("Managing API keys needs an interactive login."),
"{text}"
);
assert!(text.contains("codewhale login"), "{text}");
// The refusal must explain *why*, not just refuse.
assert!(text.contains("minting a replacement"), "{text}");
assert!(!text.contains(VALID), "the refusal echoed the key: {text}");
}
#[test]
fn a_session_alongside_a_machine_key_still_manages_keys() {
// The session is the credential the management routes accept; having a
// key exported at the same time is not a reason to refuse a human.
let env = MachineKeyEnv::from_raw(Some(VALID));
reject_machine_key_for_management(&env, true).expect("a real login may manage keys");
reject_machine_key_for_management(&MachineKeyEnv::default(), false)
.expect("no key present is not this check's problem");
}
// -- created-key rendering ------------------------------------------------
#[test]
fn a_created_secret_is_printed_once_with_an_unmissable_notice() {
let created: ApiKeyCreateResponse = serde_json::from_value(json!({
"apiKey": {
"id": "3f2a9c1e4b7d8a0f5c6e2b91",
"name": "github-actions",
"displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
"scopes": ["account:read", "agent:run"],
"createdAt": "2026-01-01T00:00:00Z",
"expiresAt": null
},
"secret": VALID,
}))
.unwrap();
let mut out = Vec::new();
write_created_key(&mut out, &created).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(
text.matches(VALID).count(),
1,
"printed more than once: {text}"
);
assert!(
text.contains("ONLY TIME YOU WILL SEE THIS SECRET"),
"{text}"
);
assert!(text.contains("CODEWHALE_API_KEY"), "{text}");
assert!(text.contains("cannot show it again"), "{text}");
}
/// Audit R02-m2: a 2xx with an empty or malformed secret is not reported as
/// a created key.
#[test]
fn a_created_response_without_a_well_formed_secret_is_not_reported_as_created() {
for secret in [json!(null), json!(""), json!("cwc_key_truncated")] {
let mut body = json!({
"apiKey": {
"id": "3f2a9c1e4b7d8a0f5c6e2b91",
"name": "github-actions",
"displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
"scopes": ["account:read"],
"createdAt": "2026-01-01T00:00:00Z"
}
});
if !secret.is_null() {
body["secret"] = secret.clone();
}
let created: ApiKeyCreateResponse = serde_json::from_value(body).unwrap();
let mut out = Vec::new();
let err = write_created_key(&mut out, &created).expect_err("malformed secret");
assert!(out.is_empty(), "printed before validating: {secret}");
let message = err.to_string();
assert!(message.contains("3f2a9c1e4b7d8a0f5c6e2b91"), "{message}");
assert!(message.contains("revoke"), "{message}");
}
}
#[test]
fn a_listing_can_never_carry_a_secret_because_the_type_has_no_field_for_one() {
// The server never returns one; the type makes a regression that started
// returning one impossible to render.
let listing: ApiKeyListResponse = serde_json::from_value(json!({
"apiKeys": [
{ "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "ci", "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
"scopes": ["account:read"], "createdAt": "2026-01-01T00:00:00Z", "secret": VALID },
{ "id": "aaaabbbbccccddddeeeeffff", "name": "old", "displayPrefix": "cwc_key_aaaabbbbccccddddeeeeffff",
"scopes": ["agent:run"], "createdAt": "2025-01-01T00:00:00Z", "revokedAt": "2026-02-02T00:00:00Z" }
]
}))
.unwrap();
let mut out = Vec::new();
write_key_listing(&mut out, &listing.api_keys).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(!text.contains(VALID), "a listing leaked a secret: {text}");
// Revoked keys stay listed so an owner can audit history.
assert!(text.contains("[revoked]"), "{text}");
assert!(text.contains("[active]"), "{text}");
}
#[test]
fn an_empty_listing_names_the_command_that_fixes_it() {
let mut out = Vec::new();
write_key_listing(&mut out, &[]).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("codewhale account api-keys create"), "{text}");
}
// -- review wiring --------------------------------------------------------
#[test]
fn review_resolves_the_route_from_the_accounts_configured_provider() {
let agent: AgentState = serde_json::from_value(
json!({ "configured": true, "modelProvider": "deepseek", "accountId": "user_1" }),
)
.unwrap();
let provider = review_provider_from_agent(&agent).expect("deepseek is a known route");
assert_eq!(provider, ProviderKind::Deepseek);
}
#[test]
fn review_refuses_with_the_configuration_exit_code_when_no_model_is_set() {
let agent = AgentState {
configured: false,
model_provider: String::new(),
account_id: None,
};
let err = review_provider_from_agent(&agent).expect_err("machine work needs a model");
let machine_error = err.downcast_ref::<MachineError>().expect("typed error");
assert_eq!(machine_error.exit_code, EXIT_AGENT_UNCONFIGURED);
assert_ne!(machine_error.exit_code, EXIT_AUTH);
assert!(
machine_error.message.contains("codewhale account keys set"),
"{machine_error}"
);
}
#[test]
fn an_unknown_provider_identifier_asks_for_an_upgrade_rather_than_guessing() {
let agent = AgentState {
configured: true,
model_provider: "a-provider-from-the-future".to_string(),
account_id: None,
};
let err = review_provider_from_agent(&agent).expect_err("unknown routes must not be guessed");
assert!(err.to_string().contains("Upgrade `codewhale`"), "{err}");
}
-688
View File
@@ -1,688 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use codewhale_secrets::account::{AccountDeviceAccess, AccountSessionState, RuntimeAccountInfo};
use super::*;
pub fn account_api_base(api_base: Option<&str>) -> Result<String> {
let requested_base = machine::resolve_api_base(
api_base,
std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
DEFAULT_API_BASE,
);
Ok(validate_api_base(&requested_base)?.display)
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountStatus {
pub profile: String,
pub api_base: String,
pub account: RuntimeAccountInfo,
pub user: Option<CloudUser>,
pub revision: String,
pub import_allowed: bool,
}
pub struct AccountAuthLease {
pub bundle: AuthBundle,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModel {
pub id: String,
pub label: String,
pub provider: String,
pub model: String,
pub protocol: String,
pub billing_mode: String,
pub is_default: bool,
}
pub struct AccountModels {
pub api_base: String,
pub account_id: String,
pub session_id: String,
pub expires_at: i64,
pub default_model: Option<String>,
pub models: Vec<AccountModel>,
pub credential: Option<String>,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone)]
pub struct AccountClient {
transport: ReqwestTransport,
store: AccountSessionStore,
profile: String,
api_base: String,
}
impl AccountClient {
pub fn new(profile: Option<&str>, api_base: Option<&str>) -> Result<Self> {
let requested_base = account_api_base(api_base)?;
let base = validate_api_base(&requested_base)?;
let profile = normalized_profile(profile);
let secrets = cloud_session_secrets()?;
Ok(Self {
transport: ReqwestTransport::new(base.url)?,
store: AccountSessionStore::new(secrets, Some(&profile), &base.display),
profile,
api_base: base.display,
})
}
fn client(&self) -> CloudClient<'_, ReqwestTransport> {
CloudClient {
transport: &self.transport,
account_store: self.store.clone(),
}
}
fn revoke_session_later(&self, previous: Option<AccountSessionSnapshot>, bundle: AuthBundle) {
let client = self.clone();
let _ = std::thread::Builder::new()
.name("cw-account-revoke".into())
.spawn(move || {
let _ = match previous {
Some(previous) => client.client().revoke_previous_session(&previous, &bundle),
None => client.client().revoke_session_bundle(&bundle),
};
});
}
pub fn snapshot(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.snapshot()?)
}
pub fn status(&self) -> Result<AccountStatus> {
let snapshot = self.snapshot()?;
let user = snapshot.load()?.and_then(|stored| stored.bundle.user);
Ok(AccountStatus {
profile: self.profile.clone(),
api_base: self.api_base.clone(),
account: snapshot.runtime_info_at(Utc::now())?,
user,
revision: snapshot.fingerprint(),
import_allowed: snapshot.is_pristine(),
})
}
pub fn claim(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.begin_login()?)
}
pub fn claim_if_unchanged(
&self,
expected_revision: &str,
) -> Result<Option<AccountSessionSnapshot>> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(None);
}
Ok(Some(transaction.renew_revision()?))
})
}
pub fn cancel_claim(&self, expected_revision: &str) -> Result<bool> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(false);
}
transaction.renew_revision()?;
Ok(true)
})
}
pub fn import(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<bool> {
self.import_with_receipt(bundle, expected_revision, replace_existing)
.map(|receipt| receipt.is_some())
}
pub fn import_with_receipt(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<Option<AccountSessionSnapshot>> {
validate_auth_bundle(&bundle)?;
let expected = self.snapshot()?;
if expected.fingerprint() != expected_revision {
return Ok(None);
}
if !replace_existing && !expected.is_pristine() {
return Ok(None);
}
let Some(committed) = self.store.save_if_unchanged(&expected, bundle.clone())? else {
return Ok(None);
};
self.revoke_session_later(Some(expected), bundle);
Ok(Some(committed))
}
pub fn fresh_auth(&self, rejected_token: Option<&str>) -> Result<Option<AccountAuthLease>> {
let snapshot = self.snapshot()?;
let Some(stored) = snapshot.load()? else {
return Ok(None);
};
if matches!(
snapshot.runtime_info_at(Utc::now())?.state,
AccountSessionState::Expired | AccountSessionState::Revoked
) {
return Ok(None);
}
let refresh = rejected_token == Some(stored.bundle.access_token.as_str())
|| !stored
.bundle
.session
.as_ref()
.and_then(|session| DateTime::parse_from_rfc3339(&session.expires_at).ok())
.is_some_and(|expires| expires.timestamp() > Utc::now().timestamp() + 120);
let (bundle, snapshot) = if refresh {
let Some(renewed) = self.client().refresh_snapshot(&snapshot)? else {
return Ok(None);
};
renewed
} else {
(stored.bundle, snapshot)
};
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&snapshot))
})?;
Ok(current.then_some(AccountAuthLease { bundle, snapshot }))
}
pub fn logout(&self) -> Result<bool> {
self.client().logout()
}
pub fn logout_if_unchanged(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
self.client().logout_from_snapshot(snapshot)
}
pub fn connect_models(&self) -> Result<AccountModels> {
self.connect_models_with_cancel(&AtomicBool::new(false))
}
pub fn connect_models_with_cancel(&self, cancelled: &AtomicBool) -> Result<AccountModels> {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let lease = self
.fresh_auth(None)?
.ok_or_else(|| anyhow!("Sign in to Codewhale before connecting account models"))?;
let (response, snapshot) = self.client().execute_from_snapshot(
HttpMethod::Get,
"/api/account/model-api",
None,
lease.snapshot,
)?;
let catalog: serde_json::Value = expect_json(response, &[200])?;
let data = catalog
.get("data")
.and_then(serde_json::Value::as_array)
.filter(|rows| rows.len() <= 8192)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))?;
let mut models = Vec::new();
for row in data {
let facts = &row["codewhale"];
if facts["usable"].as_bool() != Some(true) {
continue;
}
if facts["protocol"].as_str() != Some("chat-completions")
|| facts["billingMode"].as_str() != Some("byok_external")
{
continue;
}
let id = model_text(row, "id")?;
let provider = model_text(facts, "provider")?;
let model = model_text(facts, "model")?;
if id != format!("{provider}/{model}") {
bail!("The Codewhale account model catalog was invalid");
}
models.push(AccountModel {
id,
label: facts["label"]
.as_str()
.map(|label| printable_max(label, 200))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| model.clone()),
provider,
model,
protocol: model_text(facts, "protocol")?,
billing_mode: model_text(facts, "billingMode")?,
is_default: facts["default"].as_bool() == Some(true),
});
}
let stored = snapshot
.load()?
.ok_or_else(|| anyhow!("The signed-in account changed. Refresh and try again."))?;
let account_id = stored
.bundle
.user
.as_ref()
.map(|user| user.id.clone())
.filter(|id| !id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session = stored
.bundle
.session
.as_ref()
.filter(|session| !session.id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session_id = session.id.clone();
let access_expires = DateTime::parse_from_rfc3339(&session.expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let refresh_expires = DateTime::parse_from_rfc3339(&session.refresh_expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let default_model = catalog["codewhale"]["defaultModel"]
.as_str()
.filter(|id| models.iter().any(|model| model.id == *id))
.map(str::to_string)
.or_else(|| {
models
.iter()
.find(|model| model.is_default)
.map(|model| model.id.clone())
});
let mut result = AccountModels {
api_base: format!("{}/v1", self.api_base),
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at: 0,
default_model,
models,
credential: None,
snapshot,
};
if result.models.is_empty() {
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&result.snapshot))
})?;
if !current {
bail!("The signed-in account changed. Refresh and try again.");
}
return Ok(result);
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let device_id = self.store.device_id()?;
let mut issued_id: Option<String> = None;
let connected = self.store.with_transaction(|transaction| {
if !transaction.matches(&result.snapshot) || access_expires <= Utc::now().timestamp() {
bail!("The signed-in account changed. Refresh and try again.");
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let current = transaction.snapshot();
if let Some(access) = current.device_access()? {
if access.device_id != device_id {
bail!("The Codewhale device connection changed. Reconnect your account.");
}
machine::MachineKey::parse(&access.credential)?;
if access.expires_at > Utc::now().timestamp() {
let checked = self.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/v1/models".into(),
bearer: Some(access.credential.clone()),
body: None,
})?;
match checked.status {
200 => return Ok((access, current)),
401 => {}
_ => return Err(response_error(&checked)),
}
}
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/account/device-link".into(),
bearer: Some(stored.bundle.access_token.clone()),
body: Some(serde_json::to_vec(&serde_json::json!({
"deviceId": device_id,
"deviceName": "Codewhale",
"expectedAccountId": account_id,
}))?),
})?;
let linked: LinkedDevice = expect_json(response, &[201])?;
if linked.api_key.id.len() == 24
&& linked
.api_key
.id
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
issued_id = Some(linked.api_key.id.clone());
}
let key = machine::MachineKey::parse(&linked.secret)?;
let expires_at = DateTime::parse_from_rfc3339(&linked.api_key.expires_at)
.context("The account device expiry was invalid")?
.timestamp();
if !linked.session_bound
|| linked.api_key.scopes.len() != 1
|| linked.api_key.scopes[0] != "models:infer"
|| linked.api_key.revoked_at.is_some()
|| linked.api_key.context.kind != "device"
|| linked.api_key.context.device_id != device_id
|| linked.api_key.context.session_id != session_id
|| key.head().strip_prefix("cwc_key_") != Some(linked.api_key.id.as_str())
|| expires_at <= Utc::now().timestamp()
|| expires_at > refresh_expires
{
bail!("The Codewhale device connection could not be verified");
}
let access = AccountDeviceAccess {
device_id: device_id.clone(),
credential: linked.secret,
api_key_id: linked.api_key.id,
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at,
};
transaction.set_device_access(access.clone())?;
Ok((access, transaction.snapshot()))
});
let (access, snapshot) = match connected {
Ok(connected) => connected,
Err(error) => {
if let Some(id) = issued_id {
let _ = self.transport.execute(CloudRequest {
method: HttpMethod::Delete,
path: format!("/api/account/api-keys/{id}"),
bearer: Some(stored.bundle.access_token),
body: None,
});
}
return Err(error);
}
};
result.expires_at = access.expires_at.min(access_expires);
result.credential = Some(access.credential);
result.snapshot = snapshot;
Ok(result)
}
pub fn start_login(&self, timeout: Duration) -> Result<AccountLogin> {
self.start_login_with_cancel(timeout, Arc::new(AtomicBool::new(false)))
}
pub fn start_login_with_cancel(
&self,
timeout: Duration,
cancelled: Arc<AtomicBool>,
) -> Result<AccountLogin> {
if timeout.is_zero() || timeout > Duration::from_secs(MAX_LOGIN_TIMEOUT_SECONDS) {
bail!("Invalid Codewhale account login timeout");
}
let expected = self.store.with_transaction(|transaction| {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
Ok::<_, anyhow::Error>(transaction.renew_revision()?)
})?;
let started = Instant::now();
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
let device = self.client().start_device()?;
validate_device_code(&device.device_code)?;
validate_user_code(&device.user_code)?;
let verification_uri = validate_verification_url(
&device.verification_uri,
&self.api_base,
&device.user_code,
false,
)?;
let verification_uri_complete = validate_verification_url(
&device.verification_uri_complete,
&self.api_base,
&device.user_code,
true,
)?;
let lifetime = timeout.min(Duration::from_secs(
device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS),
));
let interval = if device.interval == 0 {
codewhale_config::device_code::DEFAULT_POLL_INTERVAL_SECS
} else {
device.interval.min(10)
};
Ok(AccountLogin {
client: self.clone(),
expected,
prompt: AccountLoginPrompt {
user_code: device.user_code.clone(),
verification_uri,
verification_uri_complete,
expires_in: lifetime.as_secs(),
interval,
},
device,
cancelled: AccountLoginCancellation(cancelled),
deadline: started + lifetime,
next_poll: started,
pending_bundle: None,
finished: false,
})
}
}
fn model_text(value: &serde_json::Value, key: &str) -> Result<String> {
value[key]
.as_str()
.filter(|text| {
!text.is_empty()
&& text.len() <= 512
&& !text.chars().any(char::is_control)
&& !text.chars().any(char::is_whitespace)
})
.map(str::to_string)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDevice {
secret: String,
api_key: LinkedDeviceKey,
session_bound: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceKey {
id: String,
scopes: Vec<String>,
expires_at: String,
revoked_at: Option<String>,
context: LinkedDeviceContext,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceContext {
kind: String,
device_id: String,
session_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountLoginPrompt {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Clone)]
pub struct AccountLoginCancellation(Arc<AtomicBool>);
impl AccountLoginCancellation {
pub fn cancel(&self) {
self.0.store(true, Ordering::Release);
}
}
pub enum AccountLoginPoll {
Pending { retry_after: Duration },
Complete { user: CloudUser },
Cancelled,
Expired,
}
pub struct AccountLogin {
client: AccountClient,
expected: AccountSessionSnapshot,
device: DeviceStart,
prompt: AccountLoginPrompt,
cancelled: AccountLoginCancellation,
deadline: Instant,
next_poll: Instant,
pending_bundle: Option<AuthBundle>,
finished: bool,
}
impl AccountLogin {
pub fn prompt(&self) -> &AccountLoginPrompt {
&self.prompt
}
pub fn cancellation(&self) -> AccountLoginCancellation {
self.cancelled.clone()
}
pub fn poll(&mut self) -> Result<AccountLoginPoll> {
let result = self.poll_once();
if !matches!(result, Ok(AccountLoginPoll::Pending { .. })) {
self.finished = true;
}
if matches!(
result,
Ok(AccountLoginPoll::Cancelled | AccountLoginPoll::Expired)
) {
self.revoke_pending();
}
result
}
fn revoke_pending(&mut self) {
if let Some(bundle) = self.pending_bundle.take() {
self.client.revoke_session_later(None, bundle);
}
}
fn poll_once(&mut self) -> Result<AccountLoginPoll> {
if self.finished || self.cancelled.0.load(Ordering::Acquire) {
return Ok(AccountLoginPoll::Cancelled);
}
let now = Instant::now();
if now >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
if now < self.next_poll {
return Ok(AccountLoginPoll::Pending {
retry_after: (self.next_poll - now).min(self.deadline - now),
});
}
if !self.client.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&self.expected))
})? {
return Ok(AccountLoginPoll::Cancelled);
}
if let Some(bundle) = self.pending_bundle.as_mut() {
if bundle.user.is_none() {
let response = self.client.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/api/me".into(),
bearer: Some(bundle.access_token.clone()),
body: None,
})?;
bundle.user = Some(expect_json::<MeResponse>(response, &[200])?.user);
}
} else {
match self.client.client().poll_device_once(&self.device)? {
DevicePollOutcome::Complete(bundle) => self.pending_bundle = Some(bundle),
DevicePollOutcome::Pending | DevicePollOutcome::SlowDown { .. } => {
self.next_poll = Instant::now() + Duration::from_secs(self.prompt.interval);
return Ok(AccountLoginPoll::Pending {
retry_after: self
.next_poll
.min(self.deadline)
.saturating_duration_since(Instant::now()),
});
}
}
}
let bundle = self
.pending_bundle
.as_ref()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
if bundle.user.is_none() {
return Ok(AccountLoginPoll::Pending {
retry_after: Duration::ZERO,
});
}
let Some(user) = bundle
.user
.as_ref()
.filter(|user| !user.id.trim().is_empty())
else {
bail!("The Codewhale service returned an account without an ID");
};
let user = user.clone();
validate_auth_bundle(bundle)?;
if Instant::now() >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
let bundle = self
.pending_bundle
.take()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
let committed = self.client.store.with_transaction(|transaction| {
if self.cancelled.0.load(Ordering::Acquire)
|| !transaction.matches(&self.expected)
|| Instant::now() >= self.deadline
{
return Ok::<_, anyhow::Error>(false);
}
transaction.replace(bundle.clone())?;
Ok(true)
})?;
if !committed {
self.client.revoke_session_later(None, bundle);
return Ok(AccountLoginPoll::Cancelled);
}
self.client
.revoke_session_later(Some(self.expected.clone()), bundle);
Ok(AccountLoginPoll::Complete { user })
}
}
impl Drop for AccountLogin {
fn drop(&mut self) {
if !self.finished {
self.cancelled.cancel();
}
self.revoke_pending();
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "codewhale-agent-contract"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Fleet role, child authority and worker runtime profile contract shared by the engine and its clients"
[lints]
workspace = true
[features]
test-support = []
[dependencies]
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
serde = { workspace = true, features = ["derive"] }
-2
View File
@@ -1,2 +0,0 @@
pub mod role;
pub mod worker_profile;
-830
View File
@@ -1,830 +0,0 @@
//! Fleet roles — the lightweight role surface for exec and sub-agent spawning.
//!
//! Learned from the OMP sub-agent role system: an agent type is a **name**
//! plus a small static posture (tool allowlist, model, reasoning level), and
//! exec consumes exactly that — never the roster, ledger, or worker
//! machinery. The durable Fleet runs (manager / executor / worker_runtime /
//! exact workflow driver) remain the one consumer of the heavy machinery and
//! resolve roles through this same surface, so both paths share one posture.
//!
//! What lives here (and only here) for spawn-time decisions:
//! - [`FleetRole`]: the closed 8-role set, parsing, and canonical labels.
//! - Per-role posture: [`role_requires_read_only_shell`],
//! [`effective_runtime_profile_for_role`], [`fleet_effective_permissions`].
//! - The tool deny lists + [`ChildAuthority`]: how a
//! role posture becomes the concrete child surface (allowlist, deny list,
//! write authority, delegation budget, fingerprint).
//!
//! Deliberately dependency-light: protocol + workflow ceiling types,
//! [`crate::worker_profile`], std. Nothing from fleet control / store /
//! ledger / executor / worker_runtime / roster / profile / identity /
//! members, and nothing from tools.
use codewhale_protocol::fleet::{FleetEffectivePermissions, migrate_legacy_role_token};
use codewhale_protocol::fleet::{PermissionCeiling, ShellCeiling};
use crate::worker_profile::{ShellPolicy, ToolScope, WorkerRuntimeProfile};
/// Canonical model-facing Fleet role values, in schema order. This is the
/// closed `enum` advertised on the Agent tool's `type` property. Legacy
/// aliases are accepted only at replay/deserialization boundaries
/// ([`migrate_legacy_role_token`]) and are never advertised to models.
pub const FLEET_ROLE_SCHEMA_VALUES: [&str; 8] = [
"general",
"explore",
"planner",
"reviewer",
"implement",
"test",
"advisor",
"custom",
];
/// Human-readable hint listing every token [`FleetRole::parse`] accepts,
/// for spawn-time error messages. Keep in sync with
/// [`migrate_legacy_role_token`] and the `from_str` match arms; those two are
/// the only role parser (#2649 was a second table in the spawn tool drifting
/// from this set).
pub const VALID_ROLE_ALIASES: &str = "general; explore; planner; reviewer; implement; test; advisor; custom \
(legacy aliases remain accepted: worker; scout; builder; verifier; consultant; default; general-purpose; general_purpose; exploration; explorer; plan; planning; awaiter; review; code-review; code_review; implementer; implementation; verify; verification; validator; tester; oracle)";
/// Canonical Fleet role for a delegated worker, with specialized behavior
/// and tool access per role.
///
/// **Public vocabulary is Fleet roles** (`general`, `explore`, `planner`,
/// `reviewer`, `implement`, `test`, `advisor`, `custom`) and the variants match that
/// vocabulary one-to-one. Serialization, prompts, receipts, and UI always
/// use [`Self::as_str`]. Legacy wire spellings (`worker`, `scout`, `plan`,
/// `review`, `implementer`, …) are accepted only through
/// [`migrate_legacy_role_token`] at deserialization / parse boundaries.
///
/// This is the closed runtime role set. It is distinct from
/// `codewhale_config::FleetRole`, which is the open config-side role
/// *declaration* (free-form name plus instruction overlay) carried by a
/// Fleet profile. The `FleetRole` type name remains a compatibility identifier.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FleetRole {
/// General-purpose worker - full tool access for multi-step tasks.
#[default]
Worker,
/// Fast exploration - read-only tools for codebase search.
Scout,
/// Planning — grounded strategy. Reads the workspace and the web and
/// may run classifier-bounded shell probes; never mutates.
Planner,
/// Code review - read + analysis tools.
Reviewer,
/// Implementation — focused on writing / patching code to satisfy
/// a specific change. Distinct from `Worker` in that the prompt
/// posture pushes hard on landing the change cleanly with the
/// minimum surrounding edit (#404).
Builder,
/// Verification — focused on running the test suite or other
/// validation gates and reporting pass/fail with evidence.
/// Distinct from `Reviewer` in that Reviewer reads code and grades it;
/// Verifier *runs* tests and reports the outcome (#404).
Verifier,
/// Advisory counsel — a strong-model second opinion the operator can ask
/// for guidance, judgement calls, and design critique (#4752).
///
/// Read-only and shell-less by construction: a Consultant reasons about the
/// code (and may read the web to ground that counsel) and says what it
/// thinks. It is distinct from `Reviewer`, which grades a specific change
/// against a standard, and from `Planner`, which produces a plan to execute.
/// A Consultant answers "what should we do here, and what are we not seeing".
Consultant,
/// Custom tool access defined at spawn time. Inherits the parent's
/// write/network/shell ceiling and is narrowed by the explicit tool list
/// or an explicit write_authority, never by a silent lock-down.
Custom,
}
impl serde::Serialize for FleetRole {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for FleetRole {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::parse(&raw)
.ok_or_else(|| serde::de::Error::unknown_variant(&raw, &FLEET_ROLE_SCHEMA_VALUES))
}
}
impl FleetRole {
/// Parse a Fleet role from user input or a serialized boundary.
///
/// Accepts Fleet role names and, at this parse boundary only, legacy
/// aliases (`scout` → explore, `plan` → planner, …).
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
let normalized = s.trim().to_ascii_lowercase();
// Boundary migration first, then canonical Fleet names.
let token = migrate_legacy_role_token(&normalized).unwrap_or(normalized.as_str());
match token {
"general" => Some(Self::Worker),
"explore" => Some(Self::Scout),
"planner" => Some(Self::Planner),
"reviewer" => Some(Self::Reviewer),
"implement" => Some(Self::Builder),
"test" => Some(Self::Verifier),
"advisor" => Some(Self::Consultant),
"custom" => Some(Self::Custom),
_ => None,
}
}
/// Canonical Fleet role label for runtime, schemas, prompts, receipts, UI.
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Worker => "general",
Self::Scout => "explore",
Self::Planner => "planner",
Self::Reviewer => "reviewer",
Self::Builder => "implement",
Self::Verifier => "test",
Self::Consultant => "advisor",
Self::Custom => "custom",
}
}
/// One-line model-facing description of what this role does. Backs the
/// role catalog returned by the agent roster action.
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Worker => "General-purpose worker with full tool access for multi-step tasks.",
Self::Scout => "Fast read-only exploration for codebase search and analysis.",
Self::Planner => {
"Grounded strategy: reads the workspace and the web, runs read-only probes, never mutates."
}
Self::Reviewer => "Reads and grades code against a standard.",
Self::Builder => {
"Lands focused code changes cleanly with the minimum surrounding edit."
}
Self::Verifier => {
"Runs the test suite and validation gates, reports pass/fail with evidence."
}
Self::Consultant => {
"Read-only high-reasoning counsel for judgement calls and design critique."
}
Self::Custom => "Custom tool access defined at spawn time by the parent's posture.",
}
}
/// All canonical roles in schema order, for catalog responses.
#[must_use]
pub fn all() -> [Self; 8] {
[
Self::Worker,
Self::Scout,
Self::Planner,
Self::Reviewer,
Self::Builder,
Self::Verifier,
Self::Consultant,
Self::Custom,
]
}
}
/// Public label for any role token (canonical or legacy alias).
/// Canonical/legacy tokens collapse to the advertised name;
/// anything else passes through trimmed.
#[must_use]
pub fn public_role_label(token: &str) -> String {
FleetRole::parse(token).map_or_else(
|| token.trim().to_string(),
|role| role.as_str().to_string(),
)
}
// ── Child authority: the ceiling, as the child actually experiences it ───────
/// Tool names that give a model its own reach onto the network.
///
/// `network_tool = false` must remove **all** of them from the child's
/// model-visible surface, not merely block them at call time — a model that can
/// see a tool will try it, and a refusal is a worse experience than an absent
/// capability. The child registry hides denied tools from
/// `tools_for_model` and refuses them in `is_tool_allowed`, so one deny list
/// covers both.
///
/// The `mcp*` wildcards are load-bearing: a remote MCP server's tools arrive
/// under a runtime-generated name, so they cannot be enumerated here and must be
/// matched by prefix. `is_tool_denied` supports `prefix*` globs for exactly this.
pub const NETWORK_TOOL_DENYLIST: &[&str] = &[
// Web search / fetch / browse, and the canonical family that fronts them.
//
// The `Web` family name itself is deliberately NOT denied. Its `search`
// and `fetch` actions are the read-only web surface a network-denied
// member is entitled to (parity with an ordinary scout), and the family
// is classified read-only at the capability envelope, so removing the
// *name* from this list grants exactly those two actions and nothing
// else. What this list removes is every other spelling of the browsing
// surface: the separate `web.run` browse tool, the legacy `web_search` /
// `fetch_url` / `wait_for_dev_server` action aliases, and the `web_*` /
// `web.*` name families, so a deny list that stops at `Web` can never
// leave `web.run` visible and callable, which is the entire browsing
// capability by another spelling. The explicit names are kept because
// they document intent and because two of them (`fetch_url`,
// `wait_for_dev_server`) are not matched by either glob.
//
// The captured ChildGrant action ceiling enforced by Core's tool registry
// lets a network-denied child keep exactly `Web{search, fetch}` past the
// denied aliases, and the URL-input guard refuses a URL-addressed
// `fetch` at dispatch, so the reach stays closed.
"web_*",
"web.*",
"web.run",
"web_run",
"web_search",
"web.fetch",
"web_fetch",
"fetch_url",
"wait_for_dev_server",
"browse",
"browser",
// Networked service tools.
"github",
"finance",
// The RLM session family's two reaching actions.
//
// `rlm_open` accepts a `url` and fetches it by calling `FetchUrlTool`
// *in-process*, under its own name — so denying `fetch_url` never sees the
// call. `rlm_eval` runs operator-supplied Python against a live kernel,
// which owns a socket API no inspection of the *call* can bound.
//
// Both are denied outright rather than gated on the input. The narrower
// contract was considered and rejected: `rlm_open` chooses its source from
// *input fields* (`file_path` / `content` / `url` / `session_object`), not
// from the action name, and the action-policy seam
// (`codewhale_execpolicy::canonical_action`) resolves names, not field shapes —
// it cannot prove a source is local before execution. So this fails closed.
// A network-denied member loses `rlm` loading and evaluation entirely,
// including the purely local `file_path` form, and keeps only the bounded
// metadata actions (`session_objects` / `configure` / `close`), which the
// per-action alias entries make expressible. See `docs/FLEET.md`.
"rlm_open",
"rlm_eval",
// Every MCP surface, including remote servers registered at runtime.
"mcp*",
"start_mcp_server",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
];
/// The deny-list entry that stands for "this child has no network".
///
/// The deny list is how `network_tool = false` reaches a child across the
/// durable-Fleet and `codewhale exec` boundaries (through
/// `worker_profile.denied_tools` / `context.disallowed_tools`). `fetch_url` is
/// the sentinel because every network denial installs it and no narrower deny
/// list does — the `web_*` / `web.*` globs deliberately do not match it,
/// which is why it is spelled out above. The in-process child reads its
/// network axis off the resolved grant, which folds this sentinel in at
/// resolve time — so the transport denial and the semantic answer can never
/// disagree.
pub const NETWORK_DENIAL_SENTINEL: &str = "fetch_url";
/// Tool names that mutate the workspace directly.
///
/// A member whose clamped ceiling says `write = false` must not merely be
/// *labelled* read-only — the mutating tools have to be gone from the surface
/// it can see and call. Only the action aliases are listed, never the `File`
/// family itself: denying `File` would take `read`/`list`/`search` with it, and
/// the registry already resolves `File{action:"write"}` through the alias table
/// to `write_file`, so denying the alias covers both spellings.
///
/// `rlm_eval` is here for the same reason it is on the network list and not for
/// a different one: the Python it runs against a live kernel calls `open(...,
/// "w")` as readily as it opens a socket. It is a mutation primitive that
/// happens to be spelled as an analysis tool, and leaving it on a `write =
/// false` surface would let a read-only member rewrite the workspace while the
/// receipt said otherwise. The rest of the family — including the local
/// `file_path` load — survives a write denial, because reading a large file
/// into a kernel is exactly what a read-only member is for.
pub const MUTATING_TOOL_DENYLIST: &[&str] = &[
"write_file",
"edit_file",
"apply_patch",
"fim_edit",
"revert_turn",
"rlm_eval",
];
/// The raw shell surface — arbitrary operator-supplied commands.
///
/// A read-only member with `shell = "full"` is the honest-labelling problem
/// this list exists for. `full` was saved so the member could *run checks*, but
/// raw shell is a general mutation primitive: `rm`, `git checkout`, or a `>`
/// redirect writes the workspace just as surely as `write_file`, while the
/// receipt says `write=false`. Denying the raw shell entries and leaving the
/// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact
/// keeps the verifier able to do its job under a contract that is true.
/// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this
/// deny list after the role is known; its input-specific read-only classifier
/// remains the authority for that narrow exception.
///
/// That surface is bounded only in its **default** form, and the distinction is
/// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary
/// `program` + `args` pairs, and `run_tests` accepts a raw `args` string. Either
/// one is a general command primitive by another name — `{"program": "bash",
/// "args": ["-lc", "..."]}` is precisely the raw shell this list just removed.
/// Denying the tools outright would take the verifier's whole purpose with
/// them, so the *unbounded arguments* are refused at the execution seam
/// instead; see `reject_unbounded_verification` in
/// `crate::tools::subagent`. The name deny list and that guard are one
/// contract split across the only two places that can each see half of it.
pub const RAW_SHELL_DENYLIST: &[&str] = &[
"Bash",
"exec_shell",
"exec_shell_wait",
"exec_wait",
"exec_shell_interact",
"exec_interact",
"exec_shell_cancel",
"task_shell_start",
"task_shell_wait",
// The persistent PTY surface registers as `terminal/run`, `terminal/send`,
// … — a glob, because the family is open-ended and every member of it is a
// raw command channel.
"terminal/*",
];
/// The deny-list entry that stands for "this child has no raw shell".
///
/// Same construction as [`NETWORK_DENIAL_SENTINEL`], and for the same reason:
/// posture is read back off the list that enforces it rather than carried as a
/// second field that could disagree. `exec_shell` is the sentinel because every
/// raw-shell denial installs it and no narrower deny list does.
///
/// Read by the tests that assert the raw-shell denial actually landed. It is
/// deliberately *not* what decides a child's shell authority — that question
/// is answered by the resolved grant's `shell` axis, which distinguishes
/// "no process surface" from "bounded verification surface" by grant field
/// rather than by a sentinel name.
#[cfg(any(test, feature = "test-support"))]
pub const RAW_SHELL_SENTINEL: &str = "exec_shell";
/// The built-in verification surface: the workspace's own configured checks.
///
/// Bounded in its arguments (see `crate::tools::execution_envelope`) but not
/// free of consequence — every entry forks a process. A member whose shell
/// ceiling is narrower than `full` holds no authority to start one, so this
/// list comes off its surface entirely. A `write = false, shell = "full"`
/// member keeps it, because running the checks is what that preset is for.
pub const VERIFICATION_SURFACE_DENYLIST: &[&str] = &["Run", "run_tests", "run_verifiers"];
/// The deny-list entry that stands for "this child holds no shell authority".
///
/// Distinct from `RAW_SHELL_SENTINEL`, and the distinction is the point.
/// `exec_shell` is installed whenever the *raw* shell is removed, which
/// includes the write-denied verifier that still holds shell authority — so
/// reading shell authority off it would take the verification surface away
/// from the one role that exists to use it. `run_tests` is installed only
/// when the shell *ceiling* itself is narrower than `full`, which is exactly
/// the posture that has no authority to start a process. The grant folds
/// this sentinel into its `shell` axis at resolve time: `Verify`/`Full`
/// collapse to `None`, while `Inspect` — classifier-bounded evidence reads,
/// not process-start authority — survives.
pub const SHELL_AUTHORITY_SENTINEL: &str = "run_tests";
/// Execution primitives that are **not** spelled as shell.
///
/// Every entry runs an operator-supplied program or schedules one: `gate_run`
/// takes a command line, the mutating `automation` actions execute or schedule
/// a stored automation with its own cwd and prompt, `start_mcp_server` spawns a
/// process, and `pr_attempt_*` writes durable work state. They are listed here
/// so a write-denied child never *sees* them; the authoritative refusal is
/// capability-derived and lives in `crate::tools::execution_envelope`, which
/// also covers the ones no list can name — repository plugin tools and MCP
/// server tools registered at runtime.
///
/// Listing the per-action alias rather than the family is deliberate and is
/// what the canonical-action seam exists for: denying `tasks` outright would
/// take `list`/`read` with it, and durable-task bookkeeping is exactly what a
/// read-only member should keep.
pub const NON_SHELL_EXECUTION_DENYLIST: &[&str] = &[
"task_gate_run",
"task_create",
"task_cancel",
"pr_attempt_record",
"pr_attempt_preflight",
"automation_run",
"automation_create",
"automation_update",
"automation_pause",
"automation_resume",
"automation_delete",
"start_mcp_server",
];
/// Whether a deny rule belongs to an enforced role posture. Operator and
/// ancestor denials are also immutable; this identifies posture rules for
/// authority diagnostics and tests, not a child opt-out exception.
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub fn is_posture_denial(rule: &str) -> bool {
[
NETWORK_TOOL_DENYLIST,
MUTATING_TOOL_DENYLIST,
RAW_SHELL_DENYLIST,
VERIFICATION_SURFACE_DENYLIST,
NON_SHELL_EXECUTION_DENYLIST,
]
.iter()
.flat_map(|list| list.iter())
.any(|entry| entry.eq_ignore_ascii_case(rule.trim()))
}
/// A Runtime role policy intersected with the live parent and translated into
/// the concrete knobs a child spawn actually carries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildAuthority {
/// The clamped ceiling. Never wider than either input.
pub ceiling: PermissionCeiling,
/// `Some(list)` narrows the child's model-visible surface to exactly
/// `list`. `Some(vec![])` — the `tools = false` case — means *no tools at
/// all*, which is what the child registry's empty-allowlist path produces.
/// `None` means full inheritance from the parent surface.
pub allowed_tools: Option<Vec<String>>,
/// Names/globs the child must never see or call. Deny wins over allow.
pub disallowed_tools: Vec<String>,
/// Spawn write authority implied by the clamped ceiling.
pub write_authority: &'static str,
/// Nested-delegation budget, clamped.
pub max_depth: u32,
/// Canonical posture role that governs the child's tool posture.
pub posture_role: &'static str,
}
impl ChildAuthority {
/// Intersect one Runtime-requested posture with the live parent posture.
///
/// Every field takes the more restrictive side, so a child can never widen
/// live authority.
#[must_use]
pub fn clamp(requested: PermissionCeiling, session: PermissionCeiling) -> Self {
let ceiling = requested.clamp_to(session);
// `tools = false` is total: an empty allowlist leaves the child with no
// model-visible tools and nothing it is permitted to call.
let allowed_tools = (!ceiling.tools).then(Vec::new);
// The deny list expresses the effective Runtime posture. The spawn
// registry separately unions it with inherited parent restrictions, so
// a descendant can never drop something an ancestor
// imposed.
let mut disallowed_tools = Vec::new();
if !ceiling.network_tool {
disallowed_tools.extend(NETWORK_TOOL_DENYLIST.iter().map(|name| (*name).to_string()));
}
// Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower
// shell posture — `none` or `read_only` — loses the raw command surface
// outright. `from_runtime_role` may subsequently
// retain canonical `Bash` for a named scout/reviewer, whose concrete
// calls are bounded by the strict read-only classifier.
//
// This is deliberately keyed on the shell field rather than only on
// `write`, and that is the whole repair: the execution envelope reads
// its `shell` bit back off this deny list
// ([`RAW_SHELL_SENTINEL`]), so a ceiling whose shell posture never
// installed a denial was invisible to it. A clamped ceiling of
// `write = true, shell = none` — which any write-capable member inherits
// inside a session that has no shell authority — therefore reached the
// envelope claiming full shell authority and could start a process the
// ceiling had refused it.
if !(ceiling.write && ceiling.shell == ShellCeiling::Full) {
disallowed_tools.extend(RAW_SHELL_DENYLIST.iter().map(|name| (*name).to_string()));
}
// Losing the *raw* shell and holding no shell authority at all are two
// different postures, and only the second one loses the bounded
// verification surface.
//
// A `verifier`/`tester` member (`write = false, shell = "full"`) is the
// case that separates them: the rule above takes its raw shell away as
// a mutation control, but the member still holds shell authority and
// running the workspace's own checks is its entire purpose. A ceiling
// whose shell posture is narrower than `full` holds no such authority,
// so for it the checks are just another way to start a process.
if ceiling.shell != ShellCeiling::Full {
disallowed_tools.extend(
VERIFICATION_SURFACE_DENYLIST
.iter()
.map(|name| (*name).to_string()),
);
}
if !ceiling.write {
// `write = false` has to be a fact about the child's tool surface,
// not a word on a receipt, so the mutating file tools go. The raw
// shell is already gone by the rule above; a Runtime scout/reviewer
// may regain only canonical Bash in `from_runtime_role`, behind its
// input-specific read-only classifier. The bounded verification
// surface (`Run` / `run_tests` / `run_verifiers`) is deliberately
// left for a full-shell verifier.
disallowed_tools.extend(
MUTATING_TOOL_DENYLIST
.iter()
.map(|name| (*name).to_string()),
);
// Removing the shell is not enough on its own. An execution
// primitive spelled as bookkeeping — a verification gate that takes
// a command line, an automation that runs one on a schedule, an MCP
// server that spawns a process — mutates the workspace exactly as
// well as the shell just removed, while the receipt says
// `write=false`. These names take them off the visible surface;
// `crate::tools::execution_envelope` refuses them by capability,
// including the ones no list can name.
disallowed_tools.extend(
NON_SHELL_EXECUTION_DENYLIST
.iter()
.map(|name| (*name).to_string()),
);
}
Self {
ceiling,
allowed_tools,
disallowed_tools,
write_authority: if ceiling.write {
"workspace_write"
} else {
"read_only"
},
max_depth: ceiling.delegation_depth,
posture_role: posture_role_for(ceiling),
}
}
/// A stable, content-free fingerprint of the envelope this authority
/// actually installs.
///
/// This is the value that turns "the Fleet computed a ceiling" into
/// something a later layer can *check*. It covers every field a spawn
/// carries — allowlist, deny list, write authority, delegation budget, and
/// posture role — so a request that drifted between admission, routing, and
/// construction cannot pass for the one the Fleet resolved. Two authorities
/// with the same fingerprint install the same child surface; that is the
/// whole contract.
///
/// Deliberately human-readable rather than hashed: it appears verbatim in
/// the fail-closed error, and an operator debugging a refused launch should
/// be able to see which side differs without a lookup table.
#[must_use]
pub fn fingerprint(&self) -> String {
let allowed = match &self.allowed_tools {
None => "inherit".to_string(),
Some(list) if list.is_empty() => "none".to_string(),
Some(list) => {
let mut list = list.clone();
list.sort();
list.join(",")
}
};
let mut denied = self.disallowed_tools.clone();
denied.sort();
denied.dedup();
format!(
"v1;posture={};write={};depth={};tools={};network={};shell={};allow={};deny={}",
self.posture_role,
self.write_authority,
self.max_depth,
self.ceiling.tools,
self.ceiling.network_tool,
self.ceiling.shell.as_str(),
allowed,
denied.join(","),
)
}
/// Derive authority exclusively from Runtime policy after Fleet identity
/// selection. Free-form semantic roles map to Runtime `custom`; neither
/// the Fleet definition nor its legacy `permissions` key participates.
#[must_use]
pub fn from_runtime_role(role: &str, session: PermissionCeiling) -> Self {
let runtime_role = runtime_role_for_member(role);
let requested = runtime_permission_ceiling(&runtime_role);
let mut authority = Self::clamp(requested, session);
authority.posture_role = runtime_role.as_str();
if matches!(
runtime_role,
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
) && authority.ceiling.shell != ShellCeiling::None
{
// Runtime's Scout/Reviewer policy permits classifier-bounded Bash
// inspection. Keep the canonical entry while all other shell and
// execution aliases remain denied.
authority
.disallowed_tools
.retain(|name| !name.eq_ignore_ascii_case("Bash"));
}
authority
}
}
/// Org-chart aliases for Fleet **member** role labels: roster slot names and
/// coordination titles that select a runtime posture but are not part of the
/// model-facing spawn vocabulary.
///
/// Deliberately *not* folded into [`migrate_legacy_role_token`]. That table
/// feeds [`FleetRole::parse`], which is also the closed vocabulary the
/// `agent` tool validates its `type` against, and — decisively — the test the
/// spawn parser uses to decide whether a `role` string is a posture alias or a
/// roster profile key. Teaching `from_str` about `manager` or `smoke-runner`
/// would stop those names resolving as roster members there. So the aliases
/// live here, one step out, and reach exactly the one consumer that wants
/// them: [`runtime_role_for_member`].
///
/// Every entry maps to the posture the durable Fleet driver already gave it,
/// so folding them in only ever *narrows* the exact driver (which previously
/// dropped all of these into write-capable `custom`); none of them widens
/// authority on either path.
fn member_role_alias(token: &str) -> Option<FleetRole> {
match token {
// Coordination happens through delegation, which needs the full
// General surface (#fleet-roster cutover (v0.8.67)). The operator is
// the helm of the overall work (it assigns managers to Workflows);
// the manager is the middle manager of one Workflow. Both coordinate,
// so both get the General surface — explicitly, not by fall-through.
"manager" | "coordinator" | "operator" => Some(FleetRole::Worker),
// Synthesis is read-only (planner posture: network reads and
// read-only probes, never workspace writes). It must never fall
// through to General's full-write posture (#fleet-roster cutover
// (v0.8.67)).
"synthesizer" | "summarizer" | "reducer" => Some(FleetRole::Planner),
// Documented Fleet task role spellings (`docs/FLEET.md`).
"smoke-runner" => Some(FleetRole::Verifier),
"read-only" => Some(FleetRole::Scout),
_ => None,
}
}
/// Map the Fleet's open semantic role label onto Runtime's closed role policy.
///
/// **This is the only name → posture mapper.** Both Fleet drivers resolve
/// through it: the exact/named-Fleet driver via
/// [`ChildAuthority::from_runtime_role`], and the durable driver via
/// `worker_runtime::roster_member_agent_type` and the task-role call sites.
/// A second table anywhere is the defect this function exists to prevent —
/// before #5575 the durable driver carried its own alias list, so the same
/// string (`synthesizer`, `smoke-runner`, `read-only`) resolved to a
/// read-only posture on one driver and to write-capable `custom` on the other.
///
/// Resolution order, and nothing else:
/// 1. an empty label means *unspecified*, which is the documented general
/// default a Fleet task without a `role` has always had;
/// 2. [`FleetRole::parse`] — the declared closed vocabulary plus its
/// documented legacy aliases ([`VALID_ROLE_ALIASES`]);
/// 3. `member_role_alias` — the org-chart/slot spellings above;
/// 4. **fail closed.**
///
/// Step 4 is the privilege boundary. An unrecognized label is still useful
/// identity (`auditor`, `release-lead`, …) and keeps its name on every receipt
/// via [`public_role_label`], but a name nobody declared must not be able to
/// hand a worker write authority. It therefore executes on the narrowest
/// posture that can still do useful work — `explore`: no workspace writes, no
/// raw shell, network reads and classifier-bounded `Bash` inspection. An
/// operator who genuinely wants "inherit whatever the parent has" spells that
/// `custom`, which is a declared role and resolves at step 2.
pub fn runtime_role_for_member(role: &str) -> FleetRole {
let token = role.trim().to_ascii_lowercase();
if token.is_empty() {
return FleetRole::Worker;
}
FleetRole::parse(&token)
.or_else(|| member_role_alias(&token))
.unwrap_or(FleetRole::Scout)
}
fn runtime_permission_ceiling(role: &FleetRole) -> PermissionCeiling {
let profile = WorkerRuntimeProfile::for_role(role.clone());
let shell = match profile.shell {
ShellPolicy::None => ShellCeiling::None,
ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
ShellPolicy::Full => ShellCeiling::Full,
};
let tools = match profile.tools {
ToolScope::Inherit => true,
ToolScope::Explicit(ref tools) => !tools.is_empty(),
};
PermissionCeiling {
write: profile.permissions.write,
network_tool: profile.permissions.network,
shell,
delegation_depth: profile.remaining_spawn_depth(),
tools,
}
}
/// Intersect a worker shell policy with the session's legacy shell opt-in.
#[must_use]
pub fn session_shell_ceiling(shell: ShellPolicy, allow_shell: bool) -> ShellCeiling {
match shell {
ShellPolicy::None => ShellCeiling::None,
ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
ShellPolicy::Full if allow_shell => ShellCeiling::Full,
ShellPolicy::Full => ShellCeiling::None,
}
}
/// Map a permission ceiling onto the canonical posture role that governs the
/// child's tool surface.
#[must_use]
pub fn posture_role_for(ceiling: PermissionCeiling) -> &'static str {
if !ceiling.tools {
// No tools at all; the narrowest posture, and the allowlist is empty
// anyway.
return "explore";
}
if ceiling.write {
return "implement";
}
match ceiling.shell {
ShellCeiling::None | ShellCeiling::ReadOnly => "explore",
ShellCeiling::Full => "test",
}
}
/// Whether a Fleet role is never allowed a mutating shell, whatever its
/// requested runtime profile says. Spawn narrows the child to a read-only
/// shell for these roles, and every receipt must report that same posture.
#[must_use]
pub fn role_requires_read_only_shell(role: &FleetRole) -> bool {
matches!(
role,
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
)
}
/// The runtime profile a worker of `role` actually runs under: the requested
/// profile with the shell narrowed for read-only roles. Receipts and headers
/// derive from this, never from the requested profile alone (#5542 review).
#[must_use]
pub fn effective_runtime_profile_for_role(
role: &FleetRole,
requested: &WorkerRuntimeProfile,
) -> WorkerRuntimeProfile {
let mut effective = requested.clone();
if role_requires_read_only_shell(role) && effective.shell.allows_shell() {
effective.shell = ShellPolicy::ReadOnly;
}
effective
}
fn shell_policy_label(shell: ShellPolicy) -> &'static str {
match shell {
ShellPolicy::None => "none",
ShellPolicy::ReadOnly => "read_only",
ShellPolicy::Full => "full",
}
}
fn tool_scope_label(tools: &ToolScope) -> &'static str {
match tools {
ToolScope::Inherit => "inherit",
ToolScope::Explicit(_) => "explicit",
}
}
/// Effective non-secret runtime permissions for a worker of `role` running
/// under `requested`. This is the single posture truth for both in-process
/// sub-agent snapshots and durable Fleet receipts: the requested profile with
/// the shell narrowed for read-only roles. `profile_id` / `profile_origin`
/// identify a saved Fleet member when one selected the role; `None` for
/// direct role dispatches.
#[must_use]
pub fn fleet_effective_permissions(
role: &FleetRole,
requested: &WorkerRuntimeProfile,
profile_id: Option<&str>,
profile_origin: Option<&str>,
) -> FleetEffectivePermissions {
let profile = effective_runtime_profile_for_role(role, requested);
FleetEffectivePermissions {
write: profile.permissions.write,
network: profile.permissions.network,
shell: shell_policy_label(profile.shell).to_string(),
tool_scope: tool_scope_label(&profile.tools).to_string(),
tools: match &profile.tools {
ToolScope::Inherit => Vec::new(),
ToolScope::Explicit(tools) => tools.clone(),
},
background: profile.background,
max_spawn_depth: profile.max_spawn_depth,
profile_id: profile_id.map(str::to_string),
profile_origin: profile_origin.map(str::to_string),
source: "worker_runtime_profile".to_string(),
}
}
-987
View File
@@ -1,987 +0,0 @@
//! Worker runtime profile — the per-role capability contract for a CodeWhale
//! worker (#3217, #3211, #3213, and the child-permission-intersection issues
//! #414 / #426 / #1186).
//!
//! This is the **Workflow substrate**: every detached worker — whether launched
//! as an `agent` sub-agent or a Fleet worker — should run under a profile
//! that bounds what it may do (permissions, shell access, tool scope, model
//! route, recursion budget, foreground/background). A child profile is always
//! **derived** from its parent and can never escalate beyond it.
//!
//! Scope: this module defines the contract and the parent→child derivation with
//! tests. `agent` and Fleet worker records now build and persist these
//! profiles so parent-visible worker projections have a single capability
//! contract. Runtime enforcement of every declared field remains incremental
//! follow-up work (#3217).
use crate::role::FleetRole;
use serde::{Deserialize, Serialize};
/// Coarse capability classes a worker may exercise, beyond read access (reads
/// are always permitted). A child may only ever hold a *subset* of its parent's
/// capabilities.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PermissionSet {
/// May modify the workspace (`write_file` / `edit_file` / `apply_patch`).
pub write: bool,
/// May use network-capable tools (web search/fetch, networked MCP servers).
pub network: bool,
}
impl PermissionSet {
/// Full capabilities (write + network).
pub const fn full() -> Self {
Self {
write: true,
network: true,
}
}
/// Read-only: no write, no network.
pub const fn read_only() -> Self {
Self {
write: false,
network: false,
}
}
/// Read-only inspection: read-only on the workspace, but network-capable.
///
/// The read-only investigator posture (scout/reviewer): it must not
/// mutate the workspace, but real read-only inspection needs
/// `git`/`gh`/web reach — the old `read_only()` default left such lanes
/// with no way to run any command or reach any remote, which made default
/// scout lanes useless for the inspection they exist for.
pub const fn read_only_with_network() -> Self {
Self {
write: false,
network: true,
}
}
/// Intersection: a capability is granted only if **both** sets grant it.
/// This is the core non-escalation primitive — `parent.intersect(child)`
/// can never produce a capability the parent lacks.
#[must_use]
pub fn intersect(self, other: Self) -> Self {
Self {
write: self.write && other.write,
network: self.network && other.network,
}
}
}
/// Shell access policy — the replacement for the legacy per-worker shell boolean
/// (#3217). Ordered from most to least restrictive so `min` yields the safer of
/// two policies.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ShellPolicy {
/// No shell access.
None,
/// Read-only / non-mutating commands only (the policy enforcement lives in
/// the exec/sandbox layer; this is the declared intent).
ReadOnly,
/// Full shell access.
Full,
}
impl ShellPolicy {
/// Convert the legacy top-level shell opt-in into the typed shell policy.
#[must_use]
pub const fn from_legacy_allow_shell(allow_shell: bool) -> Self {
if allow_shell { Self::Full } else { Self::None }
}
/// Whether any shell tools should be exposed under this policy.
#[must_use]
pub const fn allows_shell(self) -> bool {
!matches!(self, Self::None)
}
/// The more restrictive (safer) of two policies. A child can never exceed
/// its parent's shell policy.
#[must_use]
pub fn min_with(self, other: Self) -> Self {
if self <= other { self } else { other }
}
}
/// Which tools a worker may call. Mirrors the existing `AgentWorkerToolProfile`
/// (`Inherited` / `Explicit`) so the two can be reconciled when this is wired in.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolScope {
/// Inherit the parent's tool surface.
Inherit,
/// Only the explicitly listed tool names.
Explicit(Vec<String>),
}
/// File-system authority axis of a [`ChildGrant`]. Ordered least to most
/// permissive so `Ord::min` is the non-escalation primitive.
///
/// `None` is not "no writes" — it is "no file access of any kind", which is
/// what a child with an empty tool surface experiences. `Read` permits
/// evidence collection only; `Write` permits mutation inside the session's
/// write policy (workspace boundary, exact-file claims, approval posture).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileGrant {
None,
Read,
Write,
}
/// Process authority axis of a [`ChildGrant`]. Ordered least to most
/// permissive so `Ord::min` is the non-escalation primitive.
///
/// `Inspect` is the read-only shell: canonical `bash` only, and only calls
/// the agent read-only classifier proves mutation-free (the explore /
/// reviewer / planner posture). `Verify` is the bounded built-in verification
/// surface — default workspace checks, pure test selection, and bounded Git
/// fetch/merge-tree — with no shell grammar (the test/verifier posture). A
/// child asking for a surface its parent does not hold degrades to the
/// narrower side (`Inspect ∩ Verify = Inspect`): bounded inspection is a
/// subset of bounded process authority, never a widening.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ShellGrant {
None,
Inspect,
Verify,
Full,
}
/// The named tool surface a grant exposes, before the explicit scope narrows
/// it. This is the role-preset axis of `tools` — distinct from
/// [`ChildGrant::scope`], which is the caller's own allowlist.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSurface {
/// Every registered tool the grant's other axes admit.
Inherited,
/// The read-only evidence surface — `readonly_evidence_tool` plus
/// `agent` (delegation). The explore / reviewer preset.
Evidence,
}
/// The single authority object a delegated child runs under (#5633).
///
/// Roles are *presets over this object*, never a second permission system:
/// [`ChildGrant::for_role`] gives the widest grant a role can hold, and
/// [`ChildGrant::resolve`] intersects it with the effective parent-derived
/// profile and the caller's explicit scope. One projection serves every
/// consumer — the child's tool catalog, its dispatch refusals, and its
/// capability envelope all read the same fields, so a tool that is visible
/// is callable and a tool that is denied never appears.
///
/// This is deliberately a *projection*, not a second persisted record:
/// [`WorkerRuntimeProfile`] remains the wire/persistence shape and
/// `ChildAuthority` /
/// `ToolAuthorityEnvelope`
/// remain the transports that carry the same authority across the durable
/// Fleet and `codewhale exec` subprocess boundaries. What this object retires
/// is the *semantic* duplication — posture sentinel entries read back off a
/// deny list, and role-keyed re-derivation inside the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildGrant {
/// File-system authority.
pub files: FileGrant,
/// Process authority.
pub shell: ShellGrant,
/// May reach the network through any surface.
pub network: bool,
/// May drive the operator's machine (computer-use / desktop tools).
/// Children never hold this today; the field is declared so every
/// surface reports the same denial rather than implying a grant.
pub desktop: bool,
/// The named tool surface before explicit scope narrowing.
pub surface: ToolSurface,
/// The caller's explicit allowlist, already intersected with the parent's
/// scope at spawn. `Some(vec![])` — the `tools = false` posture — permits
/// nothing at all.
pub scope: Option<Vec<String>>,
/// May delegate children of its own (remaining spawn depth).
pub spawn: bool,
}
impl ChildGrant {
/// The widest grant a role can ever hold — the role preset.
///
/// This is the role-to-authority table, expressed once: everything else
/// only narrows it. Read-only roles stay read-only on the workspace by
/// intent; network reach is a read, and a worker cut off from it for no
/// role reason cannot do its job. `desktop` is never in any preset.
#[must_use]
pub fn for_role(role: &FleetRole) -> Self {
let (files, shell, surface) = match role {
// Read-only investigators: evidence collection only, with the
// classifier-bounded inspection shell.
FleetRole::Scout | FleetRole::Reviewer => {
(FileGrant::Read, ShellGrant::Inspect, ToolSurface::Evidence)
}
// Planner: analysis only, same bounded shell, broader read
// surface — a plan may consult any read the session offers.
FleetRole::Planner => (FileGrant::Read, ShellGrant::Inspect, ToolSurface::Inherited),
// Counsel only: reads to ground advice, never acts — no process
// surface at all (#4752).
FleetRole::Consultant => (FileGrant::Read, ShellGrant::None, ToolSurface::Inherited),
// Verifier: bounded workspace checks and bounded Git reference
// reads, no shell grammar.
FleetRole::Verifier => (FileGrant::Read, ShellGrant::Verify, ToolSurface::Inherited),
// Doers, and Custom: the preset asks for everything; the parent
// profile and explicit scope do all the narrowing.
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => {
(FileGrant::Write, ShellGrant::Full, ToolSurface::Inherited)
}
};
Self {
files,
shell,
network: true,
desktop: false,
surface,
scope: None,
spawn: true,
}
}
/// Resolve the grant a child actually runs under: the role preset
/// intersected with the effective parent-derived profile, carrying the
/// caller's explicit scope and the remaining delegation depth.
///
/// Every field takes the more restrictive side, so the result can never
/// name authority the parent lacked. This is the in-process counterpart
/// of [`crate::role::ChildAuthority::clamp`].
#[must_use]
pub fn resolve(
role: &FleetRole,
profile: &WorkerRuntimeProfile,
scope: Option<Vec<String>>,
can_spawn: bool,
) -> Self {
let preset = Self::for_role(role);
let profile_shell = match profile.shell {
ShellPolicy::None => ShellGrant::None,
ShellPolicy::ReadOnly => ShellGrant::Inspect,
ShellPolicy::Full => ShellGrant::Full,
};
// The posture deny lists are the transport the ceiling clamps install;
// the grant folds their sentinels into the semantic axes so the two
// can never disagree. `fetch_url` stands for "no network"; `run_tests`
// stands for "no shell authority at all" — it removes process-start
// grants (`Verify`/`Full` → `None`) while `Inspect` survives, because
// classifier-bounded evidence reads were never shell authority.
// `exec_shell` (raw shell gone) is deliberately *not* folded: a
// write-denied verifier installs it while keeping shell authority.
let denied = |name: &str| {
codewhale_execpolicy::tool_rules::tool_denied(
Some(profile.denied_tools.as_slice()),
name,
)
};
let resolved_shell = preset.shell.min(profile_shell);
let shell = if denied(crate::role::SHELL_AUTHORITY_SENTINEL) {
match resolved_shell {
ShellGrant::Inspect => ShellGrant::Inspect,
_ => ShellGrant::None,
}
} else {
resolved_shell
};
let no_tools = matches!(scope.as_deref(), Some([]));
Self {
files: if no_tools {
FileGrant::None
} else {
preset.files.min(if profile.permissions.write {
FileGrant::Write
} else {
FileGrant::Read
})
},
shell,
network: preset.network
&& profile.permissions.network
&& !denied(crate::role::NETWORK_DENIAL_SENTINEL),
desktop: false,
surface: preset.surface,
scope,
spawn: can_spawn,
}
}
/// The transport shell policy the registered surface and `BashTool`
/// enforce for this grant.
///
/// `Verify` maps to `ShellPolicy::Full`: the bounded verification surface
/// is a process-start authority the profile transports as Full, while the
/// grant itself — not the transport — decides that raw shell names never
/// reach the catalog. Keeping the transport at Full also preserves the
/// ceiling a verifier may pass to its own children.
#[must_use]
pub const fn shell_policy(&self) -> ShellPolicy {
match self.shell {
ShellGrant::None => ShellPolicy::None,
ShellGrant::Inspect => ShellPolicy::ReadOnly,
ShellGrant::Verify | ShellGrant::Full => ShellPolicy::Full,
}
}
}
/// How a worker's model is selected. New model-facing spawns default to the
/// parent/session model; a child only takes a smaller/faster family sibling when
/// the parent explicitly asks for that route.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModelRoute {
/// Same model as the parent / session.
Inherit,
/// Explicitly request a smaller/faster same-family sibling when known.
Faster,
/// Legacy persisted route from the old hidden auto-router. New spawns do
/// not emit this; runtime treats it like `Faster` for compatibility.
Auto,
/// An explicit model id, validated against the active provider at spawn time.
Fixed(String),
}
/// The capability contract a single worker runs under.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerRuntimeProfile {
pub role: FleetRole,
pub permissions: PermissionSet,
pub shell: ShellPolicy,
pub tools: ToolScope,
pub model: ModelRoute,
/// Explicit provider override; `None` inherits the parent/session provider.
pub provider: Option<String>,
/// Explicit reasoning/thinking tier; `None` inherits the parent/session tier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// Tool deny-list inherited from the parent session's `--disallowed-tools`
/// (#4042). Deny always wins over allow, even over the explicit allowlist
/// and the role posture. Entries support wildcard matching: an exact name
/// (`exec_shell`) or a `prefix*` glob (`mcp_*`), compared case-insensitively.
///
/// A child can only ever *add* entries — `derive_child()` takes the union of
/// the parent's and the child's deny lists, so a descendant can never drop a
/// restriction an ancestor imposed. The legacy `inherit_disallowed_tools:
/// false` input remains accepted but cannot remove this ceiling.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_tools: Vec<String>,
/// Absolute recursion ceiling. Older profiles stored a remaining allowance;
/// interpreting that smaller value as absolute fails closed on recovery.
/// `spawn_depth` records this worker's position on the same axis.
pub max_spawn_depth: u32,
#[serde(default)]
pub spawn_depth: u32,
/// Whole-run wall time, including queued, model and tool work.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wall_time_secs: Option<u64>,
/// Persisted wall-clock deadline; continuations cannot restart the clock.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wall_deadline_ms: Option<u64>,
/// Optional model-turn cap. Zero means unbounded, matching the normal
/// Codex and GrokBuild agent loop; an operator may still set a cap.
#[serde(default = "default_general_max_steps")]
pub max_steps: u32,
/// Whether the worker runs detached (background) or inline (foreground).
pub background: bool,
}
impl WorkerRuntimeProfile {
/// Default model turns for every role: unbounded unless explicitly capped.
pub const READ_ONLY_MAX_STEPS: u32 = 0;
pub const GENERAL_MAX_STEPS: u32 = 0;
/// Return the default model-turn cap for this role (zero = unbounded).
#[must_use]
pub const fn default_max_steps(role: FleetRole) -> u32 {
match role {
FleetRole::Scout
| FleetRole::Reviewer
| FleetRole::Planner
| FleetRole::Verifier
| FleetRole::Consultant => Self::READ_ONLY_MAX_STEPS,
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => Self::GENERAL_MAX_STEPS,
}
}
/// The default profile for a role — the per-role posture. Mirrors the role
/// stances documented in `docs/SUBAGENTS.md` (explore/plan/review are
/// read-only; verifier runs tests; implementer/general write).
#[must_use]
pub fn for_role(role: FleetRole) -> Self {
// A role's default is what the role *intends*, expressed as the widest
// posture the role can be given; the parent's effective posture is the
// ceiling (`derive_child` intersects, never widens). Read-only roles
// stay read-only on the workspace by intent. Nothing else is taken
// away by default: network reach is a read, and a worker cut off from
// the network or from shell for no role reason cannot do its job.
let (permissions, shell) = match role {
// Read-only investigators: no workspace writes, but network reach
// and the bounded verification surface so a scout/reviewer lane
// can run git/gh/web inspection. Raw shell stays denied by the
// registry clamp (read-only classifier), so this widens capability
// without widening mutation authority.
FleetRole::Scout | FleetRole::Reviewer => {
(PermissionSet::read_only_with_network(), ShellPolicy::Full)
}
// Planner: analysis only. Reads the workspace and the web and may
// run read-only shell probes (`git log`, `rg`) under the read-only
// classifier; never mutates.
FleetRole::Planner => (
PermissionSet::read_only_with_network(),
ShellPolicy::ReadOnly,
),
// Consultant: counsel only. Reads (workspace and web) to ground
// its advice; never acts on the workspace, so no shell (#4752).
FleetRole::Consultant => (PermissionSet::read_only_with_network(), ShellPolicy::None),
// Verifier: doesn't modify code, but runs the bounded built-in
// verification surface (test/check selections) under a full shell
// ceiling clamped by ChildAuthority: writes are denied and
// unbounded shell forms are refused (#5186). The old wording
// promised "runs the test suite" without saying the surface is
// bounded. See the roster description and VERIFIER_AGENT_INTRO.
FleetRole::Verifier => (PermissionSet::read_only_with_network(), ShellPolicy::Full),
// Doers, and Custom: inherit the parent's effective posture. A
// custom worker is narrowed by its explicit tool list and by the
// spawning call, not by a silent locked-down default.
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => {
(PermissionSet::full(), ShellPolicy::Full)
}
};
Self {
role: role.clone(),
permissions,
shell,
tools: ToolScope::Inherit,
model: ModelRoute::Inherit,
provider: None,
// A Consultant is asked for judgement, so it defaults to the highest
// reasoning tier rather than inheriting the session's (#4752).
// Still only a default: an explicit spawn-time or profile value
// wins via `derive_child`, same as every other role.
reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()),
denied_tools: Vec::new(),
max_spawn_depth: codewhale_protocol::fleet::DEFAULT_SPAWN_DEPTH,
spawn_depth: 0,
wall_time_secs: None,
wall_deadline_ms: None,
max_steps: Self::default_max_steps(role.clone()),
background: true,
}
}
/// Derive a child profile from this (parent) profile and a `requested` child
/// profile. The result is the **intersection** of the two — it can never
/// grant the child something the parent lacks (#414 / #426 / #1186):
///
/// - permissions are AND-ed,
/// - shell takes the more restrictive policy,
/// - an explicit parent tool set bounds the child's tool set,
/// - the spawn-depth budget decrements by one level and clamps to the ceiling,
/// - the tool deny-list is the **union** of the two — a child may add
/// restrictions but never drop one an ancestor imposed (#4042).
///
/// The child keeps its own requested role, model route, and
/// foreground/background preference (these don't grant capability), but its
/// provider falls back to the parent's when unset.
#[must_use]
pub fn derive_child(&self, requested: &WorkerRuntimeProfile) -> WorkerRuntimeProfile {
let permissions = self.permissions.intersect(requested.permissions);
let shell = self.shell.min_with(requested.shell);
// Deny-lists union: a child can never drop a restriction an ancestor
// imposed. Wildcard entries are merged verbatim (no expansion).
let mut denied_tools = self.denied_tools.clone();
for rule in &requested.denied_tools {
if !denied_tools.contains(rule) {
denied_tools.push(rule.clone());
}
}
let tools = match (&self.tools, &requested.tools) {
// Parent restricts to a set → the child can only narrow within it.
(ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit(
child
.iter()
.filter(|name| parent.contains(name))
.cloned()
.collect(),
),
(ToolScope::Explicit(parent), ToolScope::Inherit) => {
ToolScope::Explicit(parent.clone())
}
// Parent inherits the full surface → the child's request stands.
(ToolScope::Inherit, child) => child.clone(),
};
// Depth stays absolute; only the current position increments. Every
// authority projection compares the position against this same ceiling.
let max_spawn_depth = requested.max_spawn_depth.min(self.max_spawn_depth);
WorkerRuntimeProfile {
role: requested.role.clone(),
permissions,
shell,
tools,
model: requested.model.clone(),
provider: requested.provider.clone().or_else(|| self.provider.clone()),
reasoning_effort: requested
.reasoning_effort
.clone()
.or_else(|| self.reasoning_effort.clone()),
denied_tools,
max_spawn_depth,
spawn_depth: self.spawn_depth.saturating_add(1),
wall_time_secs: narrow_optional_limit(self.wall_time_secs, requested.wall_time_secs),
wall_deadline_ms: narrow_optional_limit(
self.wall_deadline_ms,
requested.wall_deadline_ms,
),
max_steps: narrow_model_steps(self.max_steps, requested.max_steps),
background: requested.background,
}
}
/// Remaining generations, projected from the one absolute ceiling.
#[must_use]
pub fn remaining_spawn_depth(&self) -> u32 {
self.max_spawn_depth.saturating_sub(self.spawn_depth)
}
#[must_use]
pub fn can_spawn_child(&self) -> bool {
self.spawn_depth < self.max_spawn_depth
}
}
/// Omission inherits; neither a child request nor a replay can widen a cap.
pub fn narrow_optional_limit<T: Ord>(inherited: Option<T>, requested: Option<T>) -> Option<T> {
match (inherited, requested) {
(Some(inherited), Some(requested)) => Some(inherited.min(requested)),
(inherited, requested) => inherited.or(requested),
}
}
/// Zero is the operator/internal unbounded sentinel, never a widening request.
pub fn narrow_model_steps(inherited: u32, requested: u32) -> u32 {
narrow_optional_limit(
(inherited > 0).then_some(inherited),
(requested > 0).then_some(requested),
)
.unwrap_or(0)
}
const fn default_general_max_steps() -> u32 {
WorkerRuntimeProfile::GENERAL_MAX_STEPS
}
impl Default for WorkerRuntimeProfile {
fn default() -> Self {
Self::for_role(FleetRole::Worker)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildActivationIdentity {
pub owner_session_id: String,
pub session_boot_id: String,
pub public_child_id: String,
pub child_id: String,
pub run_id: String,
pub generation: u32,
pub initial_submission_id: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChildActivationDelivery {
#[default]
Parent,
Caller,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct FrozenChildConfiguration {
pub persona: Option<String>,
pub denied_tools: Vec<String>,
pub delivery: ChildActivationDelivery,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildInitialAdmission {
pub submission_id: String,
pub turn_id: String,
pub message_index: usize,
pub prefix_sha256: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChildActivationStopReason {
Completed,
Aborted,
Error,
MaxTokens,
Refusal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildActivationOutput {
pub transcript_message_index: usize,
pub content_sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildActivationManifest {
pub identity: ChildActivationIdentity,
pub configuration: Option<FrozenChildConfiguration>,
pub admission: Option<ChildInitialAdmission>,
#[serde(default)]
pub output_captured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<ChildActivationStopReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<ChildActivationOutput>,
}
/// Unified pre-launch manifest for a child agent (#414).
///
/// Everything needed to provision, launch, and resume a child — prompt, role,
/// model, tools, permissions, workspace boundary, budget, and identity — comes
/// from this single persisted record. No field is derived ad-hoc at launch time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChildLaunchManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activation: Option<ChildActivationManifest>,
pub owner_session: String,
pub child_id: String,
pub profile: WorkerRuntimeProfile,
pub prompt: String,
pub cwd: Option<String>,
pub worktree: bool,
pub writable_roots: Vec<String>,
#[serde(default)]
pub writable_files: Vec<String>,
#[serde(default)]
pub coordination_contracts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_artifact: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deliverables: Vec<String>,
pub resume_identity: Option<String>,
#[serde(default)]
pub generation: u32,
/// Agent id this child was resumed from via `resume_from`, if any.
/// Carries provenance across continuation chains so receipts can trace
/// the lineage without inspecting the transcript.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_from_agent_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_intersection_never_escalates() {
let parent = PermissionSet::read_only();
let greedy_child = PermissionSet::full();
// Even though the child asks for everything, the read-only parent wins.
let got = parent.intersect(greedy_child);
assert_eq!(got, PermissionSet::read_only());
}
#[test]
fn shell_policy_min_takes_the_safer() {
assert_eq!(
ShellPolicy::ReadOnly.min_with(ShellPolicy::Full),
ShellPolicy::ReadOnly
);
assert_eq!(
ShellPolicy::None.min_with(ShellPolicy::ReadOnly),
ShellPolicy::None
);
assert_eq!(
ShellPolicy::Full.min_with(ShellPolicy::Full),
ShellPolicy::Full
);
}
#[test]
fn for_role_postures_match_role_stances() {
let explore = WorkerRuntimeProfile::for_role(FleetRole::Scout);
assert!(!explore.permissions.write, "explore must not write");
assert!(
explore.permissions.network,
"explore/read-only inspection lanes keep network reach"
);
assert_eq!(
explore.shell,
ShellPolicy::Full,
"explore/read-only inspection lanes hold shell authority so the bounded verification surface survives the clamp (raw shell still requires write)"
);
assert_eq!(
explore.model,
ModelRoute::Inherit,
"explore should not silently downgrade the child model"
);
let implementer = WorkerRuntimeProfile::for_role(FleetRole::Builder);
assert!(implementer.permissions.write, "implementer writes");
assert_eq!(implementer.shell, ShellPolicy::Full);
let verifier = WorkerRuntimeProfile::for_role(FleetRole::Verifier);
assert!(
!verifier.permissions.write,
"verifier reports, does not patch"
);
assert_eq!(
verifier.shell,
ShellPolicy::Full,
"verifier holds shell authority for the bounded verification surface (unbounded forms are refused by the policy seam)"
);
}
#[test]
fn role_step_budgets_are_unbounded_by_default_and_profile_owned() {
for role in [
FleetRole::Scout,
FleetRole::Reviewer,
FleetRole::Planner,
FleetRole::Verifier,
FleetRole::Builder,
FleetRole::Worker,
FleetRole::Custom,
] {
assert_eq!(WorkerRuntimeProfile::for_role(role.clone()).max_steps, 0);
assert_eq!(
WorkerRuntimeProfile::for_role(role.clone()).max_steps,
WorkerRuntimeProfile::default_max_steps(role)
);
}
}
/// #4752: Consultant is counsel, not labour. Its posture has to be read-only
/// and shell-less by construction, not by the caller remembering to pass
/// `write_authority: read-only`.
#[test]
fn consultant_is_read_only_shell_less_and_high_reasoning_by_default() {
let consultant = WorkerRuntimeProfile::for_role(FleetRole::Consultant);
assert!(
!consultant.permissions.write,
"a consultant advises, it never writes"
);
assert_eq!(
consultant.shell,
ShellPolicy::None,
"a consultant has no reason to run commands"
);
assert_eq!(
consultant.reasoning_effort.as_deref(),
Some("high"),
"the point of asking a consultant is the reasoning tier"
);
assert_eq!(
consultant.model,
ModelRoute::Inherit,
"tier is a reasoning-effort default, not a hardcoded model"
);
assert_eq!(
consultant.max_steps,
WorkerRuntimeProfile::READ_ONLY_MAX_STEPS,
"consultants are unbounded by default like every other role"
);
}
/// The reasoning default must not become a ceiling: an explicit request
/// still wins, exactly as it does for every other role.
#[test]
fn an_explicit_reasoning_tier_overrides_the_consultant_default() {
let parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Consultant);
requested.reasoning_effort = Some("max".to_string());
let child = parent.derive_child(&requested);
assert_eq!(child.reasoning_effort.as_deref(), Some("max"));
assert!(!child.permissions.write, "still read-only");
}
#[test]
fn child_cannot_escalate_beyond_a_readonly_parent() {
// Scout now carries the read-only inspection posture: no writes, but network reach
// and full shell authority (bounded verification surface; raw shell
// still requires write at the clamp).
let parent = WorkerRuntimeProfile::for_role(FleetRole::Scout); // read-only inspection
let greedy = WorkerRuntimeProfile::for_role(FleetRole::Builder); // wants write + full shell
let child = parent.derive_child(&greedy);
assert!(
!child.permissions.write,
"a read-only parent cannot bear a writing child"
);
assert!(
child.permissions.network,
"child inherits the read-only inspection parent's network reach"
);
assert_eq!(
child.shell,
ShellPolicy::Full,
"child shell clamped to parent's read-only inspection posture"
);
}
#[test]
fn child_explicit_tools_are_bounded_by_parent() {
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.tools = ToolScope::Explicit(vec!["read_file".into(), "grep_files".into()]);
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker);
requested.tools = ToolScope::Explicit(vec!["read_file".into(), "write_file".into()]);
let child = parent.derive_child(&requested);
match child.tools {
ToolScope::Explicit(names) => {
assert_eq!(
names,
vec!["read_file".to_string()],
"write_file not in parent set is dropped"
);
}
ToolScope::Inherit => panic!("expected explicit tool scope"),
}
}
#[test]
fn absolute_spawn_depth_is_preserved_and_position_increments() {
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.max_spawn_depth = 2;
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker);
requested.max_spawn_depth = 99; // tries to grab more than the parent has
let child = parent.derive_child(&requested);
assert_eq!(
child.max_spawn_depth, 2,
"the absolute ceiling is unchanged, never the requested 99"
);
assert_eq!(child.spawn_depth, 1);
assert!(child.can_spawn_child());
let mut leaf_parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
leaf_parent.max_spawn_depth = 1;
let grandchild = leaf_parent.derive_child(&requested);
assert_eq!(grandchild.max_spawn_depth, 1);
assert_eq!(grandchild.spawn_depth, 1);
assert!(
!grandchild.can_spawn_child(),
"budget exhausted at the leaf"
);
}
#[test]
fn child_provider_falls_back_to_parent() {
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.provider = Some("moonshot".to_string());
let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); // provider None
let child = parent.derive_child(&requested);
assert_eq!(child.provider.as_deref(), Some("moonshot"));
}
#[test]
fn child_reasoning_effort_uses_requested_then_parent() {
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.reasoning_effort = Some("low".to_string());
let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout);
let inherited = parent.derive_child(&requested);
assert_eq!(inherited.reasoning_effort.as_deref(), Some("low"));
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Scout);
requested.reasoning_effort = Some("max".to_string());
let overridden = parent.derive_child(&requested);
assert_eq!(overridden.reasoning_effort.as_deref(), Some("max"));
}
#[test]
fn child_denied_tools_union_never_drops_parent_restriction() {
// A child may only *add* deny entries; it can never drop a restriction
// an ancestor imposed (#4042 non-escalation invariant).
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.denied_tools = vec!["exec_shell".into(), "mcp_*".into()];
// Child asks for its own deny list and (tryingly) tries to omit the
// parent's exec_shell — the union keeps both.
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Builder);
requested.denied_tools = vec!["write_file".into()];
let child = parent.derive_child(&requested);
assert!(child.denied_tools.contains(&"exec_shell".to_string()));
assert!(child.denied_tools.contains(&"mcp_*".to_string()));
assert!(child.denied_tools.contains(&"write_file".to_string()));
}
/// Every built-in role keeps network reads by default: a worker cut off
/// from the network for no role reason cannot do its job. Only workspace
/// mutation is a role intent.
#[test]
fn every_role_default_keeps_network_reads_and_only_read_only_roles_withhold_writes() {
for role in [
FleetRole::Scout,
FleetRole::Reviewer,
FleetRole::Planner,
FleetRole::Verifier,
FleetRole::Consultant,
FleetRole::Builder,
FleetRole::Worker,
FleetRole::Custom,
] {
let profile = WorkerRuntimeProfile::for_role(role.clone());
assert!(
profile.permissions.network,
"{role:?} must keep network reads"
);
let read_only_by_intent = matches!(
role,
FleetRole::Scout
| FleetRole::Reviewer
| FleetRole::Planner
| FleetRole::Verifier
| FleetRole::Consultant
);
assert_eq!(
profile.permissions.write, !read_only_by_intent,
"{role:?} write default"
);
}
// Custom inherits (full ceiling); Planner probes read-only shell;
// Consultant never acts on the workspace.
assert_eq!(
WorkerRuntimeProfile::for_role(FleetRole::Custom).shell,
ShellPolicy::Full
);
assert_eq!(
WorkerRuntimeProfile::for_role(FleetRole::Planner).shell,
ShellPolicy::ReadOnly
);
assert_eq!(
WorkerRuntimeProfile::for_role(FleetRole::Consultant).shell,
ShellPolicy::None
);
}
/// The parent's effective posture is the ceiling: a full-default child role
/// under a read-only, no-network, read-only-shell parent inherits exactly
/// that, never more.
#[test]
fn derive_child_inherits_the_parent_ceiling_and_never_widens() {
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
parent.permissions = PermissionSet::read_only();
parent.shell = ShellPolicy::ReadOnly;
for role in [FleetRole::Custom, FleetRole::Builder, FleetRole::Worker] {
let child = parent.derive_child(&WorkerRuntimeProfile::for_role(role.clone()));
assert!(!child.permissions.write, "{role:?} widened write");
assert!(!child.permissions.network, "{role:?} widened network");
assert_eq!(child.shell, ShellPolicy::ReadOnly, "{role:?} widened shell");
}
// And a full parent hands a doer its full posture.
let full = WorkerRuntimeProfile::for_role(FleetRole::Worker)
.derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Custom));
assert!(full.permissions.write && full.permissions.network);
assert_eq!(full.shell, ShellPolicy::Full);
}
}
+2 -6
View File
@@ -2,14 +2,10 @@
name = "codewhale-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for Codewhale"
[lints]
workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.8.47" }
serde.workspace = true
Loaded 100 of 4537 files, more files were not shown because too many files have changed in this diff. Show more