Compare 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
4867 changed files with 237658 additions and 2538497 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 -87
View File
@@ -1,93 +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
# DeepSeek Harness creator pins are compared byte for byte against recorded
# SHA-256 values (UPSTREAM.json) and embedded with include_bytes!(); a CRLF
# Windows checkout must not rewrite any of them.
crates/skills/assets/dsh-creator/** text eol=lf
crates/engine/extension-host/src/dsh/upstream/creator/** 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
crates/app-server-protocol/METHODS.md 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
-280
View File
@@ -1,280 +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]>
[email protected] = luochen211 <[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.
-68
View File
@@ -1,68 +0,0 @@
# Berkontribusi pada Codewhale
Codewhale berkembang berkat orang yang menggunakannya, melaporkan masalah,
menerjemahkan dokumentasi, menambahkan penyedia model, dan mengirim perbaikan.
Anda dapat berkontribusi tanpa menulis kode. Semua masukan diterima.
[Roadmap](../docs/ROADMAP.md) menjelaskan tujuan proyek dan pekerjaan yang masih
terbuka. [Panduan utama](../CONTRIBUTING.md) memuat prosedur pengembangan,
pengujian, dan peninjauan yang lengkap dan terbaru.
## Pilih tempat yang sesuai
| Yang ingin Anda sampaikan | Tempatnya |
| --- | --- |
| Bug, crash, atau perilaku yang tidak diharapkan | [Laporan bug](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) |
| Ide atau permintaan fitur | [Permintaan fitur](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml) |
| Masukan tentang tampilan, tulisan, atau pengalaman penggunaan | [Formulir masukan](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feedback.yml) |
| Pertanyaan | [Tanya jawab](https://github.com/codewhale-hq/CodeWhale/discussions/categories/q-a) atau [Discord](https://discord.gg/37gfS3ksug) |
| Masalah keamanan | [Laporan keamanan privat](https://github.com/codewhale-hq/CodeWhale/security/advisories/new), bukan issue publik |
| Perbaikan untuk issue yang sudah ada | Pull request yang menautkan issue tersebut |
| Terjemahan, penyedia model, atau perbaikan dokumentasi | Pull request; tidak perlu membuka issue terlebih dahulu |
Jika ragu, buka issue dan jelaskan apa yang Anda coba lakukan, apa yang terjadi,
dan apa yang Anda harapkan. Pengelola akan membantu mengarahkannya.
## Mulai fitur baru dengan issue
Untuk fitur baru, buka issue sebelum mulai menulis kode. Jelaskan kebutuhan dan
hambatannya; rancangan solusi boleh disertakan, tetapi tidak wajib. Jika ingin
mengerjakannya sendiri, centang pilihan untuk membuka pull request pada formulir.
Pengelola dapat membahas cakupan dan perilaku fitur sebelum Anda menghabiskan
waktu untuk implementasi. Pull request fitur tanpa issue tetap dibaca, tetapi
pembahasannya biasanya membutuhkan waktu lebih lama.
## Pekerjaan yang dapat langsung Anda kirim
Anda dapat langsung mengirim pull request untuk terjemahan, penyedia dan model,
perbaikan dokumentasi, atau perbaikan bug. Jika bug belum memiliki issue,
jelaskan cara memunculkannya, hasil yang terjadi, dan hasil yang diharapkan di
pull request.
Untuk pekerjaan yang sudah direncanakan, pilih issue di
[milestone](../docs/ROADMAP.md#releases) atau yang berlabel
[help wanted](https://github.com/codewhale-hq/CodeWhale/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).
Beri komentar bahwa Anda ingin mengerjakannya agar pekerjaan tidak tumpang tindih.
[Lihat proyek terbuka](../CONTRIBUTING.md#standing-projects) untuk tautan ke
panduan terjemahan dan penyedia model.
## Siapkan perubahan Anda
Ikuti [petunjuk persiapan pengembangan](../CONTRIBUTING.md#getting-started)
untuk persyaratan Rust, perintah build, dan cara menjalankan versi sumber.
Versi di `main` dapat lebih baru daripada unduhan rilis; sertakan keluaran
`codewhale --version` ketika melaporkan masalah.
Jalankan pemeriksaan yang sesuai dengan bagian yang Anda ubah, mengikuti
[panduan pengujian lokal](../CONTRIBUTING.md#fast-local-loop). Sebelum mengirim
pull request, baca perubahan Anda sendiri dan jelaskan apa yang berubah serta
bagaimana Anda memeriksanya. Jangan sertakan kunci API atau data pribadi.
## Peninjauan dan kredit
Pull request Anda digabungkan sebagai kontribusi Anda sendiri. Pengelola
membantu menyelesaikan konflik akibat perubahan di `main`; pemeriksaan yang
merah karena masalah lain bukan tanggung jawab Anda. Jika perubahan belum siap,
pengelola menjelaskan alasannya. Kontribusi dicatat di changelog dan
[daftar kontributor](../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 -14
View File
@@ -1,14 +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: Roadmap and where to help
url: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/ROADMAP.md
about: Where Codewhale is going, what each release covers, and good places to start. 路线图。
- 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,66 +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.
This is the best place to start a feature, even if you plan to build it yourself: agreeing on the shape here makes the pull request small and quick to merge. The [roadmap](https://github.com/codewhale-hq/CodeWhale/blob/main/docs/ROADMAP.md) shows what is already planned.
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, once we agree on the shape. 我愿意在方案确定后提交 PR。
-25
View File
@@ -1,25 +0,0 @@
name: Feedback
description: Something looked, read or felt off, or you have a thought to share. 反馈与感受。
labels: ["needs-triage"]
body:
- type: markdown
attributes:
value: |
All feedback is welcome, and you do not need a fix or a plan. English or 中文 are both fine.
First impressions, confusing wording, an awkward step, a screen that looks wrong, something you liked: it all helps.
For something broken, the [bug report](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) form asks for the details we need to reproduce it.
- type: textarea
id: feedback
attributes:
label: What would you like us to know? 想告诉我们什么?
description: What you were doing, and what you noticed. A screenshot helps for anything visual.
validations:
required: true
- type: input
id: version
attributes:
label: Codewhale version (optional) 版本
description: Output of `codewhale --version`, if you have it.
+8 -22
View File
@@ -1,27 +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. -->
## Where this came from
<!-- Closes #N if this finishes an issue, Refs #N if related. Translations,
provider and model updates, docs fixes and bug fixes need no issue: just say
which it is. For a new feature with no issue yet, opening one first is the
faster path (CONTRIBUTING.md explains why); we still read this PR either way.
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
-133
View File
@@ -1,133 +0,0 @@
name: Catalog refresh
on:
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions: {}
concurrency:
group: catalog-refresh
cancel-in-progress: false
jobs:
refresh:
name: Refresh the model catalog
if: github.repository == 'codewhale-hq/Codewhale'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
env:
BRANCH: catalog-refresh
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: write
permission-pull-requests: write
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Fetch models.dev once
run: |
curl --fail --silent --show-error --location --max-time 60 \
--user-agent "CodeWhale-catalog-automation (+https://github.com/codewhale-hq/Codewhale)" \
--output "$RUNNER_TEMP/models-dev-catalog.json" \
https://models.dev/catalog.json
- name: Decide whether the pin is due
id: pin
run: |
if python3 scripts/catalog_models_dev.py seed age --max-days 7; then
echo "due=false" >> "$GITHUB_OUTPUT"
else
echo "due=true" >> "$GITHUB_OUTPUT"
fi
- name: Propose new models, re-lock and render
env:
CODEWHALE_MODELS_DEV_PATH: ${{ runner.temp }}/models-dev-catalog.json
run: |
python3 scripts/catalog_models_dev.py seed discover --write --snapshot "$CODEWHALE_MODELS_DEV_PATH" | tee "$RUNNER_TEMP/discover.txt"
python3 scripts/catalog_models_dev.py seed lock > "$RUNNER_TEMP/lock-report.txt"
python3 scripts/catalog_models_dev.py seed render
(cd web && node scripts/derive-facts.mjs)
- name: Decide whether anything material changed
id: change
run: |
material=$(git diff -U0 -- \
crates/config/assets/models_dev.bundled.json \
crates/config/assets/catalog_corrections.json \
scripts/catalog/models_dev_seed.toml \
| grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -v '"upstream":' || true)
if [ -n "$material" ] || [ "${{ steps.pin.outputs.due }}" = "true" ]; then
echo "open=true" >> "$GITHUB_OUTPUT"
else
echo "open=false" >> "$GITHUB_OUTPUT"
echo "No catalog change and the pin is under 7 days old; nothing to propose."
fi
- name: Run the catalog checks
id: checks
if: steps.change.outputs.open == 'true'
run: |
set +e
{
python3 scripts/catalog_models_dev.py seed render --check
python3 scripts/catalog_models_dev.py seed age --max-days 30
python3 -m unittest scripts/catalog_models_dev_test.py
(cd web && node scripts/check-facts.mjs)
} > "$RUNNER_TEMP/checks.txt" 2>&1
status=$?
if [ "$status" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi
tail -n 40 "$RUNNER_TEMP/checks.txt"
- name: Push the branch and open or update the pull request
if: steps.change.outputs.open == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CHECKS_OK: ${{ steps.checks.outputs.ok }}
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
run: |
git config user.name 'codewhale-agent[bot]'
git config user.email "${APP_ID}+codewhale-agent[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add crates/config/assets/models_dev.bundled.json crates/config/assets/catalog_corrections.json \
scripts/catalog/models_dev_seed.toml scripts/catalog/models_dev_seed.lock.json web/lib/facts.generated.ts
git commit -m "chore(catalog): refresh the Models.dev offline seed" \
-m "Generated by .github/workflows/catalog-refresh.yml from models.dev. No LLM wrote these rows."
git push --force-with-lease="$BRANCH" \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$BRANCH"
{
echo "Generated by the scheduled catalog refresh from models.dev. No LLM wrote these rows; review them like any other change."
echo
echo "New models from the vendor allowlist. Merging this PR publishes them on the public /models page; rows carry an \`unreviewed\` source until someone checks them against the vendor's docs:"
echo
echo '```text'
cat "$RUNNER_TEMP/discover.txt"
echo '```'
echo
echo "Checks run in the job: \`seed render --check\`, \`seed age --max-days 30\`, \`scripts/catalog_models_dev_test.py\`, \`web/scripts/check-facts.mjs\`: $([ "$CHECKS_OK" = true ] && echo passed || echo FAILED, see the job log; this PR is a draft)."
echo
echo "To stop proposing a model, add its id to \`[discover].skip\` in \`scripts/catalog/models_dev_seed.toml\`."
} > "$RUNNER_TEMP/pr-body.md"
title="chore(catalog): refresh the Models.dev offline seed"
existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
gh pr edit "$existing" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
if [ "$CHECKS_OK" = true ]; then
gh pr ready "$existing"
else
gh pr ready --undo "$existing"
fi
else
draft=""
[ "$CHECKS_OK" = true ] || draft="--draft"
gh pr create --base main --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" $draft
fi
+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
-75
View File
@@ -1,75 +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 (including
# the website's generated facts, agent docs and feature registry), 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
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- 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;
-319
View File
@@ -1,319 +0,0 @@
name: Web maintainer
on:
schedule:
- cron: '43 6 * * *'
workflow_dispatch:
inputs:
lane:
description: Which lane to run
required: true
default: all
type: choice
options:
- all
- derived
- copy-freshness
- link-health
- docs-drift
permissions: {}
concurrency:
group: web-maintainer
cancel-in-progress: false
env:
WEB_MAINTAINER_CLI_VERSION: v0.10.2
jobs:
plan:
name: Plan
if: github.repository == 'codewhale-hq/Codewhale' && github.ref == 'refs/heads/main' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
outputs:
lanes: ${{ steps.plan.outputs.lanes }}
derived: ${{ steps.plan.outputs.derived }}
steps:
- id: plan
env:
INPUT_LANE: ${{ inputs.lane || 'all' }}
run: |
derived=false
lanes='[]'
case "$INPUT_LANE" in
all) derived=true; lanes='["copy-freshness","link-health","docs-drift"]' ;;
derived) derived=true ;;
copy-freshness|link-health|docs-drift) lanes="[\"${INPUT_LANE}\"]" ;;
*) echo "::error::unknown lane"; exit 1 ;;
esac
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
echo "lanes=${lanes}" >> "$GITHUB_OUTPUT"
echo "Web maintainer plan: derived=${derived} lanes=${lanes}" >> "$GITHUB_STEP_SUMMARY"
derived:
name: Derived files
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.derived == 'true' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Install web dependencies
working-directory: web
run: npm ci --no-audit --no-fund
- name: Regenerate derived files
working-directory: web
env:
GH_TOKEN: ${{ github.token }}
run: |
node scripts/sync-latest-release.mjs
node scripts/derive-facts.mjs
node scripts/derive-changelog.mjs
node scripts/derive-install.mjs
node scripts/render-terminal-capture.mjs
- name: Check what changed
id: drift
run: |
mkdir -p "$RUNNER_TEMP/out"
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "::error::regeneration produced untracked files"
git ls-files --others --exclude-standard
exit 1
fi
git diff --name-status | node web/maintainer/check-allowlist.mjs web/maintainer/derived.txt derived
if [ -n "$(git diff --summary)" ]; then
echo "::error::regeneration created, deleted or changed modes"
exit 1
fi
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
git diff --stat >> "$GITHUB_STEP_SUMMARY"
else
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "Derived files are current." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Run the web checks on the regenerated tree
if: steps.drift.outputs.drift == 'true'
working-directory: web
run: |
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.drift.outputs.drift == 'true'
with:
name: web-maintainer-derived
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
lane:
name: Lane ${{ matrix.lane }}
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.lanes != '[]' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
lane: ${{ fromJSON(needs.plan.outputs.lanes) }}
env:
LANE: ${{ matrix.lane }}
steps:
- name: Require the DeepSeek key
env:
KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::error::WEB_MAINTAINER_DEEPSEEK_API_KEY is not set. Create a dedicated DeepSeek key with a provider-side monthly cap and add it as that repository secret, or unset WEB_MAINTAINER_ENABLED. See web/maintainer/README.md."
exit 1
fi
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Prove the command sandbox works or stop
run: |
sudo apt-get install -y --no-install-recommends bubblewrap util-linux >/dev/null
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null || true
probe="$GITHUB_WORKSPACE/.sandbox-probe"
rm -f "$probe" /usr/local/sandbox-probe
if ! timeout 10 bash -c 'exec 3<>/dev/tcp/api.deepseek.com/443'; then
echo "::error::the runner cannot reach api.deepseek.com, so the network denial cannot be proven."
exit 1
fi
if ! /usr/bin/bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--bind "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE" --chdir "$GITHUB_WORKSPACE" -- \
bash -c 'touch .sandbox-probe && ! touch /usr/local/sandbox-probe 2>/dev/null && ! timeout 10 bash -c "exec 3<>/dev/tcp/api.deepseek.com/443" 2>/dev/null'; then
echo "::error::bubblewrap did not confine a child on this runner (writes outside the workspace or outbound network were not denied). Refusing to give a model a shell."
exit 1
fi
if [ ! -e "$probe" ] || [ -e /usr/local/sandbox-probe ]; then
echo "::error::sandbox probe left unexpected state. Refusing to give a model a shell."
exit 1
fi
rm -f "$probe"
echo "Sandbox probe passed: workspace-only writes, no outbound network." >> "$GITHUB_STEP_SUMMARY"
- name: Stage trusted tooling and the pinned CLI
run: |
mkdir -p "$RUNNER_TEMP/trusted" "$RUNNER_TEMP/home"
cp web/maintainer/allowlist.txt web/maintainer/check-allowlist.mjs "$RUNNER_TEMP/trusted/"
cp "web/maintainer/lanes/${LANE}.md" "$RUNNER_TEMP/trusted/prompt.md"
CODEWHALE_VERSION="$WEB_MAINTAINER_CLI_VERSION" CODEWHALE_INSTALL_DIR="$RUNNER_TEMP/bin" sh web/public/install.sh
"$RUNNER_TEMP/bin/codewhale" --version
- name: Probe outbound links
if: matrix.lane == 'link-health'
run: node web/maintainer/link-report.mjs "$GITHUB_WORKSPACE/.git/maintainer-input/link-report.json"
- name: Record the git state the model must not change
run: |
git config --local --list | sort > "$RUNNER_TEMP/git-config.before"
find .git/hooks -type f ! -name '*.sample' > "$RUNNER_TEMP/hooks.before"
test ! -s "$RUNNER_TEMP/hooks.before"
- name: Run the lane on DeepSeek
timeout-minutes: 25
env:
WEB_MAINTAINER_DEEPSEEK_API_KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
env -i \
PATH="$PATH" \
HOME="$RUNNER_TEMP/home" \
CODEWHALE_HOME="$RUNNER_TEMP/home/.codewhale" \
CODEWHALE_SECRET_BACKEND=file \
CODEWHALE_MEMORY=false \
CODEWHALE_TELEMETRY=false \
DEEPSEEK_API_KEY="$WEB_MAINTAINER_DEEPSEEK_API_KEY" \
"$RUNNER_TEMP/bin/codewhale" \
--no-project-config \
--skip-onboarding \
--workspace "$GITHUB_WORKSPACE" \
exec \
--provider deepseek \
--model deepseek-flash \
--reasoning-effort low \
--auto \
--sandbox workspace-write \
--max-turns 40 \
--output-format stream-json \
-- "$(cat "$RUNNER_TEMP/trusted/prompt.md")" \
> "$RUNNER_TEMP/exec.jsonl"
echo "Model run finished: $(wc -l < "$RUNNER_TEMP/exec.jsonl") stream events." >> "$GITHUB_STEP_SUMMARY"
- name: Enforce the path allowlist
id: scope
run: |
if git status --porcelain --untracked-files=all --ignored | grep -qv '^ M '; then
echo "::error::the lane left untracked, ignored, staged or non-edit entries"
git status --porcelain --untracked-files=all --ignored | grep -v '^ M '
exit 1
fi
git config --local --list | sort | diff - "$RUNNER_TEMP/git-config.before" || { echo "::error::the lane changed .git/config"; exit 1; }
find .git/hooks -type f ! -name '*.sample' | diff - "$RUNNER_TEMP/hooks.before" || { echo "::error::the lane added git hooks"; exit 1; }
git diff --name-status | node "$RUNNER_TEMP/trusted/check-allowlist.mjs" "$RUNNER_TEMP/trusted/allowlist.txt" "$LANE"
if [ -n "$(git diff --summary)" ]; then
echo "::error::the lane created, deleted or changed modes"
exit 1
fi
mkdir -p "$RUNNER_TEMP/out"
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ ! -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lane ${LANE}: no change proposed." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$(wc -c < "$RUNNER_TEMP/out/change.patch")" -gt 262144 ]; then
echo "::error::patch exceeds 256 KiB"
exit 1
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
{ echo "Lane ${LANE} proposes:"; echo '```text'; git diff --stat; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Fresh checkout for verification
if: steps.scope.outputs.changed == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
path: verify
persist-credentials: false
- name: Run the web checks on the proposal in a clean tree without secrets
if: steps.scope.outputs.changed == 'true'
env:
PATCH: ${{ runner.temp }}/out/change.patch
run: |
cd verify
git apply --index --whitespace=nowarn "$PATCH"
cd web
npm ci --no-audit --no-fund
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.scope.outputs.changed == 'true'
with:
name: web-maintainer-${{ matrix.lane }}
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
publish:
name: Open pull requests
needs: [plan, derived, lane]
if: always() && needs.plan.result == 'success' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
continue-on-error: true
with:
pattern: web-maintainer-*
path: ${{ runner.temp }}/patches
- name: Look for proposed patches
id: patches
run: |
if [ -n "$(find "$RUNNER_TEMP/patches" -name change.patch -size +0c 2>/dev/null)" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No lane proposed a change; no pull request opened." >> "$GITHUB_STEP_SUMMARY"
fi
- if: steps.patches.outputs.found == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- if: steps.patches.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Open or update one pull request per lane
if: steps.patches.outputs.found == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
ARTIFACTS_DIR: ${{ runner.temp }}/patches
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
mkdir -p "$RUNNER_TEMP/trusted"
cp web/maintainer/allowlist.txt web/maintainer/derived.txt web/maintainer/check-allowlist.mjs web/maintainer/publish-prs.sh "$RUNNER_TEMP/trusted/"
TRUSTED_DIR="$RUNNER_TEMP/trusted" bash "$RUNNER_TEMP/trusted/publish-prs.sh"
-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
-272
View File
@@ -1,272 +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.
- **Public planned-work issues are written for contributors.** An issue opened for planned or release work has the parts in CONTRIBUTING.md § "What a planned-work issue gives you": status, ways to help with files, a local test command and a done-when, what maintainers already hold, and how to claim. Keep it current as pieces land or are claimed.
- **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 -13352
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.
+156 -502
View File
@@ -1,109 +1,12 @@
# Contributing to Codewhale
# Contributing to codewhale
Codewhale is what it is because of the people who used it, reported what broke,
translated it, added their providers and sent fixes. Thank you for being here.
Everyone is welcome, in English or 中文, with or without code.
All feedback is a gift to this project, including "this looked wrong" or "I got
lost here" with no fix attached. The [roadmap](docs/ROADMAP.md) says where
Codewhale is going and where help counts most.
## Where things go
| You have | Best place |
| --- | --- |
| A bug, a crash, or behavior that surprised you | [Bug report](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) |
| An idea or a feature you want | [Feature request](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml), or [Ideas](https://github.com/codewhale-hq/CodeWhale/discussions/categories/ideas) to talk it through first |
| Feedback on how something looks, reads or feels | [Feedback](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feedback.yml) |
| A question | [Q&A](https://github.com/codewhale-hq/CodeWhale/discussions/categories/q-a) or [Discord](https://discord.gg/37gfS3ksug) |
| A security problem | A [private advisory](https://github.com/codewhale-hq/CodeWhale/security/advisories/new), never a public issue ([policy](.github/SECURITY.md)) |
| A fix for an open issue | A pull request that links the issue |
| A translation, a provider or model, a docs fix | A pull request, no issue needed ([standing projects](#standing-projects)) |
| A new feature, already written or not | An issue first ([why](#new-features-start-as-an-issue)) |
If you are not sure, open an issue. Nothing is filed in the wrong place for
long: a maintainer moves it.
## New features start as an issue
Issues are the best place for feature requests, ideas and bugs. A pull request
is the best place for work we have already agreed on.
A feature that first appears as a pull request takes longer to land than one
that starts as an issue, even when we want it. The questions that decide a
feature are about the product, not the diff: does it belong in the engine or in
a plugin, what does it do in each permission mode, how does it show up in
receipts, what is it called in 19 languages. Answering those in a review means
rewriting code you already finished. Answering them in an issue costs you a few
sentences.
So for a new feature:
1. Open a [feature request](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml).
Say what you are trying to do and what gets in the way. A proposed design is
welcome and optional.
2. Tick "I would like to open a pull request" if you want to build it.
3. A maintainer answers with the shape that fits, or with what is still
undecided. Then the pull request is small and merges quickly.
A feature pull request that arrives without an issue is still read, and it is
never closed for that reason. We will most likely ask the same questions there
and it will take longer.
## Standing projects
These are always open, and a pull request needs no issue first:
- **Translations.** You can improve the terminal language packs, the translated
READMEs in `docs/i18n/`, the website, and the translated docs in
`docs/zh_hans/` and `docs/id/`. The [localization matrix](docs/LOCALIZATION.md)
shows what each language has and how to add one, and the
[Chinese localization group](https://github.com/codewhale-hq/CodeWhale/issues/6804)
is recruiting.
- **Providers and models.** You can add a missing provider or a new model, or
correct a price, a context size or a provider quirk.
[Providers](docs/PROVIDERS.md) lists what ships, and
`python3 scripts/check-provider-registry.py` checks your change.
- **Documentation.** You can fix a typo, an unclear step, a missing example or
an instruction that no longer matches the product.
- **Bug fixes.** If there is no issue yet, describe the bug in the pull request
by saying what you ran, what happened and what you expected.
- **Planned work.** You can take anything in a
[milestone](docs/ROADMAP.md#releases) or anything labelled
[help wanted](https://github.com/codewhale-hq/CodeWhale/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).
Comment on the issue to claim it so two people do not build the same thing.
Large changes to planned work are easier in layers; see
[Layered and EPIC-Sized Work](#layered-and-epic-sized-work).
### What a planned-work issue gives you
Every issue a maintainer opens for planned work, and every release tracking issue, has the same parts, so you can pick one up without asking first:
- **Status:** what is on `main` already, with commit links, and what is still missing.
- **Ways to help:** each piece open to contributors, with the files to read, the command to test it locally (usually `scripts/dev-test.sh <crate> <filter>`) and what "done" means. Pieces that need no Rust, such as trying a feature and reporting exactly what happened, are listed first. A precise report is credited like code.
- **Being built by maintainers right now:** the parts someone already holds, so you don't build the same thing twice.
- **How to take a piece:** comment to claim it, then open a pull request that says `Refs #<issue>`.
If an issue for planned work is missing one of these, say so on it. That is a bug in the issue.
## How we work with you
- We reply by naming what you reported, diagnosed or fixed.
- Your pull request merges as itself, with you as the author
([details](#how-your-contribution-lands)).
- You do not rebase around our changes, and a red check you did not cause is
ours to fix.
- We credit you in the changelog and in
[the contributors list](docs/CONTRIBUTORS.md).
- If something is not ready or not a fit, we say what is blocked and what would
change the answer.
Thank you for your interest in contributing to codewhale! This document provides guidelines and instructions for contributing.
## Getting Started
### Prerequisites
- Rust 1.89 or later (edition 2024)
- Rust 1.88 or later (edition 2024)
- Cargo package manager
- Git
@@ -115,43 +18,21 @@ If an issue for planned work is missing one of these, say so on it. That is a bu
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
@@ -164,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:
@@ -297,307 +64,194 @@ 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
## Agent-Assisted Improvements
Some architecture work is too large for one PR but still needs to be built in
dependent layers. For those changes, use this workflow:
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 first-class path for this loop
today, but the review shape matters more than the provider.
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.
## Working with an agent
Using an AI assistant is welcome and needs no disclosure. Codewhale is used to
work on Codewhale, and many fixes here began as a person pointing an agent at
an issue.
What makes agent-assisted work easy to merge is the same as for any other: a
specific reproduction, a small diff, the checks you ran, and a description of
the trade-off. Keep each pull request to one friction point, and put a list of
ideas for improvement in an issue.
Do not point an agent at auth, credentials, sandbox policy, publishing and
release plumbing, provider policy, telemetry, sponsorship, branding or global
prompts without a maintainer's agreement on the issue first.
Maintainers write some issues with the
[Agent task](.github/ISSUE_TEMPLATE/agent-task.yml) form so that an agent with
no other context can finish them; those carry the `agent-ready` label and are
open to anyone. Agents and maintainers follow
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md).
## How maintainers ship
Maintainers, and the agents working for them, land small verified slices
directly on `main` with the checks they ran in the commit message. Every push
to `main` runs a fast preflight and the full CI suite on Linux, macOS and
Windows. A release also needs a green `release-candidate.yml` run on the exact
commit being released; the 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).
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
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.
## 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 live data flow across
these crates, including the bottom-up build order.
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.
## Submitting Changes
## Writing the pull request
1. Create a feature branch from `main`:
```bash
git checkout -b feat/your-feature
```
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md): what and
why, where it came from, and how you tested it.
- Link the issue: `Closes #N` (or `Fixes` / `Resolves`) when the pull request
finishes it, `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.
- Keep it to one change. If you find related work, open an issue for it.
- If you add a layer, module or abstraction, say which one it replaces.
- Update the docs your change makes wrong.
- A draft is fine, and so is asking for direction before it is finished.
2. Make your changes and commit them
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
5. Describe your changes clearly in the PR description
## Pull Request Guidelines
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening
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
- 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/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/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/share.rs`, HTML rendering,
`gh gist create` integration, command registration.
- **#343/#346** — (v0.8.5) Runtime thread/turn timeline and durable task manager refactors.
Typically each PR touches 1–3 new files, modifies 2–5 existing files for wiring
(registries, dispatch matches, localization), and adds or updates tests. Changes
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:
```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.md) — for reproducible problems
or regressions
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) — for ideas and
improvements
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
Please do **not** open a public issue for a vulnerability. See
[SECURITY.md](.github/SECURITY.md) for private reporting.
If you discover a security vulnerability, please do **not** open a public issue.
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).
experience levels. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full
code of conduct.
## License
By contributing to Codewhale, you agree that your contributions will be
licensed under the MIT License.
By contributing to codewhale, you agree that your contributions will be licensed under the MIT License.
## Questions?
Ask in [Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or
on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer, Hunter, on
WeChat (`hunterbown`) and ask to join the Whale Brothers group. Everyone is
welcome.
Feel free to open an issue for any questions about contributing.
Generated
+1688 -4179
View File
File diff suppressed because it is too large. Load diff
+18 -129
View File
@@ -1,164 +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]
agent-client-protocol = { version = "=5.2.0", default-features = false }
agent-client-protocol-schema = { version = "=1.17.0", default-features = false }
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 -221
View File
@@ -1,285 +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 (**Accept Edits**) it edits files in the repository
without asking and asks before each command; `Shift+Tab` steps the
permission dial when you want it to ask more or less.
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 Accept Edits permission. One dial
switches both: `Shift+Tab` steps Ask → Accept Edits → Plan → Auto-Review →
Full Access.
> To rotate or remove a saved key: `codewhale auth clear --provider deepseek`.
| | Switch with | Options |
| --- | --- | --- |
| Mode | the Plan stop on `Shift+Tab`, or `/mode` | **Plan** reads and searches but changes nothing. **Work** (every other stop) 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 commands can reach anything on this computer; 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).
Codewhale is what it is because of its community, and all feedback is
welcome: a bug, an idea, or something that looked or felt wrong.
[Open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) for any
of these; an issue is also the best place to start a new feature. Pull requests
are welcome for fixes, translations, providers and planned work. The
[contributing guide](CONTRIBUTING.md) shows where each kind of help goes, and
the [roadmap](docs/ROADMAP.md) shows where the project is headed. 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.
Everyone is welcome.
## 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.*
File diff suppressed because it is too large. Load diff
-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 -1402
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;
Loaded 100 of 4867 files, more files were not shown because too many files have changed in this diff. Show more