Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a62337714 |
No files matched your search
@@ -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 = []
|
||||
@@ -1,253 +0,0 @@
|
||||
# CNB is a one-way mirror from GitHub. Keep this file source-controlled here;
|
||||
# CNB-side edits will be overwritten by the GitHub -> CNB sync workflow.
|
||||
|
||||
.feishu_bridge_tests: &feishu_bridge_tests
|
||||
name: feishu bridge tests
|
||||
runner:
|
||||
tags: cnb:arch:amd64
|
||||
cpus: 8
|
||||
docker:
|
||||
image: node:22-bookworm
|
||||
stages:
|
||||
- name: feishu bridge tests
|
||||
script: |
|
||||
set -eu
|
||||
cd integrations/feishu-bridge
|
||||
npm ci
|
||||
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:
|
||||
tags: cnb:arch:amd64
|
||||
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: |
|
||||
set -eu
|
||||
apt-get update
|
||||
apt-get install -y git libdbus-1-dev nodejs npm pkg-config
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
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
|
||||
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
|
||||
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."
|
||||
|
||||
.linux_release_preflight: &linux_release_preflight
|
||||
name: linux release preflight
|
||||
runner:
|
||||
tags: cnb:arch:amd64
|
||||
cpus: 16
|
||||
docker:
|
||||
image: rust:1.88-bookworm
|
||||
stages:
|
||||
- name: install release dependencies
|
||||
script: |
|
||||
set -eu
|
||||
apt-get update
|
||||
apt-get install -y curl git libdbus-1-dev nodejs npm pkg-config
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup component add rustfmt clippy
|
||||
fi
|
||||
|
||||
- *rust_workspace_gates_stage
|
||||
|
||||
- name: crate publish dry-run
|
||||
script: |
|
||||
set -eu
|
||||
./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
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
node scripts/release/npm-wrapper-smoke.js
|
||||
./target/release/codewhale --version
|
||||
./target/release/codew --version
|
||||
|
||||
main:
|
||||
push:
|
||||
- *feishu_bridge_tests
|
||||
- *linux_rust_gates
|
||||
|
||||
"(fix/*|rebrand/*)":
|
||||
push:
|
||||
- *linux_rust_gates
|
||||
|
||||
"work/v*":
|
||||
push:
|
||||
- *feishu_bridge_tests
|
||||
- *linux_release_preflight
|
||||
|
||||
$:
|
||||
tag_push:
|
||||
- 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
|
||||
script: |
|
||||
set -eu
|
||||
|
||||
apt-get update
|
||||
apt-get install -y git musl-tools nodejs pkg-config
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
./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
|
||||
|
||||
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
|
||||
strip \
|
||||
target/cnb-release/codewhale-linux-x64 \
|
||||
target/cnb-release/codew-linux-x64 \
|
||||
target/cnb-release/codewhale-tui-linux-x64 \
|
||||
|| true
|
||||
|
||||
(
|
||||
cd target/cnb-release
|
||||
sha256sum \
|
||||
codewhale-linux-x64 \
|
||||
codew-linux-x64 \
|
||||
codewhale-tui-linux-x64 \
|
||||
> codewhale-artifacts-sha256.txt
|
||||
)
|
||||
|
||||
tag_name="${CNB_BRANCH:-}"
|
||||
if [ -z "$tag_name" ]; then
|
||||
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
|
||||
{
|
||||
echo "# ${tag_name:-CNB release}"
|
||||
echo
|
||||
awk -v version="${version}" '
|
||||
index($0, "## [" version "]") == 1 { in_section = 1; next }
|
||||
in_section && /^## \[/ { exit }
|
||||
in_section { print }
|
||||
' CHANGELOG.md
|
||||
echo
|
||||
echo "Built by CNB from ${commit_sha}."
|
||||
echo
|
||||
echo "Assets:"
|
||||
echo "- codewhale-linux-x64"
|
||||
echo "- codew-linux-x64"
|
||||
echo "- codewhale-tui-linux-x64 (v0.9.4 compatibility alias)"
|
||||
echo "- codewhale-artifacts-sha256.txt"
|
||||
} > target/cnb-release/CNB_RELEASE.md
|
||||
|
||||
- name: create cnb release
|
||||
type: git:release
|
||||
options:
|
||||
descriptionFromFile: target/cnb-release/CNB_RELEASE.md
|
||||
latest: true
|
||||
|
||||
- name: upload linux x64 release assets
|
||||
image: cnbcool/attachments:latest
|
||||
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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "CodeWhale",
|
||||
"build": {
|
||||
"context": "..",
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"RUST_VERSION": "1.88"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"vadimcn.vscode-lldb"
|
||||
],
|
||||
"settings": {
|
||||
"rust-analyzer.cargo.features": "all",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}",
|
||||
"CARGO_TARGET_DIR": "/home/codewhale/.cargo/target"
|
||||
},
|
||||
"mounts": [
|
||||
"source=codewhale-state,target=/home/codewhale/.codewhale,type=volume",
|
||||
"source=codewhale-cargo-target,target=/home/codewhale/.cargo/target,type=volume"
|
||||
],
|
||||
"postCreateCommand": "cargo build --locked",
|
||||
"remoteUser": "codewhale"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
*.pdb
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.rlib
|
||||
|
||||
# Sensitive environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Development
|
||||
/node_modules/
|
||||
/.vscode/
|
||||
/.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
/.git/
|
||||
/.gitignore
|
||||
/.gitattributes
|
||||
|
||||
# CI/CD
|
||||
/.github/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Generated
|
||||
/outputs/
|
||||
/tmp/
|
||||
|
||||
# Local runtime state
|
||||
/.deepseek/
|
||||
|
||||
# Claude Code artifacts
|
||||
/.claude/
|
||||
/.ace-tool/
|
||||
|
||||
# Documentation (not needed at runtime), except schemas embedded via include_str!
|
||||
/docs/
|
||||
!/docs/schemas/
|
||||
!/docs/schemas/**
|
||||
/website/
|
||||
/*.md
|
||||
!/README.md
|
||||
!/CHANGELOG.md
|
||||
|
||||
# Assets (screenshots, etc.)
|
||||
/assets/
|
||||
|
||||
# Scripts
|
||||
/scripts/
|
||||
|
||||
# Development configs
|
||||
/.devcontainer/
|
||||
/config.example.toml
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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 API (default provider)
|
||||
# Get an API key from DeepSeek, then keep it local in `.env`.
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
# NVIDIA NIM-hosted models
|
||||
# NVIDIA_API_KEY=
|
||||
# NVIDIA_NIM_API_KEY=
|
||||
|
||||
# AtlasCloud OpenAI-compatible endpoint
|
||||
# ATLASCLOUD_API_KEY=
|
||||
|
||||
# Mistral AI (la Plateforme) — https://console.mistral.ai/api-keys
|
||||
# MISTRAL_API_KEY=
|
||||
|
||||
# Codewhale API — account key (cwc_key_…) with the models:infer scope
|
||||
# https://app.codewhale.net/settings?section=api
|
||||
# CODEWHALE_API_KEY=
|
||||
@@ -1,86 +0,0 @@
|
||||
# Ensure LF line endings for files consumed by include_str!() on all platforms.
|
||||
# include_str!() preserves raw bytes; CRLF breaks substring assertions and
|
||||
# produces different compiled binaries on Windows vs Linux/macOS.
|
||||
# Cover nested mode/approval/personality prompts — a top-level *.md glob alone
|
||||
# left modes/*.md on CRLF under Windows autocrlf and blew the agent token budget.
|
||||
crates/engine/src/prompts/**/*.md text eol=lf
|
||||
crates/engine/src/prompts/*.md text eol=lf
|
||||
crates/engine/src/prompts/*.txt text eol=lf
|
||||
|
||||
# The rest of the include_str!() inputs. `* text=auto` below converts these to
|
||||
# CRLF on a Windows checkout, which is invisible for the ones that are only
|
||||
# parsed or substring-matched and fatal for the one that is compared byte for
|
||||
# byte: crates/telemetry/tests/golden/v1.json is asserted against
|
||||
# serde_json::to_string_pretty output, which always emits LF, so Windows CI
|
||||
# failed `golden_payload_v1` with a pure \r\n diff.
|
||||
crates/telemetry/tests/golden/*.json text eol=lf
|
||||
# Generated pet native artifacts are regenerated in memory and compared byte
|
||||
# for byte by `npm --prefix pet run check` (pet/scripts/build-pet.mjs), and the
|
||||
# TUI copy is embedded with include_str!() (pet_watch/owner.rs). The generator
|
||||
# emits LF, so a CRLF Windows checkout reported all three as stale and would
|
||||
# embed different bytes.
|
||||
crates/tui/src/tui/pet_watch/pet-native.js text eol=lf
|
||||
pet/ios/Resources/pet-native.js text eol=lf
|
||||
pet/ios/Resources/demo.jsonl text eol=lf
|
||||
# Tideline component goldens (crates/tui/src/tui/goldens/*.txt) are
|
||||
# fs::read_to_string'd and compared byte for byte against buffer dumps that
|
||||
# always join rows with LF (golden_harness.rs::render_golden_text), so a
|
||||
# CRLF checkout failed all eight Windows golden tests with a pure \r\n diff.
|
||||
crates/tui/src/tui/goldens/*.txt text eol=lf
|
||||
# FEAT-025 baseline export goldens (crates/tui/src/commands/fixtures/*.md) are
|
||||
# include_str!()'d and compared byte for byte against a document the exporter
|
||||
# builds with LF, so a CRLF checkout failed the four Windows golden tests with a
|
||||
# pure \r\n diff (Windows CI caught this; Linux cannot see it).
|
||||
crates/tui/src/commands/fixtures/*.md text eol=lf
|
||||
# FEAT-029 debug diagnostics baselines (crates/tui/src/commands/fixtures/
|
||||
# diagnostics/*) are compared byte for byte against command output joined
|
||||
# with LF, so a CRLF checkout failed every Windows baseline parity test with a
|
||||
# pure \r\n diff.
|
||||
crates/tui/src/commands/fixtures/diagnostics/* text eol=lf
|
||||
# Conformance fixtures are recorded wire bytes (SSE framing is line-exact) and
|
||||
# goldens compared byte for byte; a CRLF checkout must not rewrite either.
|
||||
crates/tui/tests/fixtures/conformance/** text eol=lf
|
||||
crates/*/assets/**/*.json text eol=lf
|
||||
crates/*/assets/**/*.md text eol=lf
|
||||
crates/*/locales/*.json text eol=lf
|
||||
workflows/*.js text eol=lf
|
||||
# Executable documentation: the Fleet tutorial's JSON fence is parsed by the
|
||||
# task-spec contract test, so its bytes must agree on Windows and Unix.
|
||||
docs/FLEET_WORKFLOW_TUTORIAL.md text eol=lf
|
||||
# The dsh bundle scene is include_str!() into the generated client.js and
|
||||
# hashed for stale detection; CRLF would change both across platforms.
|
||||
crates/tui/src/integrations/dsh/*.js text eol=lf
|
||||
# The native DSH import fixture bundle is hashed into the conversion receipt,
|
||||
# so a CRLF checkout changed the expected digest on Windows.
|
||||
scripts/fixtures/dsh-web-app/** text eol=lf
|
||||
|
||||
# The extension host's generated protocol TS is compared byte for byte with
|
||||
# the generator's LF output (protocol/tests.rs), and its dist bundle is
|
||||
# include_bytes!()'d and digest-checked at launch; a CRLF Windows checkout
|
||||
# failed the former and would embed different bytes.
|
||||
crates/engine/extension-host/src/protocol.generated.ts text eol=lf
|
||||
crates/engine/extension-host/dist/** text eol=lf
|
||||
# Reviewed extension-host fixtures carry exact source SHA-256 receipts.
|
||||
# A Windows checkout must not rewrite their admitted source closure to CRLF.
|
||||
crates/engine/tests/fixtures/extension_host/** text eol=lf
|
||||
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
|
||||
|
||||
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
|
||||
*.rs text eol=lf
|
||||
|
||||
# Release shell scripts are invoked directly by bash on Windows
|
||||
# checkouts; CRLF turns `set -euo pipefail` into an invalid option.
|
||||
scripts/release/*.sh text eol=lf
|
||||
|
||||
# Keep repository attributes themselves stable on every platform.
|
||||
.gitattributes text eol=lf
|
||||
|
||||
# Everything else auto-detects (default).
|
||||
* text=auto
|
||||
|
||||
# Preserve the qualified upstream font license, including its trailing space.
|
||||
web/public/brand/fonts/OFL.txt text eol=lf whitespace=-blank-at-eol
|
||||
|
||||
# Generated protocol and SDK exports are compared byte for byte; keep LF on Windows checkouts.
|
||||
crates/app-server-protocol/schema/** text eol=lf
|
||||
npm/runtime-sdk/generated/** text eol=lf
|
||||
@@ -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
|
||||
@@ -1,279 +0,0 @@
|
||||
# Contributor credit identity map.
|
||||
#
|
||||
# Format:
|
||||
# alias = Display Name <[email protected]>
|
||||
#
|
||||
# The right-hand side must use GitHub's numeric noreply address so a harvested
|
||||
# commit authored with it links to the contributor's account. Only commit
|
||||
# authorship puts someone in the repository's contributor list; a co-author
|
||||
# trailer shows on their profile but not there. The left-hand side may be a
|
||||
# GitHub login, old-style noreply address, raw email from a contributor commit,
|
||||
# or local machine email seen in older harvested history.
|
||||
|
||||
hmbown = Hmbown <[email protected]>
|
||||
[email protected] = Hmbown <[email protected]>
|
||||
fleitz = Fred Leitz <[email protected]>
|
||||
[email protected] = Fred Leitz <[email protected]>
|
||||
[email protected] = Fred Leitz <[email protected]>
|
||||
Angel-Hair = Lu Shihan <[email protected]>
|
||||
[email protected] = Lu Shihan <[email protected]>
|
||||
dmitri-0 = dmitri-0 <[email protected]>
|
||||
[email protected] = dmitri-0 <[email protected]>
|
||||
SparkofSpike = Sh1Zuku <[email protected]>
|
||||
zhangweiii = zhangweiii <[email protected]>
|
||||
[email protected] = zhangweiii <[email protected]>
|
||||
sternelee = Sterne Lee <[email protected]>
|
||||
[email protected] = Sterne Lee <[email protected]>
|
||||
Mr-Moon121 = Jeffrey Luna <[email protected]>
|
||||
lerugray = Ray Weiss <[email protected]>
|
||||
[email protected] = Ray Weiss <[email protected]>
|
||||
reidliu41 = reidliu41 <[email protected]>
|
||||
[email protected] = reidliu41 <[email protected]>
|
||||
ousamabenyounes = Ben Younes <[email protected]>
|
||||
[email protected] = Ben Younes <[email protected]>
|
||||
ljm3790865 = ljm3790865 <[email protected]>
|
||||
HUQIANTAO = HUQIANTAO <[email protected]>
|
||||
Hu Qiantao = HUQIANTAO <[email protected]>
|
||||
[email protected] = HUQIANTAO <[email protected]>
|
||||
[email protected] = HUQIANTAO <[email protected]>
|
||||
[email protected] = HUQIANTAO <[email protected]>
|
||||
punkcanyang = Punkcan Yang <[email protected]>
|
||||
Punkcan Yang = Punkcan Yang <[email protected]>
|
||||
[email protected] = Punkcan Yang <[email protected]>
|
||||
merchloubna70-dot = merchloubna70-dot <[email protected]>
|
||||
h3c-hexin = h3c-hexin <[email protected]>
|
||||
[email protected] = h3c-hexin <[email protected]>
|
||||
[email protected] = h3c-hexin <[email protected]>
|
||||
axobase001 = axobase001 <[email protected]>
|
||||
donglovejava = donglovejava <[email protected]>
|
||||
Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
|
||||
idling11 = idling11 <[email protected]>
|
||||
Hanmiao Li = idling11 <[email protected]>
|
||||
[email protected] = idling11 <[email protected]>
|
||||
angziii = angziii <[email protected]>
|
||||
aboimpinto = aboimpinto <[email protected]>
|
||||
Paulo Aboim Pinto = aboimpinto <[email protected]>
|
||||
[email protected] = aboimpinto <[email protected]>
|
||||
[email protected] = aboimpinto <[email protected]>
|
||||
encyc = encyc <[email protected]>
|
||||
Duducoco = Duducoco <[email protected]>
|
||||
cyq1017 = cyq1017 <[email protected]>
|
||||
cyq = cyq1017 <[email protected]>
|
||||
[email protected] = cyq1017 <[email protected]>
|
||||
cy2311 = CY <[email protected]>
|
||||
noaft = Toan Nguyen <[email protected]>
|
||||
[email protected] = Toan Nguyen <[email protected]>
|
||||
zlh124 = zlh124 <[email protected]>
|
||||
LeoLin990405 = LeoLin990405 <[email protected]>
|
||||
THINKER-ONLY = THINKER-ONLY <[email protected]>
|
||||
nightt5879 = nightt5879 <[email protected]>
|
||||
LmeSzinc = LmeSzinc <[email protected]>
|
||||
Lstarsky0 = Lstarsky0 <[email protected]>
|
||||
RepentStar = RepentStar <[email protected]>
|
||||
CCChisato = Fushimi Rio <[email protected]>
|
||||
aznikline = aznikline <[email protected]>
|
||||
Aznable = aznikline <[email protected]>
|
||||
[email protected] = aznikline <[email protected]>
|
||||
Liu-Vince = Liu-Vince <[email protected]>
|
||||
Vince = Liu-Vince <[email protected]>
|
||||
[email protected] = Liu-Vince <[email protected]>
|
||||
LI-Jialu = LI-Jialu <[email protected]>
|
||||
JiarenWang = JiarenWang <[email protected]>
|
||||
wdw8276 = wdw8276 <[email protected]>
|
||||
pengyou200902 = pengyou200902 <[email protected]>
|
||||
linzhiqin2003 = linzhiqin2003 <[email protected]>
|
||||
LING71671 = LING71671 <[email protected]>
|
||||
JasonOA888 = JasonOA888 <[email protected]>
|
||||
Inference1 = Inference1 <[email protected]>
|
||||
hongqitai = hongqitai <[email protected]>
|
||||
gordonlu = gordonlu <[email protected]>
|
||||
angus-guo = gus <[email protected]>
|
||||
[email protected] = gus <[email protected]>
|
||||
gaord = gaord <[email protected]>
|
||||
Ben Gao = gaord <[email protected]>
|
||||
[email protected] = gaord <[email protected]>
|
||||
MXAntian = MXAntian <[email protected]>
|
||||
[email protected] = MXAntian <[email protected]>
|
||||
DarrellThomas = Darrell Thomas <[email protected]>
|
||||
[email protected] = Darrell Thomas <[email protected]>
|
||||
taixinguo = Taixin Guo <[email protected]>
|
||||
Taixin Guo = Taixin Guo <[email protected]>
|
||||
JayBeest = JayBeest <[email protected]>
|
||||
jcorneli = JayBeest <[email protected]>
|
||||
[email protected] = JayBeest <[email protected]>
|
||||
lucaszhu-hue = lucaszhu-hue <[email protected]>
|
||||
zhuangbiaowei = zhuangbiaowei <[email protected]>
|
||||
yuanchenglu = yuanchenglu <[email protected]>
|
||||
Vishnu1837 = Vishnu1837 <[email protected]>
|
||||
sximelon = sximelon <[email protected]>
|
||||
Sskift = Sskift <[email protected]>
|
||||
New2Niu = New2Niu <[email protected]>
|
||||
mvanhorn = mvanhorn <[email protected]>
|
||||
MengZ-super = MengZ-super <[email protected]>
|
||||
membphis = membphis <[email protected]>
|
||||
LeoAlex0 = LeoAlex0 <[email protected]>
|
||||
Lee-take = Lee-take <[email protected]>
|
||||
lbcheng888 = lbcheng888 <[email protected]>
|
||||
kunpeng-ai-lab = kunpeng-ai-lab <[email protected]>
|
||||
elowen53 = elowen53 <[email protected]>
|
||||
Elowen = elowen53 <[email protected]>
|
||||
[email protected] = elowen53 <[email protected]>
|
||||
CrepuscularIRIS = CrepuscularIRIS <[email protected]>
|
||||
chnjames = chnjames <[email protected]>
|
||||
ChaceLyee2101 = ChaceLyee2101 <[email protected]>
|
||||
ci4ic4 = ci4ic4 <[email protected]>
|
||||
Chavdar Ivanov = ci4ic4 <[email protected]>
|
||||
[email protected] = ci4ic4 <[email protected]>
|
||||
yusufgurdogan = yusufgurdogan <[email protected]>
|
||||
Yusuf Gurdogan = yusufgurdogan <[email protected]>
|
||||
hotelswith = yusufgurdogan <[email protected]>
|
||||
[email protected] = yusufgurdogan <[email protected]>
|
||||
AresNing = AresNing <[email protected]>
|
||||
|
||||
shenjackyuanjie = shenjackyuanjie <[email protected]>
|
||||
shenjack = shenjackyuanjie <[email protected]>
|
||||
[email protected] = shenjackyuanjie <[email protected]>
|
||||
xyuai = xyuai <[email protected]>
|
||||
AdityaVG13 = AdityaVG13 <[email protected]>
|
||||
[email protected] = AdityaVG13 <[email protected]>
|
||||
Implementist = Implementist <[email protected]>
|
||||
implecao = Implementist <[email protected]>
|
||||
[email protected] = Implementist <[email protected]>
|
||||
jrcjrcc = jrcjrcc <[email protected]>
|
||||
[email protected] = jrcjrcc <[email protected]>
|
||||
RefuseOdd = RefuseOdd <[email protected]>
|
||||
wywsoor = wywsoor <[email protected]>
|
||||
hsdbeebou = hsdbeebou <[email protected]>
|
||||
tdccccc = tdccccc <[email protected]>
|
||||
greyfreedom = greyfreedom <[email protected]>
|
||||
[email protected] = greyfreedom <[email protected]>
|
||||
puneetdixit200 = puneetdixit200 <[email protected]>
|
||||
roian6 = Chanhyo Jung <[email protected]>
|
||||
[email protected] = Chanhyo Jung <[email protected]>
|
||||
yekern = Stime <[email protected]>
|
||||
Stime = Stime <[email protected]>
|
||||
pkeging = pkeging <[email protected]>
|
||||
[email protected] = pkeging <[email protected]>
|
||||
findshan = Wenshan Deng <[email protected]>
|
||||
[email protected] = Wenshan Deng <[email protected]>
|
||||
KUK4 = KUK4 <[email protected]>
|
||||
[email protected] = KUK4 <[email protected]>
|
||||
|
||||
# Harvest-credit reconciliation: contributors restored to docs/CONTRIBUTORS.md
|
||||
# whose harvested work previously lacked a canonical map entry.
|
||||
GTC2080 = TaoMu <[email protected]>
|
||||
[email protected] = TaoMu <[email protected]>
|
||||
MMMarcinho = MMMarcinho <[email protected]>
|
||||
MeAiRobot = MeAiRobot <[email protected]>
|
||||
NorethSea = NorethSea <[email protected]>
|
||||
SamhandsomeLee = SamhandsomeLee <[email protected]>
|
||||
YaYII = YaYII <[email protected]>
|
||||
sandofree = sandofree <[email protected]>
|
||||
tiger-dog = tiger-dog <[email protected]>
|
||||
Jianfengwu2024 = Jianfengwu2024 <[email protected]>
|
||||
wplll = wplll <[email protected]>
|
||||
buko = buko <[email protected]>
|
||||
|
||||
# Machine-credit reconciliation follow-up: contributors credited in prose or
|
||||
# harvested by handle but missing from AUTHOR_MAP. Logins/IDs verified against
|
||||
# the GitHub user API.
|
||||
1Git2Clone = 1Git2Clone <[email protected]>
|
||||
jieshu666 = jieshu666 <[email protected]>
|
||||
rockeverm3m = rockeverm3m <[email protected]>
|
||||
[email protected] = rockeverm3m <[email protected]>
|
||||
wavezhang = wavezhang <[email protected]>
|
||||
hxy91819 = hxy91819 <[email protected]>
|
||||
[email protected] = hxy91819 <[email protected]>
|
||||
heloanc = heloanc <[email protected]>
|
||||
[email protected] = heloanc <[email protected]>
|
||||
bistack = Sun Zhenyuan <[email protected]>
|
||||
[email protected] = Sun Zhenyuan <[email protected]>
|
||||
OctoBored = OctoBored <[email protected]>
|
||||
skyzhao1223 = SKY ZHAO <[email protected]>
|
||||
zhaotian1 = SKY ZHAO <[email protected]>
|
||||
[email protected] = SKY ZHAO <[email protected]>
|
||||
vFONGv = Matthew.Fong <[email protected]>
|
||||
[email protected] = Matthew.Fong <[email protected]>
|
||||
luismateusvargas = Luis Mateus Vargas <[email protected]>
|
||||
redjade75723 = redjade75723 <[email protected]>
|
||||
|
||||
# v0.8.68 underwater-lane harvests.
|
||||
moduvoice = moduvoice <[email protected]>
|
||||
[email protected] = moduvoice <[email protected]>
|
||||
qinlinwang = qinlinwang <[email protected]>
|
||||
hange = qinlinwang <[email protected]>
|
||||
[email protected] = qinlinwang <[email protected]>
|
||||
knqiufan = knqiufan <[email protected]>
|
||||
[email protected] = knqiufan <[email protected]>
|
||||
|
||||
# v0.9.1 Windows TUI reports harvested into maintainer fixes.
|
||||
SparkofSpike = Sh1Zuku <[email protected]>
|
||||
Sh1Zuku = Sh1Zuku <[email protected]>
|
||||
|
||||
# v0.9.1 provider harvests.
|
||||
baendlorel = baendlorel <[email protected]>
|
||||
[email protected] = baendlorel <[email protected]>
|
||||
EvanProgramming = Evan <[email protected]>
|
||||
adity982 = ADITYA <[email protected]>
|
||||
vibecoding-skills = Harsh Dattani <[email protected]>
|
||||
XhesicaFrost = XhesicaFrost <[email protected]>
|
||||
ffaacceelee = ffaacceelee <[email protected]>
|
||||
mky = mky <[email protected]>
|
||||
cacdcaecawae = cacdcaecawae <[email protected]>
|
||||
XiaoHuo888-hue = XiaoHuo888-hue <[email protected]>
|
||||
[email protected] = XiaoHuo888-hue <[email protected]>
|
||||
wuisabel-gif = Isabel Wu <[email protected]>
|
||||
redstar = Kai Nacke <[email protected]>
|
||||
[email protected] = Kai Nacke <[email protected]>
|
||||
Shizuku = Sh1Zuku <[email protected]>
|
||||
[email protected] = Sh1Zuku <[email protected]>
|
||||
musichen = Alex Musichen <[email protected]>
|
||||
[email protected] = Alex Musichen <[email protected]>
|
||||
M-Maciej = M-Maciej <[email protected]>
|
||||
serephus = Serephus <[email protected]>
|
||||
# [email protected] = UNRESOLVED — 21 commits on main via #704; GitHub login not yet identified
|
||||
qiuYliangM = 秋月凉梦 <[email protected]>
|
||||
[email protected] = 秋月凉梦 <[email protected]>
|
||||
whp233 = whp233 <[email protected]>
|
||||
[email protected] = whp233 <[email protected]>
|
||||
Gabriel-Degret = Gabriel Degret <[email protected]>
|
||||
[email protected] = Gabriel Degret <[email protected]>
|
||||
huangxianzhan = huangxianzhan <[email protected]>
|
||||
[email protected] = huangxianzhan <[email protected]>
|
||||
zhuowp = zhuowp <[email protected]>
|
||||
[email protected] = zhuowp <[email protected]>
|
||||
z27014 = zhuowp <[email protected]>
|
||||
[email protected] = zhuowp <[email protected]>
|
||||
[email protected] = 秋月凉梦 <[email protected]>
|
||||
|
||||
asto18089 = asto18089 <[email protected]>
|
||||
xiechimon = Xmon Dai <[email protected]>
|
||||
yrk111222 = Rongkun Yan <[email protected]>
|
||||
VincentCorleone = Vincent Corleone <[email protected]>
|
||||
Vincent Corleone = Vincent Corleone <[email protected]>
|
||||
[email protected] = Vincent Corleone <[email protected]>
|
||||
yetuge = yetuge <[email protected]>
|
||||
[email protected] = yetuge <[email protected]>
|
||||
Serendo = Serendo <[email protected]>
|
||||
[email protected] = Serendo <[email protected]>
|
||||
dajiaohuang = dajiaohuang <[email protected]>
|
||||
[email protected] = dajiaohuang <[email protected]>
|
||||
Wu Shuwen = dajiaohuang <[email protected]>
|
||||
[email protected] = dajiaohuang <[email protected]>
|
||||
harryvgiunta = Harry Giunta <[email protected]>
|
||||
[email protected] = Harry Giunta <[email protected]>
|
||||
xiaoray-blip = Arlo <[email protected]>
|
||||
Arlo = Arlo <[email protected]>
|
||||
xyzs996 = Mark Yan <[email protected]>
|
||||
[email protected] = Mark Yan <[email protected]>
|
||||
Mark Yan = Mark Yan <[email protected]>
|
||||
henrikdukefoss = Henrik Bakke Dukefoss <[email protected]>
|
||||
Henrik Bakke Dukefoss = Henrik Bakke Dukefoss <[email protected]>
|
||||
[email protected] = Henrik Bakke Dukefoss <[email protected]>
|
||||
|
||||
[email protected] = Dhruv Singal <[email protected]>
|
||||
[email protected] = Boyce <[email protected]>
|
||||
Boyce = Boyce <[email protected]>
|
||||
Felixkw12 = Felix Wunderlich <[email protected]>
|
||||
[email protected] = Felix Wunderlich <[email protected]>
|
||||
Felix Wunderlich = Felix Wunderlich <[email protected]>
|
||||
@@ -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.
|
||||
@@ -1,76 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
|
||||
Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).
|
||||
@@ -1,62 +0,0 @@
|
||||
# Berkontribusi pada Codewhale
|
||||
|
||||
Terima kasih atas minat Anda untuk berkontribusi pada Codewhale! Dokumen ini memberikan panduan dan instruksi untuk berkontribusi.
|
||||
|
||||
---
|
||||
|
||||
## Memulai (Getting Started)
|
||||
|
||||
### Prasyarat
|
||||
|
||||
- Rust 1.88 atau lebih baru (edisi 2024)
|
||||
- Pengelola paket Cargo
|
||||
- Git
|
||||
|
||||
### Menyiapkan Lingkungan Pengembangan
|
||||
|
||||
1. Fork dan klon repositori:
|
||||
```bash
|
||||
git clone https://github.com/USERNAME_ANDA/CodeWhale.git
|
||||
cd CodeWhale
|
||||
```
|
||||
|
||||
2. Kompilasi proyek:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
3. Jalankan pengujian (tests):
|
||||
```bash
|
||||
cargo test --workspace --all-features
|
||||
```
|
||||
|
||||
4. Jalankan dalam mode pengembangan:
|
||||
```bash
|
||||
cargo run --bin codewhale
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alur Kerja Pengembangan
|
||||
|
||||
### Gaya Kode (Code Style)
|
||||
|
||||
- Jalankan `cargo fmt` sebelum melakukan commit untuk memastikan format kode konsisten.
|
||||
- Jalankan `cargo clippy` dan atasi seluruh peringatan.
|
||||
- Ikuti konvensi penamaan Rust (`snake_case` untuk fungsi/variabel, `CamelCase` untuk tipe data/struct).
|
||||
- Tambahkan komentar dokumentasi untuk API publik.
|
||||
|
||||
### Pengujian
|
||||
|
||||
- Tulis pengujian untuk fungsionalitas baru.
|
||||
- Pastikan seluruh pengujian yang ada lulus: `cargo test --workspace --all-features`.
|
||||
- Tempatkan unit test di samping kode yang diuji (`#[cfg(test)]`), serta tambahkan pengujian integrasi di bawah direktori `tests/` pada crate yang bersangkutan.
|
||||
|
||||
---
|
||||
|
||||
## Pengajuan Pull Request (PR)
|
||||
|
||||
1. Buat branch baru dari `main` untuk fitur atau perbaikan Anda.
|
||||
2. Pastikan `cargo fmt`, `cargo clippy`, dan pengujian lulus sebelum mengajukan PR.
|
||||
3. Tulis judul dan deskripsi PR yang jelas yang menjelaskan tujuan perubahan.
|
||||
4. Setiap kontributor akan mendapatkan kredit dalam changelog dan `docs/CONTRIBUTORS.md`.
|
||||
@@ -1,2 +0,0 @@
|
||||
github: [Hmbown]
|
||||
buy_me_a_coffee: hmbown
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or regression
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
## Actual behavior
|
||||
|
||||
## Environment
|
||||
|
||||
- OS:
|
||||
- DeepSeek CLI version:
|
||||
- Model:
|
||||
- Shell:
|
||||
|
||||
## Logs or screenshots
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something is broken or behaves wrong. 报告问题。
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. English or 中文 are both fine.
|
||||
|
||||
- Security problem? Do not post it here. Use a [private advisory](https://github.com/codewhale-hq/CodeWhale/security/advisories/new).
|
||||
- Already reported? Add a 👍 to that issue instead of opening a new one.
|
||||
- Remove API keys and tokens from anything you paste.
|
||||
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate. 我已搜索过现有 issue。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened? 发生了什么?
|
||||
description: What you saw, and what you expected instead. Paste error text as text, not only a screenshot.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce 复现步骤
|
||||
description: Exact commands, key presses, or prompts. Shorter is better.
|
||||
placeholder: |
|
||||
1. Run `codewhale` in a git repository
|
||||
2. Type ...
|
||||
3. See ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Codewhale version 版本
|
||||
description: Output of `codewhale --version`. Desktop app — the version in About.
|
||||
placeholder: codewhale 0.10.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where did it happen? 在哪里出现?
|
||||
multiple: true
|
||||
options:
|
||||
- TUI (terminal interface)
|
||||
- CLI command (codewhale exec, codewhale doctor, ...)
|
||||
- Desktop app
|
||||
- Web (codewhale web or app.codewhale.net)
|
||||
- Plugins / extension host
|
||||
- MCP
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system 操作系统
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- WSL
|
||||
- Android (Termux)
|
||||
- HarmonyOS
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: channel
|
||||
attributes:
|
||||
label: How did you get Codewhale? 获取方式
|
||||
options:
|
||||
- Official script (codewhale.net/install.sh)
|
||||
- GitHub Releases download
|
||||
- Desktop app download
|
||||
- npm
|
||||
- Homebrew
|
||||
- Cargo (crates.io)
|
||||
- Built from a git checkout
|
||||
- Scoop / Nix / Docker / other package
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Provider and model 提供商和模型
|
||||
description: As shown by `/model`. Leave empty if it does not matter.
|
||||
placeholder: "provider / model id"
|
||||
|
||||
- type: input
|
||||
id: terminal
|
||||
attributes:
|
||||
label: Terminal and shell 终端
|
||||
description: Only for terminal problems. Mention tmux, zellij, or screen if you use one.
|
||||
placeholder: iTerm2 + zsh, Windows Terminal + PowerShell, VS Code terminal + bash
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: codewhale doctor (optional)
|
||||
description: Run `codewhale doctor` and paste the relevant lines. Check it for secrets first.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Logs, screenshots, anything else (optional)
|
||||
description: Logs, a screenshot or recording, config snippets, or a guess at the cause.
|
||||
@@ -1,11 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/codewhale-hq/CodeWhale/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue. See SECURITY.md.
|
||||
- name: Ask a question
|
||||
url: https://github.com/codewhale-hq/CodeWhale/discussions
|
||||
about: How-to questions and ideas you want to talk through. 提问和讨论。
|
||||
- name: Discord
|
||||
url: https://discord.gg/37gfS3ksug
|
||||
about: Chat with the community and maintainers.
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
## Proposed solution
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
## Additional context
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea or an improvement. 功能建议。
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the idea. English or 中文 are both fine. Short is good.
|
||||
|
||||
If someone already asked for this, add a 👍 to their issue instead.
|
||||
Want to talk it through first? Use [Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or [Discord](https://discord.gg/37gfS3ksug).
|
||||
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate. 我已搜索过现有 issue。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this solve? 要解决什么问题?
|
||||
description: What are you trying to do, and what gets in the way today?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What should Codewhale do? 希望 Codewhale 怎么做?
|
||||
description: The behavior you want. Commands, key presses, or config are welcome.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where? 在哪里?
|
||||
multiple: true
|
||||
options:
|
||||
- TUI (terminal interface)
|
||||
- CLI command (codewhale exec, ...)
|
||||
- Desktop app
|
||||
- Web (codewhale web or app.codewhale.net)
|
||||
- Plugins / extension host
|
||||
- MCP
|
||||
- Providers and models
|
||||
- Docs
|
||||
- Other / not sure
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Workarounds or other tools (optional)
|
||||
description: What you do today, or how another tool handles it.
|
||||
|
||||
- type: checkboxes
|
||||
id: contribute
|
||||
attributes:
|
||||
label: Contributing
|
||||
options:
|
||||
- label: I would like to open a pull request for this. 我愿意提交 PR。
|
||||
@@ -1,24 +1,13 @@
|
||||
<!-- Thanks for the PR! English or 中文 are both fine. Short is good. -->
|
||||
## Summary
|
||||
|
||||
## What and why
|
||||
## Testing
|
||||
|
||||
<!-- What does this change, and why? A few sentences is enough. -->
|
||||
|
||||
## Related issue (optional)
|
||||
|
||||
<!-- Closes #N if this finishes it, Refs #N if related. Skip it if there is none.
|
||||
Please do not write a closing word inside a negation ("this does not close #N"):
|
||||
GitHub closes the issue anyway. -->
|
||||
|
||||
## How I tested it
|
||||
|
||||
<!-- Commands you ran and what they showed. Test what you changed, for example
|
||||
`scripts/dev-test.sh tui <filter>`. A few quick checks run on your PR; if one
|
||||
fails for a reason unrelated to your change, a maintainer will deal with it.
|
||||
For visible UI changes, add a screenshot or recording. -->
|
||||
- [ ] `cargo test --all-features`
|
||||
- [ ] `cargo fmt --all -- --check`
|
||||
- [ ] `cargo clippy --all-targets --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
|
||||
@@ -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.*
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/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.
|
||||
#
|
||||
# Expected environment:
|
||||
# TAG – git tag, e.g. "v0.8.31"
|
||||
# MANIFEST – path to codewhale-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
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${TAG:?}"
|
||||
: "${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
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
die() { echo "::error::${1}" >&2; exit 1; }
|
||||
|
||||
sha() {
|
||||
local file="${1:?}"
|
||||
local val
|
||||
val="$(awk -v f="${file}" '$2 == f {print $1; exit}' "${MANIFEST}")"
|
||||
if [ -z "${val}" ]; then
|
||||
die "Missing binary in checksum manifest: ${file}"
|
||||
fi
|
||||
echo "${val}"
|
||||
}
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- temp dirs --------------------------------------------------------
|
||||
|
||||
FORMULA_FILE="$(mktemp)"
|
||||
LEGACY_FILE="$(mktemp)"
|
||||
TAP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}" "${LEGACY_FILE}"' EXIT
|
||||
|
||||
readonly BASE_URL="https://github.com/codewhale-hq/CodeWhale/releases/download/${TAG}"
|
||||
|
||||
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"
|
||||
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}"
|
||||
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}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
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}"
|
||||
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}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install Dir["*"].first => "codewhale"
|
||||
resource("codew").stage { bin.install Dir["*"].first => "codew" }
|
||||
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 --------------------------------------------------
|
||||
|
||||
ENCODED_TOKEN="$(printf '%s' "${TOKEN}" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')"
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "Formula unchanged (already at ${VERSION}); nothing to push."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: bump formula to ${VERSION}
|
||||
|
||||
Automated update from the release workflow."
|
||||
|
||||
git push origin HEAD:main
|
||||
echo "Pushed formula update to ${TAP_REPO} (v${VERSION})"
|
||||
@@ -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"
|
||||
@@ -1,176 +0,0 @@
|
||||
name: Create release tag
|
||||
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
on:
|
||||
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
|
||||
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: |
|
||||
v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
|
||||
if [ -z "$v" ]; then
|
||||
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]+$'; then
|
||||
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Workspace version: $v"
|
||||
|
||||
- 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
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag ${TAG} already exists at the intended SHA; nothing to do."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag ${TAG} does not exist; will create."
|
||||
fi
|
||||
|
||||
- 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
|
||||
}
|
||||
|
||||
- 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
|
||||
|
||||
- name: Warn if PAT missing
|
||||
if: steps.create.outputs.pushed == '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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish to Crates.io
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Verify version matches tag
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version')
|
||||
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Main landing gate
|
||||
|
||||
# Why this exists
|
||||
#
|
||||
# `ci.yml` runs only on `pull_request` and `workflow_dispatch`. Nothing
|
||||
# validates a push to `main`, so a landing that is red on arrival stays red
|
||||
# until the next release candidate run, and every open PR then inherits the
|
||||
# failure: on 2026-10-29 four commits landed red behind `[skip ci]`
|
||||
# (`PersistRequest::ClearCheckpoint` became dead, `clippy` failed on
|
||||
# `codewhale-skills`, and the blocking-calls budget went stale for a file that
|
||||
# had moved crates) and all nine open PRs looked broken for reasons that had
|
||||
# nothing to do with their authors' code.
|
||||
#
|
||||
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
|
||||
# which is the checked-in local preflight for exactly the class of breakage that
|
||||
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
|
||||
# the dead-code and blocking-calls budgets, generated-asset drift, and the
|
||||
# feature receipts. Measured at 45 s on a warm Linux runner.
|
||||
#
|
||||
# It deliberately does NOT duplicate `ci.yml`. No test suite, no clippy, no
|
||||
# multi-OS matrix: those stay pull-request and release gates. The point is to
|
||||
# catch a red landing in minutes, not to re-run the release gate on every push.
|
||||
|
||||
on:
|
||||
# Manual only: run it after a landing you want checked or after an infra
|
||||
# outage. Before pushing, run scripts/preflight.sh --check locally.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: main-landing-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Landing preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# The preflight diffs against the push's base commit to scope feature
|
||||
# receipts, and the changelog/budget checks read history.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt
|
||||
|
||||
- name: Preflight
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: bash scripts/preflight.sh --check
|
||||
@@ -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"
|
||||
@@ -1,201 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
# Manual artifact build for final release validation.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -Dwarnings
|
||||
CODEWHALE_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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-x64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-linux-x64
|
||||
alias_artifact: codew-linux-x64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux-arm64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-linux-arm64
|
||||
alias_artifact: codew-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
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos-arm64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-macos-arm64
|
||||
alias_artifact: codew-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
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
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'
|
||||
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
|
||||
- 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
|
||||
- name: Stage artifact
|
||||
id: stage
|
||||
shell: bash
|
||||
run: |
|
||||
short_sha="${GITHUB_SHA::12}"
|
||||
bin_path="target/${{ matrix.target }}/release/${{ matrix.binary }}"
|
||||
if [ ! -f "${bin_path}" ]; then
|
||||
echo "Binary not at ${bin_path}; searching target/ for ${{ matrix.binary }}:"
|
||||
find target -name "${{ matrix.binary }}" -type f
|
||||
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
|
||||
repository=${GITHUB_REPOSITORY}
|
||||
ref=${GITHUB_REF_NAME}
|
||||
commit=${GITHUB_SHA}
|
||||
artifact=${artifact}
|
||||
profile=release-nightly-no-lto
|
||||
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
|
||||
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/*
|
||||
retention-days: 14
|
||||
@@ -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
|
||||
@@ -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.*
|
||||
@@ -1,695 +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 }}"
|
||||
signer="$(dirname "${OHOS_NATIVE_SDK}")/toolchains/lib/binary-sign-tool.jar"
|
||||
if [[ ! -f "${signer}" ]]; then
|
||||
echo "binary-sign-tool.jar is missing from the SDK toolchains component" >&2
|
||||
exit 1
|
||||
fi
|
||||
java -jar "${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="$(java -jar "${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
|
||||
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}"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -3,656 +3,55 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version, without v; dispatch from the matching existing vX.Y.Z tag ref'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Read release-candidate.yml runs for the RC receipt check below.
|
||||
actions: read
|
||||
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 release source
|
||||
id: release
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! [[ "${INPUT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release version '${INPUT_VERSION}' must use X.Y.Z." >&2
|
||||
exit 1
|
||||
fi
|
||||
tag="v${INPUT_VERSION}"
|
||||
if [[ "${GITHUB_REF}" != "refs/tags/${tag}" ]]; then
|
||||
echo "::error::Dispatch release.yml from --ref ${tag}, not ${GITHUB_REF}." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
|
||||
if ! [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release tag '${tag}' must use vX.Y.Z." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --verify "refs/tags/${tag}^{commit}" >/dev/null 2>&1; then
|
||||
echo "::error::Release tag ${tag} does not exist. Create it from the frozen main commit before dispatching." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
|
||||
event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")"
|
||||
if [[ "${event_sha}" != "${sha}" ]]; then
|
||||
echo "::error::Trigger SHA ${event_sha} does not match ${tag} at ${sha}; the tag moved after this run was created." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "sha=${sha}"
|
||||
echo "version=${tag#v}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
- name: Validate tagged release metadata
|
||||
shell: bash
|
||||
env:
|
||||
SHA: ${{ steps.release.outputs.sha }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout --detach "${SHA}"
|
||||
expected="${TAG#v}"
|
||||
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")"
|
||||
sdk_version="$(node -p "require('./npm/runtime-sdk/package.json').version")"
|
||||
vscode_version="$(node -p "require('./extensions/vscode/package.json').version")"
|
||||
for pair in \
|
||||
"workspace:${workspace_version}" \
|
||||
"npm:${npm_version}" \
|
||||
"npm binary:${binary_version}" \
|
||||
"runtime-sdk:${sdk_version}" \
|
||||
"vscode:${vscode_version}"; do
|
||||
label="${pair%%:*}"
|
||||
actual="${pair#*:}"
|
||||
if [[ "${actual}" != "${expected}" ]]; then
|
||||
echo "::error::${label} version ${actual} does not match tag ${TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
./scripts/release/check-versions.sh --require-dated-release
|
||||
- name: Require release source on main
|
||||
run: ./scripts/release/ensure-release-on-main.sh "${{ steps.release.outputs.sha }}"
|
||||
- name: Require a green release-candidate receipt for this exact SHA
|
||||
# Fail fast, before the long parity and artifact builds: the tag must
|
||||
# point at a commit a release-candidate run already validated,
|
||||
# Parity included. A missing receipt means run the RC on this SHA,
|
||||
# never move the tag (v0.10.0 was re-pointed after the RC skipped
|
||||
# parity).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ steps.release.outputs.sha }}
|
||||
run: ./scripts/release/require-rc-receipt.sh "${GITHUB_REPOSITORY}" "${SHA}"
|
||||
- name: Refuse an existing public asset set
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: node scripts/release/ensure-release-assets-absent.js "${GITHUB_REPOSITORY}" "${TAG}"
|
||||
|
||||
parity:
|
||||
name: Parity
|
||||
needs: resolve
|
||||
uses: ./.github/workflows/release-parity.yml
|
||||
|
||||
artifacts:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
needs: [parity, resolve]
|
||||
if: ${{ !cancelled() && needs.resolve.result == 'success' && needs.parity.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: 14
|
||||
secrets:
|
||||
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
|
||||
|
||||
docker-build:
|
||||
needs: [artifacts, resolve]
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' }}
|
||||
name: Docker ${{ matrix.platform }}
|
||||
timeout-minutes: 60
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
architecture: amd64
|
||||
cli_artifact: codewhale-linux-x64
|
||||
shim_artifact: codew-linux-x64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
architecture: arm64
|
||||
cli_artifact: codewhale-linux-arm64
|
||||
shim_artifact: codew-linux-arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek
|
||||
artifact_name: deepseek-linux-x64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: deepseek
|
||||
artifact_name: deepseek-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: deepseek
|
||||
artifact_name: deepseek-macos-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek.exe
|
||||
artifact_name: deepseek-windows-x64.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout release infrastructure
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: infra
|
||||
- name: Download Codewhale release binary
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: ${{ matrix.cli_artifact }}
|
||||
path: docker-context/bin
|
||||
- name: Download codew release alias
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: ${{ matrix.shim_artifact }}
|
||||
path: docker-context/bin
|
||||
- name: Verify native release bytes
|
||||
targets: ${{ matrix.target }}
|
||||
- run: cargo build --release --target ${{ matrix.target }}
|
||||
- name: Rename binary
|
||||
shell: bash
|
||||
env:
|
||||
CLI_ARTIFACT: ${{ matrix.cli_artifact }}
|
||||
SHIM_ARTIFACT: ${{ matrix.shim_artifact }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mv -- "docker-context/bin/${CLI_ARTIFACT}" docker-context/bin/codewhale
|
||||
mv -- "docker-context/bin/${SHIM_ARTIFACT}" docker-context/bin/codew
|
||||
chmod 0755 docker-context/bin/codewhale docker-context/bin/codew
|
||||
cmp docker-context/bin/codewhale docker-context/bin/codew
|
||||
docker-context/bin/codewhale --version
|
||||
docker-context/bin/codew --version
|
||||
- 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
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} ${{ matrix.artifact_name }}
|
||||
- uses: actions/upload-artifact@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: Extract image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.resolve.outputs.version }}
|
||||
- name: Revalidate release tag before container upload
|
||||
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: Assemble and push native image by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
with:
|
||||
context: docker-context
|
||||
file: infra/packaging/docker/Dockerfile.release
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ steps.image.outputs.name }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Smoke native image digest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm --entrypoint codewhale "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codew "${IMAGE}" --version
|
||||
- name: Export image digest
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Unexpected image digest: ${DIGEST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p digests
|
||||
touch "digests/${DIGEST#sha256:}"
|
||||
- name: Upload image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: docker-digest-${{ matrix.architecture }}
|
||||
path: digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
# Derived channel: the tagged/latest manifest is published only after the
|
||||
# canonical GitHub Release exists. docker-build pushes by digest (untagged)
|
||||
# and smokes each image before the release, so a broken image still stops
|
||||
# the release, but no public tag can point at a version whose GitHub
|
||||
# Release was never published.
|
||||
docker:
|
||||
timeout-minutes: 30
|
||||
needs: [docker-build, release, resolve]
|
||||
if: ${{ !cancelled() && needs.docker-build.result == 'success' && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout release infrastructure
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: infra
|
||||
- name: Download native image digests
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
path: digests
|
||||
pattern: docker-digest-*
|
||||
merge-multiple: true
|
||||
- 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: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern=v{{major}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=v${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=latest
|
||||
- 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: Publish multi-architecture manifest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.name }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t digest_files < <(find digests -maxdepth 1 -type f -printf '%f\n' | sort)
|
||||
if [[ "${#digest_files[@]}" -ne 2 ]]; then
|
||||
echo "Expected exactly two native image digests; found ${#digest_files[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sources=()
|
||||
for digest in "${digest_files[@]}"; do
|
||||
if ! [[ "${digest}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "Unexpected image digest file: ${digest}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sources+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
tag_args=()
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "${tag}" ]] && tag_args+=(--tag "${tag}")
|
||||
done <<< "${TAGS}"
|
||||
if [[ "${#tag_args[@]}" -eq 0 ]]; then
|
||||
echo "No container tags were generated." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
|
||||
- name: Verify and smoke published container
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "${IMAGE}"
|
||||
raw_manifest="$(docker buildx imagetools inspect --raw "${IMAGE}")"
|
||||
jq -e \
|
||||
'[.manifests[] | select(.platform.os == "linux") | "linux/\(.platform.architecture)"] | unique | sort == ["linux/amd64", "linux/arm64"]' \
|
||||
<<< "${raw_manifest}"
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm --entrypoint codewhale "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codew "${IMAGE}" --version
|
||||
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: ${{ matrix.artifact_name }}
|
||||
release:
|
||||
timeout-minutes: 30
|
||||
needs: [artifacts, docker-build, resolve]
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: repo
|
||||
- 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: artifacts
|
||||
- name: Revalidate exact authoritative asset set
|
||||
run: node repo/scripts/release/assemble-release-assets.js --verify artifacts
|
||||
- name: Generate release body from CHANGELOG
|
||||
shell: bash
|
||||
run: |
|
||||
./repo/scripts/release/generate-release-body.sh \
|
||||
"${{ needs.resolve.outputs.tag }}" repo/CHANGELOG.md > release-body.md
|
||||
- name: Revalidate release tag before GitHub Release write
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
./repo/scripts/release/verify-remote-tag.sh \
|
||||
"https://github.com/${GITHUB_REPOSITORY}.git" \
|
||||
"${TAG}" \
|
||||
"${EXPECTED_SHA}"
|
||||
- name: Reconfirm public asset set is still empty
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: node repo/scripts/release/ensure-release-assets-absent.js "${GITHUB_REPOSITORY}" "${TAG}"
|
||||
# Upload into a DRAFT: 34 sequential uploads that die halfway must leave
|
||||
# nothing public (and nothing a rerun's immutability guard refuses).
|
||||
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
|
||||
- name: List artifacts
|
||||
run: find artifacts -type f
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.resolve.outputs.tag }}
|
||||
name: Codewhale ${{ needs.resolve.outputs.tag }}
|
||||
files: artifacts/*
|
||||
draft: true
|
||||
files: artifacts/*/*
|
||||
prerelease: false
|
||||
body_path: release-body.md
|
||||
overwrite_files: false
|
||||
fail_on_unmatched_files: true
|
||||
- name: Verify the draft's exact asset set, then publish it at once
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
node repo/scripts/release/verify-release-inventory.js \
|
||||
--draft --asset-dir artifacts --publish \
|
||||
"${GITHUB_REPOSITORY}" "${TAG}"
|
||||
|
||||
# CNB tag pushes start its public binary pipeline, so mirror only after
|
||||
# the canonical release is complete. Calling the reusable workflow here
|
||||
# also works when GITHUB_TOKEN publication suppresses release events.
|
||||
cnb:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
uses: ./.github/workflows/sync-cnb.yml
|
||||
with:
|
||||
release_tag: ${{ needs.resolve.outputs.tag }}
|
||||
source_sha: ${{ needs.resolve.outputs.sha }}
|
||||
secrets:
|
||||
CNB_GIT_TOKEN: ${{ secrets.CNB_GIT_TOKEN }}
|
||||
|
||||
gitcode:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && vars.GITCODE_MIRROR == 'enabled' }}
|
||||
uses: ./.github/workflows/sync-gitcode.yml
|
||||
with:
|
||||
release_tag: ${{ needs.resolve.outputs.tag }}
|
||||
source_sha: ${{ needs.resolve.outputs.sha }}
|
||||
secrets:
|
||||
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
|
||||
|
||||
npm:
|
||||
timeout-minutes: 20
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
- name: Pin OIDC-capable npm CLI
|
||||
run: npm install --global [email protected]
|
||||
- name: Revalidate release tag before npm publish
|
||||
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: Revalidate public release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ./scripts/release/verify-release-assets.sh "${{ needs.resolve.outputs.version }}"
|
||||
- name: Test npm wrapper
|
||||
working-directory: npm/codewhale
|
||||
run: npm test
|
||||
- name: Publish npm wrapper with trusted publishing
|
||||
working-directory: npm/codewhale
|
||||
env:
|
||||
# npm runs prepublishOnly in this step; that guard revalidates the
|
||||
# public GitHub Release and therefore needs the same read token as
|
||||
# the explicit asset gate above.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: npm publish --access public
|
||||
|
||||
homebrew:
|
||||
timeout-minutes: 20
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check Homebrew tap token
|
||||
# A real release must update the tap. Skipping silently (the old
|
||||
# behaviour) left `brew install codewhale` on the previous version
|
||||
# with a green release run; fail loudly like release-republish.yml.
|
||||
env:
|
||||
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
|
||||
run: |
|
||||
if [[ -z "${TOKEN:-}" ]]; then
|
||||
echo "::error::No Homebrew tap token configured (HOMEBREW_TAP_PAT or RELEASE_TAG_PAT); cannot update the tap for this release." >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- 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
|
||||
|
||||
# Every publish used to turn Web Frontend red: `check:latest-release`
|
||||
# compares the checked-in release record with GitHub's latest release, and
|
||||
# nothing wrote the record, so someone hand-committed it after each release.
|
||||
# This job writes the record, proves it against the web gates, and proposes
|
||||
# it to main as a bot PR. It never pushes to the default branch: the ruleset
|
||||
# requires a PR there. While the PR is open (up to 24h after publish, and
|
||||
# only when the record is exactly one release behind), `check:latest-release`
|
||||
# warns instead of failing on every event (see web/scripts/sync-latest-release.mjs).
|
||||
#
|
||||
# Known limit: repo settings stop GITHUB_TOKEN from opening PRs, so the PR is
|
||||
# opened with RELEASE_TAG_PAT. Without it the job pushes the branch and fails
|
||||
# with the compare link, which is still one click instead of a hand commit.
|
||||
sync-release-record:
|
||||
timeout-minutes: 15
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
# derive-facts.mjs dates model ids from git history (web.yml pins 0
|
||||
# for the same reason); a shallow clone rewrites every addedAt.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
- name: Refresh the release record from the published release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node web/scripts/sync-latest-release.mjs
|
||||
node web/scripts/derive-facts.mjs
|
||||
- name: Prove the record passes the web fact gates
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node web/scripts/sync-latest-release.mjs --check
|
||||
node web/scripts/check-cloud-facts.mjs
|
||||
node web/scripts/check-facts.mjs
|
||||
- name: Propose the record to the default branch
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PR_TOKEN: ${{ secrets.RELEASE_TAG_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
paths=(
|
||||
web/data/latest-published-release.json
|
||||
docs/public-surface-facts.json
|
||||
docs/cloud-facts/stable.json
|
||||
web/lib/facts.generated.ts
|
||||
web/data/agent-docs.generated.json
|
||||
web/data/features.generated.json
|
||||
)
|
||||
if git diff --quiet -- "${paths[@]}"; then
|
||||
echo "Release record already current on ${DEFAULT_BRANCH}; nothing to propose."
|
||||
exit 0
|
||||
fi
|
||||
# GitHub's latest may already be newer than this run's tag; the
|
||||
# record always follows GitHub, so name the branch after what it says.
|
||||
recorded="$(node -p "require('./web/data/latest-published-release.json').tag")"
|
||||
branch="chore/release-record-${recorded}"
|
||||
title="chore(web): record ${recorded} as the published release"
|
||||
|
||||
git config user.name "CodeWhale Bot"
|
||||
git config user.email "[email protected]"
|
||||
git switch --quiet -c "${branch}"
|
||||
git add -- "${paths[@]}"
|
||||
git commit --quiet \
|
||||
-m "${title}" \
|
||||
-m "Written by release.yml sync-release-record after ${TAG} published. Gates run in the job: sync-latest-release --check, check-cloud-facts, check-facts."
|
||||
git show --stat --format='%h %s' HEAD
|
||||
|
||||
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null; then
|
||||
echo "::notice title=Release record already proposed::Branch ${branch} exists; leaving it for review."
|
||||
exit 0
|
||||
fi
|
||||
git push origin "HEAD:refs/heads/${branch}"
|
||||
|
||||
compare="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${DEFAULT_BRANCH}...${branch}?expand=1"
|
||||
if [[ -n "${PR_TOKEN}" ]] && GH_TOKEN="${PR_TOKEN}" gh pr create \
|
||||
--repo "${GITHUB_REPOSITORY}" --base "${DEFAULT_BRANCH}" --head "${branch}" \
|
||||
--title "${title}" \
|
||||
--body "Written by release.yml \`sync-release-record\` after ${TAG} published. Merging it turns \`check:latest-release\` green on ${DEFAULT_BRANCH}. No-Issue: release automation."; then
|
||||
echo "::notice title=Release record proposed::Opened a PR from ${branch}."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error title=Release record PR not opened::Branch ${branch} is pushed; open and merge it: ${compare}"
|
||||
exit 1
|
||||
@@ -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
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Lock down obvious spam issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
lockdown:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Auto-close spam patterns from new accounts
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const author = issue.user;
|
||||
|
||||
// Only consider brand-new accounts. If the user has been around
|
||||
// long enough to file good-faith issues elsewhere, don't touch.
|
||||
const created = new Date(author.created_at || 0);
|
||||
const ageDays = (Date.now() - created.getTime()) / 86_400_000;
|
||||
if (ageDays > 30) return;
|
||||
|
||||
const blob = `${issue.title || ''}\n${issue.body || ''}`;
|
||||
const patterns = [
|
||||
/\btg\s*@/i,
|
||||
/\btelegram\s+@/i,
|
||||
/\bt\.me\//i,
|
||||
/\bwhatsapp\s+\+/i,
|
||||
/\bseo\s+service/i,
|
||||
/\bguest\s+post/i,
|
||||
/\bbacklink/i,
|
||||
/\bbuy\s+followers/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.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['spam'],
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Close stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 5 * * *' # daily, off-peak
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
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
|
||||
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-label: 'stale'
|
||||
only-labels: 'needs-info'
|
||||
exempt-issue-labels: 'pinned,keep-open,release-blocker,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
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 60
|
||||
@@ -1,121 +0,0 @@
|
||||
name: Sync to CNB
|
||||
|
||||
# Mirror commits and release tags to cnb.cool/codewhale.net/codewhale
|
||||
# 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.
|
||||
#
|
||||
# Why the rewrite (v0.8.31):
|
||||
# The previous implementation used the opaque tencentcom/git-sync Docker
|
||||
# action, which discovered every local ref via fetch-depth: 0 and tried
|
||||
# to push them all — including dependabot/* branches that GitHub had
|
||||
# locally. Those follow-on pushes ran without the configured credential
|
||||
# helper in scope and failed with
|
||||
# `fatal: could not read Username for 'https://cnb.cool'`
|
||||
# No concurrency block meant the main-push and tag-push workflow runs
|
||||
# that auto-tag.yml fires within seconds of each other raced. About
|
||||
# half of recent runs failed for those two reasons combined.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
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.
|
||||
concurrency:
|
||||
group: cnb-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
if [ -z "${CNB_TOKEN:-}" ]; then
|
||||
echo "::error::CNB_GIT_TOKEN secret is not set; cannot push to CNB." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# URL-encode any '%' in the token so basic-auth doesn't break on
|
||||
# special characters. CNB tokens are typically alphanumeric so
|
||||
# this is belt-and-suspenders.
|
||||
ENCODED_TOKEN="$(printf '%s' "${CNB_TOKEN}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
|
||||
REMOTE_URL="https://cnb:${ENCODED_TOKEN}@cnb.cool/codewhale.net/codewhale.git"
|
||||
# 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}"
|
||||
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
|
||||
# to protect against. `--force-with-lease` would be safer
|
||||
# in a multi-writer scenario, but in our setup the lease
|
||||
# check requires `refs/remotes/cnb/main` to exist in the
|
||||
# runner's local clone, which it never does (we add `cnb`
|
||||
# as a fresh remote in this step and don't fetch first).
|
||||
# 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
|
||||
else
|
||||
# First-party fix/rebrand/release branches are first-class CNB
|
||||
# sources for heavy Linux CI, release preflight, and
|
||||
# Lighthouse/Feishu bootstrap.
|
||||
# Mirror the triggering branch exactly so the CNB clone path stays
|
||||
# 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
|
||||
fi
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,126 +0,0 @@
|
||||
name: Issue and PR 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
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const issue = context.payload.issue || pr;
|
||||
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 — 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');
|
||||
|
||||
// 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');
|
||||
|
||||
}
|
||||
|
||||
if (labels.size === 0) return;
|
||||
|
||||
// Only add labels that already exist on the repo to avoid creating noise.
|
||||
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingNames = new Set(existing.map(l => l.name));
|
||||
const toAdd = [...labels].filter(name => existingNames.has(name));
|
||||
if (toAdd.length === 0) return;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -20,12 +11,7 @@ codewhale-inference/
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
!.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
|
||||
@@ -47,148 +33,25 @@ dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
firebase-debug.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
|
||||
docs/*.pdf
|
||||
|
||||
# Rust
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
firebase-debug.log
|
||||
tmp/
|
||||
|
||||
# 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
|
||||
AGENTS.md
|
||||
# …except the deliberate, maintained agent-guidance files:
|
||||
!/AGENTS.md
|
||||
!crates/tui/AGENTS.md
|
||||
!crates/tui/locales/AGENTS.md
|
||||
!web/AGENTS.md
|
||||
CLAUDE.md
|
||||
NEXT_SESSION.md
|
||||
AI_HANDOFF.md
|
||||
result.json
|
||||
count_deps.py
|
||||
project_overhaul_prompt.md
|
||||
|
||||
.codex/
|
||||
docs/rlm-paper.txt
|
||||
.context/
|
||||
.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
|
||||
.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
|
||||
.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.
|
||||
.claude/HANDOFF_*
|
||||
.claude/CODEMAP_*
|
||||
.claude/handoff_*
|
||||
.claude/codemap_*
|
||||
|
||||
# Maintainer-internal design notes (trade-secret material, never published)
|
||||
.private/
|
||||
|
||||
# Coverage reports (build artifacts)
|
||||
web/coverage/
|
||||
|
||||
# Agent handoffs and version-specific setup plans are working-state notes, not
|
||||
# public docs. Keep durable setup guidance in docs/runbooks instead.
|
||||
docs/*HANDOFF*.md
|
||||
docs/*handoff*.md
|
||||
docs/*_PLAN.md
|
||||
!docs/archive/**
|
||||
|
||||
# direnv
|
||||
.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
|
||||
@@ -1,16 +0,0 @@
|
||||
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> GitHub <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
|
||||
Hunter Bown <[email protected]> qwencoder <[email protected]>
|
||||
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
|
||||
Hunter Bown <[email protected]> cursoragent <[email protected]>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
|
||||
@@ -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
|
||||
@@ -1,268 +1,49 @@
|
||||
# Codewhale agent guidance
|
||||
# Project Instructions
|
||||
|
||||
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.
|
||||
This file provides context for AI assistants working on this project.
|
||||
|
||||
## The ponytail method
|
||||
## Project Type: Rust
|
||||
|
||||
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.
|
||||
### Commands
|
||||
- Build: `cargo build`
|
||||
- Test: `cargo test`
|
||||
- Run: `cargo run`
|
||||
- Check: `cargo check`
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
|
||||
Before writing code, walk the decision ladder in order and stop at the first
|
||||
rung that answers:
|
||||
### Project: deepseek-cli
|
||||
|
||||
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.
|
||||
### Documentation
|
||||
See README.md for project overview.
|
||||
|
||||
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.
|
||||
### Version Control
|
||||
This project uses Git. See .gitignore for excluded files.
|
||||
|
||||
**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:
|
||||
## Advanced Capabilities
|
||||
|
||||
- **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.
|
||||
### Model Context Protocol (MCP)
|
||||
This CLI supports MCP for extending tool access.
|
||||
- Use `mcp_read_resource` to read context from external servers.
|
||||
- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers.
|
||||
- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`.
|
||||
|
||||
## Working rules
|
||||
### Multi-Agent Orchestration
|
||||
For complex, multi-step tasks, you should delegate work:
|
||||
- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output.
|
||||
- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently.
|
||||
|
||||
- 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 uses the canonical checkout on current `main`. Do not
|
||||
create branches, worktrees, nested clones or task-specific integration lanes
|
||||
to handle dirty, stale or conflicting work. When agents share the checkout,
|
||||
partition by file and retry an `index.lock` failure. Verified main slices may
|
||||
be committed and pushed normally under the rule below; tag, release,
|
||||
deployment and destructive cleanup still require their explicit
|
||||
authorization.
|
||||
- **Uncommitted work found in the checkout is the agent's own** (founder,
|
||||
2026-10-22). Read it, work out what it was for, prove it with the smallest
|
||||
check that covers it, fix what is broken, and commit it in coherent groups
|
||||
by explicit path. Do not route around another writer's files or leave them
|
||||
dirty. Ownership is not a blind sweep:
|
||||
- A file changed in the last 15 minutes, or one a live session or agent is
|
||||
editing, is mid-edit: ask that writer to commit now, and commit what
|
||||
remains once it goes quiet. Never commit a half-written edit.
|
||||
- Never commit secrets, credentials, `.env` files, build output or caches;
|
||||
add them to `.gitignore` instead.
|
||||
- Check a repository's visibility before pushing. Inference business and
|
||||
infrastructure detail stays out of public repositories.
|
||||
- Commit by explicit path; never `git add -A`.
|
||||
- Work that fails its check and cannot be fixed now is not pushed to `main`.
|
||||
Record it and report it instead.
|
||||
- **Maintainers land small verified slices directly on `main`.** Run the
|
||||
focused local check (`scripts/preflight.sh --check`), put the real counts in
|
||||
the commit message, fetch and rebase onto `origin/main` and retry on
|
||||
rejection. Never force-push or rewrite a shared ref. Contributor PRs are
|
||||
always welcome and merge as themselves (founder, 2026-11-07).
|
||||
- When the task is local-only, stay fully offline: no browsing, GitHub or remote
|
||||
Git operations, downloads, dependency installation, provider calls, or
|
||||
source/diff transmission. Record the missing external receipt and keep working
|
||||
locally.
|
||||
- Public name is **Codewhale**. Compatibility identifiers such as `CodeWhale`,
|
||||
`codew`, protocol names, and storage keys change only through an explicit
|
||||
migration.
|
||||
- Keep providers and models first-class and provider-neutral.
|
||||
- Never rewrite published history, retag a release, force-push a shared ref, or
|
||||
publish without explicit authorization. Preserve human contributor credit.
|
||||
- **Model-visible means logged.** Anything that reaches a model request must be
|
||||
reconstructable from the session log, and a new model-visible input needs a
|
||||
session event. Live presentation and the persisted record must agree; when they
|
||||
disagree the record is right.
|
||||
- **Misconfiguration fails loud**, at load when it is self-contained, otherwise
|
||||
at the earliest point it can be resolved. Never silently skip a missing
|
||||
referent.
|
||||
- **Write down what a design does not do**, beside the behaviour it owns — a
|
||||
short known-limitations note in the owning module. A stated limit stops the
|
||||
next reader from assuming a capability that was never built.
|
||||
- **Be generous in public** (founder, 2026-11-07). Thank people specifically
|
||||
for their reports, diagnosis, fixes and testing; link the carrying commit or
|
||||
PR and distinguish candidate work from a shipped release. Status, answers
|
||||
and useful questions are welcome; repetitive progress posts and bot noise
|
||||
are not. Evidence stays in commits and PR bodies; ownership stays in Linear.
|
||||
A contributor PR is never closed because CI is slow, flaky or red for a
|
||||
reason the PR did not cause; when a PR is closed, say why in one specific,
|
||||
thankful sentence. Review bots stay off unless the founder enables them.
|
||||
- **A user feature lands with its registry row.** A new command, `[features]`
|
||||
flag, provider or user-visible feature adds or updates its row in
|
||||
`docs/features.toml` in the same change;
|
||||
`cargo test -p codewhale-tui --test feature_registry` fails on flag drift.
|
||||
- **Write `close`/`fix`/`resolve #N` only when you mean it.** GitHub closes the
|
||||
issue on merge even inside "does not close #N"; use `Refs #N` otherwise.
|
||||
### Project Mapping
|
||||
- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files.
|
||||
|
||||
## Landing other people's work
|
||||
## Guidelines
|
||||
|
||||
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.
|
||||
- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`.
|
||||
- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible.
|
||||
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
|
||||
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
|
||||
|
||||
**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.
|
||||
## Important Notes
|
||||
|
||||
- **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.
|
||||
- **No new tests and no new code comments unless explicitly asked**
|
||||
(founder, 2026-10-08). Do not write new unit or integration tests unless the
|
||||
user explicitly instructs it for that task; prove a change by running it and
|
||||
the existing checks. 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.
|
||||
<!-- Add project-specific notes here -->
|
||||
@@ -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.
|
||||
@@ -1,12 +1,12 @@
|
||||
# Contributing to codewhale
|
||||
# Contributing to DeepSeek CLI
|
||||
|
||||
Thank you for your interest in contributing to codewhale! This document provides guidelines and instructions for contributing.
|
||||
Thank you for your interest in contributing to DeepSeek CLI! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.89 or later (edition 2024)
|
||||
- Rust 1.85 or later (edition 2024)
|
||||
- Cargo package manager
|
||||
- Git
|
||||
|
||||
@@ -14,47 +14,25 @@ Thank you for your interest in contributing to codewhale! This document provides
|
||||
|
||||
1. Fork and clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/CodeWhale.git
|
||||
cd CodeWhale
|
||||
git clone https://github.com/YOUR_USERNAME/DeepSeek-CLI.git
|
||||
cd DeepSeek-CLI
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
4. Run with development settings:
|
||||
```bash
|
||||
./target/debug/codewhale --version
|
||||
./target/debug/codewhale
|
||||
cargo run
|
||||
```
|
||||
|
||||
### Testing the latest source
|
||||
|
||||
The canonical source is [`codewhale-hq/Codewhale`'s `main` branch](https://github.com/codewhale-hq/Codewhale/tree/main).
|
||||
Release candidates land there after their CI gates pass, so contributors can
|
||||
test and build on the same source. Tagged downloads remain the latest published
|
||||
release; their version can lag the development version on `main`.
|
||||
|
||||
For a fresh checkout of the current source:
|
||||
|
||||
```bash
|
||||
git clone --branch main https://github.com/codewhale-hq/Codewhale.git
|
||||
cd Codewhale
|
||||
CODEWHALE_BUILD_SHA="$(git rev-parse HEAD)" cargo build --release --locked -p codewhale-cli
|
||||
./target/release/codewhale --version
|
||||
./target/release/codewhale
|
||||
```
|
||||
|
||||
Include the commit shown by `--version` when reporting a problem. If you work
|
||||
from a fork, add the canonical repository as `upstream` and fetch `upstream/main`
|
||||
before starting a change; preserve any local work when updating your branch.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Style
|
||||
@@ -67,125 +45,8 @@ 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))
|
||||
- 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.
|
||||
- Ensure all existing tests pass: `cargo test`
|
||||
- For integration tests, use the `tests/` directory
|
||||
|
||||
### Commit Messages
|
||||
|
||||
@@ -198,294 +59,34 @@ Use clear, descriptive commit messages following conventional commits:
|
||||
- `test:` Adding or updating tests
|
||||
- `chore:` Maintenance tasks
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Before merging, a maintainer may:
|
||||
|
||||
- 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.
|
||||
|
||||
### Fallback — 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.
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
4. **Avoid the trust-boundary surface** without prior maintainer
|
||||
sign-off. That includes auth/credential flows, sandbox policy,
|
||||
publishing/release plumbing, and `prompts/` content. PRs that touch
|
||||
these without prior discussion are unlikely to merge directly even
|
||||
when the change is well-implemented.
|
||||
|
||||
## Layered and EPIC-Sized Work
|
||||
|
||||
Some architecture work is too large for one PR but still needs to be built in
|
||||
dependent layers. For those changes, use this workflow:
|
||||
|
||||
1. Start with a tracking issue or EPIC when the work spans multiple PRs. Name
|
||||
the intended slices and state what each slice is not trying to close yet.
|
||||
2. Keep each implementation PR focused on one behavior boundary.
|
||||
3. Later layers may stay in your fork or open as draft PRs while the lower
|
||||
layer is still moving. Draft stacked PR titles or descriptions should say
|
||||
`Draft / depends on #NNNN`.
|
||||
4. A dependent PR is not ready for merge review until the lower layer has
|
||||
landed, the branch has been rebased onto current `main`, and the PR targets
|
||||
`main`.
|
||||
5. The PR body should identify which earlier PR it builds on, what is in scope,
|
||||
what is explicitly out of scope, which issues it references, and which local
|
||||
commands were run.
|
||||
6. Use `Closes #...` only when the slice fully satisfies an issue. Use
|
||||
`Refs #...` with a short `(partial)` note when the PR advances a broad issue
|
||||
but leaves follow-up work.
|
||||
7. Structured commits are fine during review. Maintainers merge the PR itself
|
||||
(merge commit, squash or rebase) with you as the author. If a layer has to
|
||||
be harvested instead, the [fallback](#fallback--harvest) rules apply.
|
||||
|
||||
Before asking for merge review on a layered PR, check that it is:
|
||||
|
||||
- rebased onto current `main`
|
||||
- marked ready for review, not draft
|
||||
- focused to one behavior boundary
|
||||
- backed by local command evidence in the PR body
|
||||
- green in its pull-request checks, or has any remaining red check clearly
|
||||
explained
|
||||
- covered by round-trip or migration-preservation tests when it changes config
|
||||
or schema behavior
|
||||
- referencing broad issues as partial unless it really closes them
|
||||
|
||||
For layered work, a useful PR description shape is:
|
||||
|
||||
```text
|
||||
Summary:
|
||||
Scope:
|
||||
Not in this slice:
|
||||
Builds on:
|
||||
Issues:
|
||||
Validation:
|
||||
```
|
||||
|
||||
## Which branch to target
|
||||
|
||||
**`main`, for everything.** There is no separate staging branch. An earlier
|
||||
version of this guide pointed layered refactors at `codex/v0.9.0-stewardship`;
|
||||
that branch no longer exists, so please ignore any instruction you find
|
||||
elsewhere to base work on it.
|
||||
|
||||
**We do not expect you to rebase around our churn.** If your PR conflicts only
|
||||
because `main` moved while it was in review, say so and a maintainer resolves
|
||||
it on your branch. If your branch is in a fork we cannot push to, we ask on the
|
||||
PR first; harvesting is the last resort.
|
||||
|
||||
## Labels
|
||||
|
||||
Issues and pull requests are labelled by `.github/workflows/triage.yml`, which
|
||||
only ever adds labels that already exist. Pull requests get their labels from
|
||||
the paths they change and their conventional-commit title prefix; issues get
|
||||
them from the title and form answers.
|
||||
|
||||
| Family | Meaning |
|
||||
| --- | --- |
|
||||
| `area:*` | The subsystem or client: `tui`, `core`, `tools`, `mcp`, `state`, `sandbox`, `plugins`, `providers`, `runtime-api`, `install`, `localization`, `ci`, `desktop`, `web`, `mobile`, `remote-control`, `subagents` |
|
||||
| `os:*` | Platform-specific behaviour: `windows`, `macos`, `linux`, `freebsd`, `ohos` |
|
||||
| `priority:p0` / `p1` / `p2` | Set by a maintainer; `release-blocker` still marks work that must land before a release |
|
||||
| `bug`, `enhancement`, `documentation`, `question`, `cleanup`, `performance`, `security` | What kind of change it is |
|
||||
| `accessibility` | Screen readers, keyboard use, contrast, motion |
|
||||
| `contributor:external` | Opened by someone outside the maintainer team |
|
||||
| `lang:zh` | Written in Chinese |
|
||||
| `needs-triage`, `needs-info`, `agent-ready`, `agent-in-progress`, `needs-human`, `keep-open`, `pinned`, `stale` | Workflow state; other workflows read these names |
|
||||
|
||||
Release targeting uses milestones, not version labels.
|
||||
|
||||
## Agent-Assisted Improvements
|
||||
|
||||
Codewhale is allowed to help improve Codewhale, but the contribution still has
|
||||
to be shaped for human review. The recommended workflow is the recursive self-improvement prompt
|
||||
in the private `codewhale-ops` repo: run it
|
||||
from a fresh fork or branch, let the agent find exactly one small friction point,
|
||||
and stop after one patch. DeepSeek V4 Pro is the reference path for this loop
|
||||
today, but any configured provider works — the review shape matters more than
|
||||
the provider.
|
||||
|
||||
Agents and maintainers should follow the stewardship posture in
|
||||
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence,
|
||||
verification, and narrow patches while keeping the final community decision
|
||||
human-reviewed.
|
||||
|
||||
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.
|
||||
|
||||
## How maintainers and agents ship
|
||||
|
||||
Maintainers, and the agents working for them, land small verified slices
|
||||
directly on `main` with the local checks they ran and their counts in the
|
||||
commit message. A push to `main` starts no GitHub build or test workflow:
|
||||
GitHub only mirrors `main` to CNB and GitCode. CNB then runs its own Linux
|
||||
gates on the mirror and reports them on CNB, not on GitHub. Do not write
|
||||
`[skip ci]`; nothing needs skipping, and it would also skip the mirrors.
|
||||
|
||||
The exhaustive gate runs once, at the final release step, from one dispatch on
|
||||
the frozen candidate commit:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
candidate_sha="$(git rev-parse origin/main)"
|
||||
gh workflow run ci.yml --repo codewhale-hq/CodeWhale --ref main \
|
||||
-f expected_sha="${candidate_sha}"
|
||||
```
|
||||
|
||||
It stops if `main` no longer resolves to that commit. Otherwise it runs every
|
||||
CI gate on Linux, macOS and Windows, the budget measurements, the
|
||||
documentation build, `cargo-deny`, the cargo and npm audits and the
|
||||
OpenHarmony build. Publishing also needs a green `release-candidate.yml` run
|
||||
on the same commit, which `release.yml` checks; the full sequence is in
|
||||
[docs/RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md).
|
||||
|
||||
Contributor pull requests still merge as pull requests, as described in
|
||||
[How Your Contribution Lands](#how-your-contribution-lands).
|
||||
Example: `feat: add --doctor command for system diagnostics`
|
||||
|
||||
## 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.
|
||||
|
||||
| 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 |
|
||||
|
||||
Generated files that the product embeds or validates, such as model catalogs,
|
||||
website facts and schemas, remain tracked with their generators. Platform
|
||||
mirrors such as `.winget/` are retained when their packaging tools require them.
|
||||
Keep local critique output, temporary verification reports and personal
|
||||
operator instructions outside the tracked product tree; describe the change
|
||||
and its validation in the pull request. Do not copy workspace-level operator
|
||||
`AGENTS.md` or `CLAUDE.md` files into this repository.
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime data flow and
|
||||
[the build guide](docs/BUILD_PERFORMANCE.md) for crate dependencies and local
|
||||
verification.
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point and CLI definition
|
||||
├── config.rs # Configuration management
|
||||
├── client.rs # HTTP client for DeepSeek API
|
||||
├── llm_client.rs # LLM abstraction layer
|
||||
├── models.rs # Data structures
|
||||
├── mcp.rs # Model Context Protocol support
|
||||
├── hooks.rs # Hook system for extensibility
|
||||
├── skills.rs # Skills/plugin system
|
||||
├── core/ # Core engine components
|
||||
│ ├── engine.rs # Main agent loop
|
||||
│ ├── session.rs # Session management
|
||||
│ └── ...
|
||||
├── tools/ # Built-in tools
|
||||
│ ├── shell.rs # Shell execution
|
||||
│ ├── file.rs # File operations
|
||||
│ └── ...
|
||||
├── tui/ # Terminal UI
|
||||
│ ├── app.rs # Application state
|
||||
│ ├── ui.rs # Rendering logic
|
||||
│ └── ...
|
||||
└── sandbox/ # Sandbox execution (macOS)
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
@@ -496,9 +97,12 @@ verification.
|
||||
|
||||
2. Make your changes and commit them
|
||||
|
||||
3. Run the pre-push verification commands (see
|
||||
[Pre-push verification](#pre-push-verification) above for the exact
|
||||
gate and the stricter release clippy form)
|
||||
3. Ensure CI passes:
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
|
||||
4. Push your branch and create a Pull Request
|
||||
|
||||
@@ -506,71 +110,29 @@ verification.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening
|
||||
a PR — what and why, the issue line, and how you tested it
|
||||
- An issue line in the PR description is optional: `Closes #N` (or `Fixes` /
|
||||
`Resolves`) when the PR finishes the issue, `Refs #N` for related or partial
|
||||
work. Never write a closing keyword inside a negation such as "does not
|
||||
close #N": GitHub closes the issue anyway
|
||||
- If you add a new layer, module, or abstraction, say which one it replaces
|
||||
or deletes
|
||||
- Keep PRs focused on a single change
|
||||
- Update documentation if needed
|
||||
- Add tests for new functionality
|
||||
- If a check is red for a reason your change did not cause, a maintainer will
|
||||
deal with it
|
||||
|
||||
## Shape of a Typical PR
|
||||
|
||||
A well-structured PR follows a consistent pattern. Recent exemplars include:
|
||||
|
||||
- **#386** — `/init` command: new `crates/tui/src/commands/groups/project/init.rs` module, project-type detection,
|
||||
AGENTS.md generation, command registration in `commands/mod.rs`, localization strings.
|
||||
- **#389** — Inline LSP diagnostics: LSP subsystem in `crates/lsp/src/`, engine hooks in
|
||||
`crates/engine/src/core/engine/lsp_hooks.rs`, config toggle, test coverage.
|
||||
- **#387** — Self-update: new `crates/cli/src/update.rs` module, CLI subcommand registration,
|
||||
HTTP download + SHA256 verification + atomic binary replacement.
|
||||
- **#393** — `/share` session URL: new `crates/tui/src/commands/groups/project/share.rs`, HTML rendering,
|
||||
`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 the commands in
|
||||
[Pre-push verification](#pre-push-verification).
|
||||
- Ensure CI passes before requesting review
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, please use one of the issue templates:
|
||||
When reporting issues, please include:
|
||||
|
||||
- [Bug report](.github/ISSUE_TEMPLATE/bug_report.yml) — for reproducible problems
|
||||
or regressions
|
||||
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.yml) — for ideas and
|
||||
improvements
|
||||
|
||||
The forms ask for what a report needs (`codewhale --version`, OS, how you got
|
||||
Codewhale, and steps to reproduce). Questions go to
|
||||
[Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or
|
||||
[Discord](https://discord.gg/37gfS3ksug).
|
||||
|
||||
## Security
|
||||
|
||||
If you discover a security vulnerability, please do **not** open a public issue.
|
||||
See [SECURITY.md](.github/SECURITY.md) for the responsible disclosure process and
|
||||
contact information.
|
||||
- Operating system and version
|
||||
- Rust version (`rustc --version`)
|
||||
- DeepSeek CLI version (`deepseek --version`)
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs actual behavior
|
||||
- Relevant error messages or logs
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful and inclusive. We welcome contributors of all backgrounds and
|
||||
experience levels. See [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md) for the full
|
||||
code of conduct.
|
||||
Be respectful and inclusive. We welcome contributors of all backgrounds and experience levels.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to codewhale, you agree that your contributions will be licensed under the MIT License.
|
||||
By contributing to DeepSeek CLI, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,162 +1,68 @@
|
||||
[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",
|
||||
]
|
||||
exclude = ["vendor/codewhale-ratatui"]
|
||||
default-members = ["crates/cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.3"
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.6"
|
||||
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"
|
||||
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/codewhale-hq/CodeWhale"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
keywords = ["deepseek", "cli", "ai", "agent", "llm"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
# 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"
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.5", features = ["json", "ws"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
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"] }
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
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"] }
|
||||
tracing = "0.1"
|
||||
gix = { version = "=0.90.0", default-features = false, features = ["sha1", "sha256"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
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"
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
|
||||
# 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"
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
codegen-units = 16
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
# 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
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
# 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" }
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
@@ -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.
|
||||
@@ -1,93 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 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
|
||||
#
|
||||
# The image ships the canonical `codewhale` and `codew` command names 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
|
||||
# Or mount an env file:
|
||||
# docker run --rm -it --env-file .env codewhale
|
||||
|
||||
ARG RUST_VERSION=1.88
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────────────
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG BUILDPLATFORM
|
||||
ARG CODEWHALE_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}
|
||||
|
||||
RUN if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
|
||||
dpkg --add-architecture arm64; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
pkg-config libdbus-1-dev \
|
||||
&& if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Translate Docker platform into Rust target triple.
|
||||
# linux/amd64 → x86_64-unknown-linux-gnu
|
||||
# linux/arm64 → aarch64-unknown-linux-gnu
|
||||
RUN case "${TARGETPLATFORM}" in \
|
||||
linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \
|
||||
linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \
|
||||
*) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
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.
|
||||
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 \
|
||||
&& mkdir -p /out \
|
||||
&& cp target/$(cat /rust-target)/release/codewhale /out/ \
|
||||
&& cp target/$(cat /rust-target)/release/codewhale /out/codew
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
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.
|
||||
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
|
||||
|
||||
# `codewhale` and `codew` are two command names for the same runtime.
|
||||
|
||||
ENTRYPOINT ["codewhale"]
|
||||
CMD []
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Parity Spec: Codex vs Claude Code
|
||||
|
||||
This document defines "parity" as measurable behavior in this repository.
|
||||
It is intended to be short, testable, and easy to run during reviews.
|
||||
|
||||
## Scope
|
||||
|
||||
Parity is evaluated on:
|
||||
|
||||
- Instruction following (including `AGENTS.md` and task constraints)
|
||||
- Rust/Cargo workflow discipline
|
||||
- Change quality and scope control
|
||||
- Safety and repo hygiene
|
||||
- Clear, audit-friendly reporting
|
||||
|
||||
Unless a task says otherwise, parity targets the default Rust workflow:
|
||||
|
||||
1) search with `rg` 2) edit minimally 3) validate with Cargo commands.
|
||||
|
||||
## Parity Behaviors (Measurable)
|
||||
|
||||
An agent is considered at parity when it reliably exhibits the following
|
||||
behaviors on eval tasks.
|
||||
|
||||
### 1) Instruction and Scope Compliance
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Respects path constraints (for example: "do not edit `src/*`")
|
||||
- Does not revert or disturb unrelated user changes
|
||||
- Avoids destructive git commands (for example: `git reset --hard`)
|
||||
- Stops and reports if unexpected repo changes appear mid-task
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `scope_violations = 0` (no edits outside allowed paths)
|
||||
- `destructive_git_cmds = 0`
|
||||
- `unrelated_reverts = 0`
|
||||
|
||||
### 2) Rust/Cargo Workflow Discipline
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Uses Cargo as the source of truth for validation
|
||||
- Chooses appropriate checks for the task size/scope
|
||||
- Reports validation outcomes clearly (pass/fail + command)
|
||||
|
||||
Suggested metrics (binary unless noted):
|
||||
|
||||
- `cargo_check_pass`
|
||||
- `cargo_test_pass` (required for most parity gates)
|
||||
- `cargo_fmt_check_pass` (when formatting could be affected)
|
||||
- `cargo_clippy_pass` (recommended for non-trivial code edits)
|
||||
- `validation_reported = 1` (commands + outcomes are stated)
|
||||
|
||||
### 3) Change Quality and Minimality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Keeps edits focused and atomic
|
||||
- Preserves existing style and patterns
|
||||
- Updates documentation when public behavior changes
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `task_acceptance_pass = 1` (task-specific checks succeed)
|
||||
- `files_touched_within_expectation = 1`
|
||||
- `style_regressions = 0` (via `fmt`/`clippy`/review)
|
||||
|
||||
### 4) Reporting Quality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- States what changed, where, and why
|
||||
- Provides clickable file references
|
||||
- Separates results from speculation
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `changed_files_listed = 1`
|
||||
- `key_paths_cited = 1`
|
||||
- `claims_match_repo_state = 1`
|
||||
|
||||
## Parity Metrics and Gates
|
||||
|
||||
Use these gates for pass/fail decisions.
|
||||
|
||||
### Hard Gates (must pass)
|
||||
|
||||
- No scope violations
|
||||
- No destructive git commands
|
||||
- `cargo test` exits 0
|
||||
- Task-specific acceptance checks pass
|
||||
|
||||
### Soft Gates (should pass; track as %)
|
||||
|
||||
- `cargo check` exits 0
|
||||
- `cargo fmt --check` exits 0
|
||||
- `cargo clippy --all-targets --all-features` exits 0
|
||||
- Edits are minimal and well-scoped
|
||||
- Reporting is complete and auditable
|
||||
|
||||
A simple parity score can be computed as:
|
||||
|
||||
- Fail immediately on any hard-gate violation
|
||||
- Otherwise: `score = soft_gates_passed / soft_gates_total`
|
||||
|
||||
Target: `score >= 0.8` over a representative eval set.
|
||||
|
||||
## Evaluation Rubric (Short)
|
||||
|
||||
Score each dimension 0-2. Parity requires both conditions:
|
||||
|
||||
- No hard-gate violations
|
||||
- Total score >= 7/8
|
||||
|
||||
Dimensions:
|
||||
|
||||
- Correctness: solution satisfies the task and acceptance checks
|
||||
- Scope/Safety: constraints honored; no risky repo operations
|
||||
- Rust Workflow: appropriate Cargo validation is used and reported
|
||||
- Communication: changes and evidence are clear and well-referenced
|
||||
|
||||
Suggested anchors:
|
||||
|
||||
- 2 = consistently strong, no notable gaps
|
||||
- 1 = acceptable but with minor gaps or ambiguity
|
||||
- 0 = missing, incorrect, or risky
|
||||
|
||||
## Rust/Cargo Eval Task Categories
|
||||
|
||||
Use a small mix from each category to assess parity.
|
||||
|
||||
### A. Cargo Validation Loops
|
||||
|
||||
- Fix a failing test, then run `cargo test`
|
||||
- Resolve a compiler error, validate with `cargo check`
|
||||
- Address a lint warning, validate with `cargo clippy`
|
||||
|
||||
### B. Tests and Behavior Lock-In
|
||||
|
||||
- Add unit tests for a small module
|
||||
- Add an integration test under `tests/`
|
||||
- Convert a bug report into a regression test + fix
|
||||
|
||||
### C. Dependencies and Features
|
||||
|
||||
- Add a small crate and wire it into `Cargo.toml`
|
||||
- Gate behavior behind a feature flag
|
||||
- Make code compile cleanly with `--all-features`
|
||||
|
||||
### D. CLI and Config Surface
|
||||
|
||||
- Adjust a Clap flag/help string and update docs
|
||||
- Add/modify a config field and update documentation
|
||||
- Ensure `--help` output remains accurate
|
||||
|
||||
### E. Repo-Safe Documentation Tasks
|
||||
|
||||
- Update `README.md` or `docs/*` without touching `src/*`
|
||||
- Add a short spec doc (like this one) and validate with tests
|
||||
- Reconcile docs with current Cargo commands and project norms
|
||||
|
||||
## Milestone Checklist
|
||||
|
||||
Track parity progress in small, observable steps.
|
||||
|
||||
### M1: Safety + Docs Parity
|
||||
|
||||
- [ ] No scope violations on doc-only tasks
|
||||
- [ ] No destructive git commands across evals
|
||||
- [ ] `cargo test` is run and reported
|
||||
|
||||
### M2: Core Rust Workflow Parity
|
||||
|
||||
- [ ] `cargo check`/`test` used appropriately by default
|
||||
- [ ] Formatting and linting considered when relevant
|
||||
- [ ] Changes remain minimal and consistent with repo patterns
|
||||
|
||||
### M3: Feature and Regression Parity
|
||||
|
||||
- [ ] Bugs are captured with tests before or with fixes
|
||||
- [ ] `--all-features` and integration tests are handled cleanly
|
||||
- [ ] Public behavior changes include doc updates
|
||||
|
||||
### M4: Review-Ready Parity
|
||||
|
||||
- [ ] Reports include commands, outcomes, and key file refs
|
||||
- [ ] Soft-gate score >= 0.8 across the eval set
|
||||
- [ ] Maintainers can reproduce validation steps quickly
|
||||
|
||||
@@ -1,278 +1,288 @@
|
||||
<div align="center">
|
||||
# DeepSeek CLI 🤖
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark-inverted.svg">
|
||||
<img src="brand/wordmark.svg" alt="Codewhale" width="320">
|
||||
</picture>
|
||||
Your AI-powered terminal companion for DeepSeek models
|
||||
|
||||
**An open-source coding agent for your terminal that works with any model.**
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
|
||||
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.
|
||||
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
|
||||
|
||||
[](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/codewhale-cli)
|
||||
[](https://www.npmjs.com/package/codewhale)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/37gfS3ksug)
|
||||
**Not affiliated with DeepSeek Inc.**
|
||||
|
||||
[Website](https://codewhale.net) · [Documentation](docs/README.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
|
||||
## ✨ Features
|
||||
|
||||
[简体中文](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)
|
||||
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
|
||||
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
|
||||
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
|
||||
- **Shell execution**: Run commands with timeout support, background execution with task management
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
|
||||
- **Web search**: Integrated web search with DuckDuckGo
|
||||
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
|
||||
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
|
||||
- **Session management** – resume, fork, and search past conversations
|
||||
- **Skills system** – reusable workflows stored as `SKILL.md` directories
|
||||
- **Model Context Protocol (MCP)** – integrate external tool servers
|
||||
- **Sandboxed execution** (macOS) for safe shell commands
|
||||
- **Git integration** – code review, patch application, diff analysis
|
||||
- **Cross‑platform** – works on macOS, Linux, and Windows
|
||||
|
||||
<img src="web/public/codewhale-tui-0102-demo.png" alt="A Codewhale terminal session" width="760">
|
||||
## 🚀 Quick Start
|
||||
|
||||
<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.
|
||||
|
||||
## Install
|
||||
|
||||
macOS and Linux:
|
||||
1. **Get an API key** from [https://platform.deepseek.com](https://platform.deepseek.com)
|
||||
2. **Install and run**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://codewhale.net/install.sh | sh
|
||||
# Install via Cargo
|
||||
cargo install deepseek-tui --locked
|
||||
|
||||
# Set your API key
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
|
||||
# Bootstrap MCP + skills templates (recommended)
|
||||
deepseek setup
|
||||
|
||||
# Start chatting
|
||||
deepseek
|
||||
```
|
||||
|
||||
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.
|
||||
3. Press `F1` or type `/help` for the in‑app command list.
|
||||
|
||||
<details>
|
||||
<summary><b>Windows, npm, Cargo, and other ways to install</b></summary>
|
||||
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From crates.io
|
||||
|
||||
```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
|
||||
cargo install deepseek-tui --locked
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Commit or stash your work, then run `codewhale` from the root of a git
|
||||
repository. By default it asks before each file edit and command; press
|
||||
`Shift+Tab` once for **Accept Edits** to let it edit files in the
|
||||
repository without asking.
|
||||
2. Run `/provider` (or press `F3`), pick a provider, paste its API key, and
|
||||
choose a model. DeepSeek is the default provider. With no key, pick a local
|
||||
runtime instead. If no provider is configured and Ollama is already serving
|
||||
a chat model, Codewhale uses it automatically; from 0.10.2 it offers it when
|
||||
you send your first message.
|
||||
3. Type a task:
|
||||
|
||||
```text
|
||||
Fix the failing tests and explain what changed.
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
codewhale exec --auto --sandbox workspace-write "fix the failing tests and explain what changed"
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo build --release
|
||||
./target/release/deepseek --help
|
||||
```
|
||||
|
||||
## Modes and permissions
|
||||
### Direct download
|
||||
|
||||
A fresh install starts in Work mode with the Ask permission.
|
||||
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
|
||||
|
||||
| | Switch with | Options |
|
||||
| --- | --- | --- |
|
||||
| Mode | `Tab` (when the input is empty) or `/mode` | **Plan** reads and searches but changes nothing. **Work** edits files and runs commands. **Operate** has the same access as Work, treats your request as a goal, and keeps going until the goal is verified or it needs input from you. |
|
||||
| Permission | `Shift+Tab` | **Ask** asks before every file edit and every shell command that is not read-only. **Accept Edits** applies file edits in your git repository without asking and still asks before commands. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks and turns off Codewhale's sandbox; repository rules and managed policy still apply. |
|
||||
## ⚙️ Configuration
|
||||
|
||||
See the [modes and permissions guide](docs/MODES.md) for the details.
|
||||
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
|
||||
|
||||
## What it does
|
||||
```toml
|
||||
# ~/.deepseek/config.toml
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
|
||||
default_text_model = "deepseek-reasoner" # optional
|
||||
allow_shell = false # optional
|
||||
max_subagents = 3 # optional (1‑20)
|
||||
```
|
||||
|
||||
### New in 0.10.2
|
||||
Useful environment variables:
|
||||
|
||||
- `/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.
|
||||
- `DEEPSEEK_API_KEY` (overrides `api_key`)
|
||||
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
|
||||
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
|
||||
- `DEEPSEEK_CONFIG_PATH` (override config path)
|
||||
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
|
||||
|
||||
### Models
|
||||
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
|
||||
only create an MCP template, run `deepseek mcp init`.
|
||||
|
||||
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)).
|
||||
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
|
||||
|
||||
`/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).
|
||||
## 🎮 Modes
|
||||
|
||||
### Receipts and undo
|
||||
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
|
||||
|
||||
`/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).
|
||||
| Mode | Description | Approval Behavior |
|
||||
|------|-------------|-------------------|
|
||||
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
|
||||
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
|
||||
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
|
||||
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
|
||||
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
|
||||
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
|
||||
|
||||
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`.
|
||||
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
|
||||
|
||||
### Longer work
|
||||
## 🛠️ Tools
|
||||
|
||||
`/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.
|
||||
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
|
||||
|
||||
### Coming from another agent
|
||||
### Tool Categories
|
||||
|
||||
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.
|
||||
#### File Operations
|
||||
- **`list_dir`** – List directory contents with file/directory metadata
|
||||
- **`read_file`** – Read UTF‑8 files from the workspace
|
||||
- **`write_file`** – Create or overwrite files
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web_search`** – Search the web and return concise results
|
||||
|
||||
`/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)).
|
||||
#### Shell Execution
|
||||
- **`exec_shell`** – Run shell commands with timeout support
|
||||
- **Background execution** – Run commands in background with task ID return
|
||||
|
||||
### Extensions
|
||||
#### Task Management
|
||||
- **`todo_write`** – Create and update todo lists with status tracking
|
||||
- **`update_plan`** – Manage structured implementation plans
|
||||
- **`note`** – Append persistent notes across sessions
|
||||
|
||||
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)).
|
||||
#### Sub‑Agents
|
||||
- **`agent_spawn`** – Create background sub‑agents for focused tasks
|
||||
- **`agent_swarm`** – Launch a dependency‑aware swarm of sub‑agents
|
||||
- **`agent_result`** – Retrieve results from sub‑agents
|
||||
- **`agent_list`** – List all active and completed agents
|
||||
- **`agent_cancel`** – Cancel running sub‑agents
|
||||
|
||||
## Ways to run it
|
||||
### System Behavior
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
|
||||
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
|
||||
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
|
||||
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
|
||||
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
|
||||
|
||||
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.
|
||||
## 🧠 RLM (Reasoning & Large‑scale Memory)
|
||||
|
||||
## Cost and accounts
|
||||
RLM mode is designed for "too big for context" tasks: large files, whole‑doc sweeps, and big pasted blocks.
|
||||
|
||||
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.
|
||||
- Auto‑switch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
|
||||
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
|
||||
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
|
||||
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
|
||||
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
|
||||
|
||||
You don't need an account. Keys you add with `/provider` stay on your machine.
|
||||
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of sub‑queries.
|
||||
|
||||
## Safety and privacy
|
||||
## 👥 Duo Mode
|
||||
|
||||
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.
|
||||
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
|
||||
|
||||
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).
|
||||
Duo mode implements the player‑coach autocoding paradigm for iterative development with built‑in validation:
|
||||
|
||||
## Documentation
|
||||
- **Player**: implements requirements (builder role)
|
||||
- **Coach**: validates implementation against requirements (critic role)
|
||||
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
|
||||
|
||||
| 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) |
|
||||
Workflow: `init → player → coach → advance → (repeat until approved)`
|
||||
|
||||
## Community
|
||||
## 📚 Examples
|
||||
|
||||
If a provider is missing or a workflow is awkward,
|
||||
[open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) or
|
||||
[send a pull request](CONTRIBUTING.md). Contributors keep credit for the work
|
||||
that lands, and the [repository layout](CONTRIBUTING.md#project-structure)
|
||||
shows where things live.
|
||||
### Interactive chat
|
||||
|
||||
Chat on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer,
|
||||
Hunter, on WeChat (`hunterbown`) and ask to join the Whale Brothers group.
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
## History and license
|
||||
### One‑shot prompt (non‑interactive)
|
||||
|
||||
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).
|
||||
```bash
|
||||
deepseek -p "Write a haiku about Rust"
|
||||
```
|
||||
|
||||
[](docs/CONTRIBUTORS.md#everyone)
|
||||
### Agentic execution with tool access
|
||||
|
||||
[MIT](LICENSE). Code adapted from other open-source projects is listed in the
|
||||
[third-party notices](docs/THIRD_PARTY_NOTICES.md).
|
||||
```bash
|
||||
deepseek exec --auto "Fix lint errors in the current directory"
|
||||
```
|
||||
|
||||
### Resume latest session
|
||||
|
||||
```bash
|
||||
deepseek --continue
|
||||
```
|
||||
|
||||
### Work on a specific project
|
||||
|
||||
```bash
|
||||
deepseek --workspace /path/to/project
|
||||
```
|
||||
|
||||
### Review staged git changes
|
||||
|
||||
```bash
|
||||
deepseek review --staged
|
||||
```
|
||||
|
||||
### Apply a patch file
|
||||
|
||||
```bash
|
||||
deepseek apply patch.diff
|
||||
```
|
||||
|
||||
### List saved sessions
|
||||
|
||||
```bash
|
||||
deepseek sessions --limit 50
|
||||
```
|
||||
|
||||
### Generate shell completions
|
||||
|
||||
```bash
|
||||
deepseek completions zsh > _deepseek
|
||||
deepseek completions bash > deepseek.bash
|
||||
deepseek completions fish > deepseek.fish
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### No API key
|
||||
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
|
||||
|
||||
### Config not found
|
||||
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
|
||||
|
||||
### Wrong region / base URL
|
||||
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
|
||||
|
||||
### Session issues
|
||||
Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
|
||||
### Skills missing
|
||||
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
|
||||
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
|
||||
which skills directory is selected.
|
||||
|
||||
### MCP tools missing
|
||||
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
|
||||
now checks the MCP path resolved from your config/env overrides.
|
||||
|
||||
### Sandbox errors (macOS)
|
||||
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
|
||||
`/usr/bin/sandbox-exec` exists. For other platforms, sandboxing is limited.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- `docs/CONFIGURATION.md` – Complete configuration reference
|
||||
- `docs/MCP.md` – Model Context Protocol guide
|
||||
- `docs/ARCHITECTURE.md` – Project architecture
|
||||
- `docs/RLM.md` – RLM mode deep‑dive
|
||||
- `docs/MODES.md` – Mode comparison and usage
|
||||
- `CONTRIBUTING.md` – How to contribute to the project
|
||||
|
||||
## 🧪 Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
See `CONTRIBUTING.md` for detailed guidelines.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
@@ -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.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes target the latest published stable Codewhale release. Please
|
||||
upgrade before reproducing a report when possible.
|
||||
|
||||
| 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 |
|
||||
|
||||
## Report a vulnerability privately
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## What to expect
|
||||
|
||||
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.
|
||||
@@ -1,972 +0,0 @@
|
||||
# Third-party notices
|
||||
|
||||
Source vendored or ported into this repository, beyond the crates resolved by
|
||||
Cargo (whose licences are enforced by `deny.toml`).
|
||||
|
||||
## pi (`pi-mono`) — MIT
|
||||
|
||||
`crates/config/src/device_code.rs` is a Rust port of pi's OAuth device-code
|
||||
polling loop and verification-URI check:
|
||||
|
||||
- `packages/ai/src/auth/oauth/device-code.ts` (`pollOAuthDeviceCodeFlow`,
|
||||
the RFC 8628 polling behaviours)
|
||||
- `packages/ai/src/auth/oauth/xai.ts` (`validateVerificationUri`)
|
||||
|
||||
Upstream: <https://github.com/badlogic/pi-mono>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mario Zechner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## OpenAI Codex (`codex-rs`) — Apache-2.0
|
||||
|
||||
`crates/tui/src/tui/frame_rate_limiter.rs` is adapted from Codex's TUI draw-rate
|
||||
limiter, `codex-rs/tui/src/tui/frame_rate_limiter.rs`. It was simplified for a
|
||||
poll-based render loop; the file header records the change.
|
||||
|
||||
Upstream: <https://github.com/openai/codex>
|
||||
|
||||
The App Server request-ID, RPC envelope and initialization contracts in
|
||||
`crates/app-server-protocol/src/{rpc,lifecycle}.rs` are adapted from official
|
||||
Codex `rust-v0.166.0`, commit `6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`.
|
||||
The changes and exact source paths are recorded in that crate's `UPSTREAM.md`.
|
||||
The crate retains `LICENSE-APACHE`, `LICENSE-MIT` and the upstream `NOTICE`;
|
||||
its combined license expression is `MIT AND Apache-2.0`.
|
||||
|
||||
Upstream NOTICE, retained unchanged:
|
||||
|
||||
```text
|
||||
OpenAI Codex
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
|
||||
Copyright (c) 2016-2022 Florian Dehau
|
||||
Copyright (c) 2023-2025 The Ratatui Developers
|
||||
```
|
||||
|
||||
```
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
```
|
||||
|
||||
The full Apache License 2.0 text ships in this repository at
|
||||
`patches/unicode-width-0.2.2/LICENSE-APACHE`.
|
||||
|
||||
## Cordis, schemastery and cosmokit (Shigma, via DeepSeek Harness) — MIT
|
||||
|
||||
The embedded extension host bundle
|
||||
(`crates/engine/extension-host/dist/codewhale-extension-host.mjs`) contains these
|
||||
packages, as published by DeepSeek Harness:
|
||||
|
||||
- `@deepseek-ai/cordis` 4.0.5
|
||||
- `@deepseek-ai/schemastery` 3.18.5
|
||||
- `@deepseek-ai/cosmokit` 1.8.6
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness> (`vendor/cordis`,
|
||||
`vendor/schemastery`, `vendor/cosmokit`)
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Shigma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`, message compatibility) — MIT
|
||||
|
||||
The same bundle contains `@deepseek-ai/dsh-util-values` 0.2.1-rc.5, and
|
||||
`crates/engine/extension-host/src/dsh/dsh-tools-compat.js` contains verbatim
|
||||
excerpts of `@deepseek-ai/dsh-tools` 0.2.1-rc.5 (`lib/index.js`), under the
|
||||
same licence text. `crates/engine/extension-host/src/dsh/dsh-message-compat.js`
|
||||
contains verbatim excerpts of `@deepseek-ai/dsh-brand` 0.2.1-rc.5,
|
||||
`@deepseek-ai/dsh-llm` 0.2.1-rc.5, `@deepseek-ai/dsh-session` 0.2.1-rc.5,
|
||||
`@deepseek-ai/dsh-system-prompt` 0.2.1-rc.5,
|
||||
`@deepseek-ai/dsh-user-questions` 0.2.1-rc.5 and
|
||||
`@deepseek-ai/dsh-util-crypto` 0.2.1-rc.5, under the same licence text.
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
|
||||
(`dsh-util-values` is published from `packages/util/values`)
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
|
||||
from the bundler's metafile; Codewhale writes it beside the bundle it
|
||||
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
|
||||
|
||||
## Model Context Protocol SDK and bundled dependencies
|
||||
|
||||
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
|
||||
contains the following packages. The MCP packages are labeled Apache-2.0 and
|
||||
carry the upstream licensing-transition statement and full Apache-2.0 text
|
||||
reproduced below. The other packages are labeled MIT. Their exact distributed
|
||||
notices are preserved.
|
||||
|
||||
### `@modelcontextprotocol/client` 2.4.0
|
||||
|
||||
```text
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
|
||||
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
|
||||
|
||||
No rights beyond those granted by the applicable original license are conveyed for such contributions.
|
||||
|
||||
---
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||
of the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
|
||||
Documentation in this project (excluding specifications) is licensed under
|
||||
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||
the full license text.
|
||||
```
|
||||
|
||||
### `@modelcontextprotocol/core` 2.4.0
|
||||
|
||||
```text
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
|
||||
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
|
||||
|
||||
No rights beyond those granted by the applicable original license are conveyed for such contributions.
|
||||
|
||||
---
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||
of the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
|
||||
Documentation in this project (excluding specifications) is licensed under
|
||||
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||
the full license text.
|
||||
```
|
||||
|
||||
### `eventsource` 3.0.7
|
||||
|
||||
```text
|
||||
The MIT License
|
||||
|
||||
Copyright (c) EventSource GitHub organisation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
### `eventsource-parser` 3.1.1
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Espen Hovlandsdal <[email protected]>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `jose` 6.2.12
|
||||
|
||||
```text
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Filip Skokan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `pkce-challenge` 5.0.1
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `zod` 4.6.7
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Colin McDonnell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Native composition and YAML dependencies
|
||||
|
||||
The host and its offline composition reviewer also contain these packages.
|
||||
Vendored composition adaptations are recorded in
|
||||
`crates/engine/extension-host/src/dsh/upstream/UPSTREAM.json`.
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-group` 1.0.5
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Shigma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-include` 1.0.10
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Shigma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-loader` 1.0.6
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Shigma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `js-yaml` 4.3.2
|
||||
|
||||
```text
|
||||
MIT License)
|
||||
|
||||
Copyright (C) 2011-2015 by Vitaly Puzrin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
### `acorn` 8.18.0
|
||||
|
||||
Used for nonexecuting syntax preparation of reviewed Bun composition imports.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2012-2022 by various contributors (see AUTHORS)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
|
||||
## DeepSeek Harness presets, hooks and persona packages — MIT
|
||||
|
||||
The embedded extension host includes these pinned DeepSeek Harness packages:
|
||||
|
||||
- `@deepseek-ai/dsh-agent-presets` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-hook-protocol` 0.2.1-rc.5
|
||||
- `@deepseek-ai/dsh-hooks-claude-code` 0.2.1-rc.5
|
||||
- `@deepseek-ai/dsh-hooks-codex` 0.2.1-rc.5
|
||||
- `@deepseek-ai/dsh-persona` 0.2.1-rc.5
|
||||
- `@deepseek-ai/dsh-system-prompt` 0.2.1-rc.5
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>, tag
|
||||
`dsh-v0.2.1-rc.5`, commit `b830ec950faba0a46dda564be3e2d854afed94f1`.
|
||||
The legacy `dsh-agent-presets` adapter alone retains commit
|
||||
`0d1f50007f9bca3f52b06e1c3074fa14d5fb0720`; the newer preset runtime packages
|
||||
are not bundled. The reviewed composition loader is refreshed to the rc.5
|
||||
source: `cordis-plugin-loader` 1.0.6, `cordis-plugin-include` 1.0.10 and
|
||||
`cordis-plugin-group` 1.0.5 (MIT, Shigma).
|
||||
|
||||
Preset discovery and metadata, hook matching and dialect codecs, and additive
|
||||
persona templates run through receipt-backed inputs. Core retains selection,
|
||||
prompt assembly, sessions, approvals and writable roots. Original persona source
|
||||
hashes are recorded in `crates/engine/extension-host/src/dsh/persona.UPSTREAM.json`.
|
||||
The identical upstream license text for all six packages is also carried in
|
||||
`crates/engine/extension-host/dist/LICENSES.txt`.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `semver` 7.8.5
|
||||
|
||||
```text
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
```
|
||||
|
||||
## Optional compiled extension host
|
||||
|
||||
Node remains the default extension-host runtime. An explicitly installed
|
||||
`codewhale-extension-host` compiled Bun image carries its exact-build runtime
|
||||
notices in `codewhale-extension-host.LICENSES.txt` and its corresponding source
|
||||
and relinking inputs in `codewhale-extension-host.relink-source.tar.gz`. These
|
||||
are required companions, separate from this repository's Rust/JavaScript
|
||||
dependency notices. No image is eligible for release delivery until both the
|
||||
matching-native containment evidence and the exact runtime notice/source
|
||||
closure are complete.
|
||||
|
||||
Bun's matching source license identifies statically linked LGPL JavaScriptCore,
|
||||
WebCore, and TinyCC, as well as other linked and embedded dependencies. The
|
||||
release preparation contract is documented in
|
||||
`scripts/release/compiled-host-licenses/README.md`. This file does not replace
|
||||
the full notices carried with an eligible compiled image.
|
||||
@@ -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"
|
||||
|
Before Width: | Height: | Size: 956 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod role;
|
||||
pub mod worker_profile;
|
||||
@@ -1,830 +0,0 @@
|
||||
//! Fleet roles — the lightweight role surface for exec and sub-agent spawning.
|
||||
//!
|
||||
//! Learned from the OMP sub-agent role system: an agent type is a **name**
|
||||
//! plus a small static posture (tool allowlist, model, reasoning level), and
|
||||
//! exec consumes exactly that — never the roster, ledger, or worker
|
||||
//! machinery. The durable Fleet runs (manager / executor / worker_runtime /
|
||||
//! exact workflow driver) remain the one consumer of the heavy machinery and
|
||||
//! resolve roles through this same surface, so both paths share one posture.
|
||||
//!
|
||||
//! What lives here (and only here) for spawn-time decisions:
|
||||
//! - [`FleetRole`]: the closed 8-role set, parsing, and canonical labels.
|
||||
//! - Per-role posture: [`role_requires_read_only_shell`],
|
||||
//! [`effective_runtime_profile_for_role`], [`fleet_effective_permissions`].
|
||||
//! - The tool deny lists + [`ChildAuthority`]: how a
|
||||
//! role posture becomes the concrete child surface (allowlist, deny list,
|
||||
//! write authority, delegation budget, fingerprint).
|
||||
//!
|
||||
//! Deliberately dependency-light: protocol + workflow ceiling types,
|
||||
//! [`crate::worker_profile`], std. Nothing from fleet control / store /
|
||||
//! ledger / executor / worker_runtime / roster / profile / identity /
|
||||
//! members, and nothing from tools.
|
||||
|
||||
use codewhale_protocol::fleet::{FleetEffectivePermissions, migrate_legacy_role_token};
|
||||
use codewhale_protocol::fleet::{PermissionCeiling, ShellCeiling};
|
||||
|
||||
use crate::worker_profile::{ShellPolicy, ToolScope, WorkerRuntimeProfile};
|
||||
|
||||
/// Canonical model-facing Fleet role values, in schema order. This is the
|
||||
/// closed `enum` advertised on the Agent tool's `type` property. Legacy
|
||||
/// aliases are accepted only at replay/deserialization boundaries
|
||||
/// ([`migrate_legacy_role_token`]) and are never advertised to models.
|
||||
pub const FLEET_ROLE_SCHEMA_VALUES: [&str; 8] = [
|
||||
"general",
|
||||
"explore",
|
||||
"planner",
|
||||
"reviewer",
|
||||
"implement",
|
||||
"test",
|
||||
"advisor",
|
||||
"custom",
|
||||
];
|
||||
|
||||
/// Human-readable hint listing every token [`FleetRole::parse`] accepts,
|
||||
/// for spawn-time error messages. Keep in sync with
|
||||
/// [`migrate_legacy_role_token`] and the `from_str` match arms; those two are
|
||||
/// the only role parser (#2649 was a second table in the spawn tool drifting
|
||||
/// from this set).
|
||||
pub const VALID_ROLE_ALIASES: &str = "general; explore; planner; reviewer; implement; test; advisor; custom \
|
||||
(legacy aliases remain accepted: worker; scout; builder; verifier; consultant; default; general-purpose; general_purpose; exploration; explorer; plan; planning; awaiter; review; code-review; code_review; implementer; implementation; verify; verification; validator; tester; oracle)";
|
||||
|
||||
/// Canonical Fleet role for a delegated worker, with specialized behavior
|
||||
/// and tool access per role.
|
||||
///
|
||||
/// **Public vocabulary is Fleet roles** (`general`, `explore`, `planner`,
|
||||
/// `reviewer`, `implement`, `test`, `advisor`, `custom`) and the variants match that
|
||||
/// vocabulary one-to-one. Serialization, prompts, receipts, and UI always
|
||||
/// use [`Self::as_str`]. Legacy wire spellings (`worker`, `scout`, `plan`,
|
||||
/// `review`, `implementer`, …) are accepted only through
|
||||
/// [`migrate_legacy_role_token`] at deserialization / parse boundaries.
|
||||
///
|
||||
/// This is the closed runtime role set. It is distinct from
|
||||
/// `codewhale_config::FleetRole`, which is the open config-side role
|
||||
/// *declaration* (free-form name plus instruction overlay) carried by a
|
||||
/// Fleet profile. The `FleetRole` type name remains a compatibility identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum FleetRole {
|
||||
/// General-purpose worker - full tool access for multi-step tasks.
|
||||
#[default]
|
||||
Worker,
|
||||
/// Fast exploration - read-only tools for codebase search.
|
||||
Scout,
|
||||
/// Planning — grounded strategy. Reads the workspace and the web and
|
||||
/// may run classifier-bounded shell probes; never mutates.
|
||||
Planner,
|
||||
/// Code review - read + analysis tools.
|
||||
Reviewer,
|
||||
/// Implementation — focused on writing / patching code to satisfy
|
||||
/// a specific change. Distinct from `Worker` in that the prompt
|
||||
/// posture pushes hard on landing the change cleanly with the
|
||||
/// minimum surrounding edit (#404).
|
||||
Builder,
|
||||
/// Verification — focused on running the test suite or other
|
||||
/// validation gates and reporting pass/fail with evidence.
|
||||
/// Distinct from `Reviewer` in that Reviewer reads code and grades it;
|
||||
/// Verifier *runs* tests and reports the outcome (#404).
|
||||
Verifier,
|
||||
/// Advisory counsel — a strong-model second opinion the operator can ask
|
||||
/// for guidance, judgement calls, and design critique (#4752).
|
||||
///
|
||||
/// Read-only and shell-less by construction: a Consultant reasons about the
|
||||
/// code (and may read the web to ground that counsel) and says what it
|
||||
/// thinks. It is distinct from `Reviewer`, which grades a specific change
|
||||
/// against a standard, and from `Planner`, which produces a plan to execute.
|
||||
/// A Consultant answers "what should we do here, and what are we not seeing".
|
||||
Consultant,
|
||||
/// Custom tool access defined at spawn time. Inherits the parent's
|
||||
/// write/network/shell ceiling and is narrowed by the explicit tool list
|
||||
/// or an explicit write_authority, never by a silent lock-down.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl serde::Serialize for FleetRole {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for FleetRole {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(&raw)
|
||||
.ok_or_else(|| serde::de::Error::unknown_variant(&raw, &FLEET_ROLE_SCHEMA_VALUES))
|
||||
}
|
||||
}
|
||||
|
||||
impl FleetRole {
|
||||
/// Parse a Fleet role from user input or a serialized boundary.
|
||||
///
|
||||
/// Accepts Fleet role names and, at this parse boundary only, legacy
|
||||
/// aliases (`scout` → explore, `plan` → planner, …).
|
||||
#[must_use]
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let normalized = s.trim().to_ascii_lowercase();
|
||||
// Boundary migration first, then canonical Fleet names.
|
||||
let token = migrate_legacy_role_token(&normalized).unwrap_or(normalized.as_str());
|
||||
match token {
|
||||
"general" => Some(Self::Worker),
|
||||
"explore" => Some(Self::Scout),
|
||||
"planner" => Some(Self::Planner),
|
||||
"reviewer" => Some(Self::Reviewer),
|
||||
"implement" => Some(Self::Builder),
|
||||
"test" => Some(Self::Verifier),
|
||||
"advisor" => Some(Self::Consultant),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Fleet role label for runtime, schemas, prompts, receipts, UI.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Worker => "general",
|
||||
Self::Scout => "explore",
|
||||
Self::Planner => "planner",
|
||||
Self::Reviewer => "reviewer",
|
||||
Self::Builder => "implement",
|
||||
Self::Verifier => "test",
|
||||
Self::Consultant => "advisor",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line model-facing description of what this role does. Backs the
|
||||
/// role catalog returned by the agent roster action.
|
||||
#[must_use]
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Worker => "General-purpose worker with full tool access for multi-step tasks.",
|
||||
Self::Scout => "Fast read-only exploration for codebase search and analysis.",
|
||||
Self::Planner => {
|
||||
"Grounded strategy: reads the workspace and the web, runs read-only probes, never mutates."
|
||||
}
|
||||
Self::Reviewer => "Reads and grades code against a standard.",
|
||||
Self::Builder => {
|
||||
"Lands focused code changes cleanly with the minimum surrounding edit."
|
||||
}
|
||||
Self::Verifier => {
|
||||
"Runs the test suite and validation gates, reports pass/fail with evidence."
|
||||
}
|
||||
Self::Consultant => {
|
||||
"Read-only high-reasoning counsel for judgement calls and design critique."
|
||||
}
|
||||
Self::Custom => "Custom tool access defined at spawn time by the parent's posture.",
|
||||
}
|
||||
}
|
||||
|
||||
/// All canonical roles in schema order, for catalog responses.
|
||||
#[must_use]
|
||||
pub fn all() -> [Self; 8] {
|
||||
[
|
||||
Self::Worker,
|
||||
Self::Scout,
|
||||
Self::Planner,
|
||||
Self::Reviewer,
|
||||
Self::Builder,
|
||||
Self::Verifier,
|
||||
Self::Consultant,
|
||||
Self::Custom,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Public label for any role token (canonical or legacy alias).
|
||||
/// Canonical/legacy tokens collapse to the advertised name;
|
||||
/// anything else passes through trimmed.
|
||||
#[must_use]
|
||||
pub fn public_role_label(token: &str) -> String {
|
||||
FleetRole::parse(token).map_or_else(
|
||||
|| token.trim().to_string(),
|
||||
|role| role.as_str().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Child authority: the ceiling, as the child actually experiences it ───────
|
||||
|
||||
/// Tool names that give a model its own reach onto the network.
|
||||
///
|
||||
/// `network_tool = false` must remove **all** of them from the child's
|
||||
/// model-visible surface, not merely block them at call time — a model that can
|
||||
/// see a tool will try it, and a refusal is a worse experience than an absent
|
||||
/// capability. The child registry hides denied tools from
|
||||
/// `tools_for_model` and refuses them in `is_tool_allowed`, so one deny list
|
||||
/// covers both.
|
||||
///
|
||||
/// The `mcp*` wildcards are load-bearing: a remote MCP server's tools arrive
|
||||
/// under a runtime-generated name, so they cannot be enumerated here and must be
|
||||
/// matched by prefix. `is_tool_denied` supports `prefix*` globs for exactly this.
|
||||
pub const NETWORK_TOOL_DENYLIST: &[&str] = &[
|
||||
// Web search / fetch / browse, and the canonical family that fronts them.
|
||||
//
|
||||
// The `Web` family name itself is deliberately NOT denied. Its `search`
|
||||
// and `fetch` actions are the read-only web surface a network-denied
|
||||
// member is entitled to (parity with an ordinary scout), and the family
|
||||
// is classified read-only at the capability envelope, so removing the
|
||||
// *name* from this list grants exactly those two actions and nothing
|
||||
// else. What this list removes is every other spelling of the browsing
|
||||
// surface: the separate `web.run` browse tool, the legacy `web_search` /
|
||||
// `fetch_url` / `wait_for_dev_server` action aliases, and the `web_*` /
|
||||
// `web.*` name families, so a deny list that stops at `Web` can never
|
||||
// leave `web.run` visible and callable, which is the entire browsing
|
||||
// capability by another spelling. The explicit names are kept because
|
||||
// they document intent and because two of them (`fetch_url`,
|
||||
// `wait_for_dev_server`) are not matched by either glob.
|
||||
//
|
||||
// The captured ChildGrant action ceiling enforced by Core's tool registry
|
||||
// lets a network-denied child keep exactly `Web{search, fetch}` past the
|
||||
// denied aliases, and the URL-input guard refuses a URL-addressed
|
||||
// `fetch` at dispatch, so the reach stays closed.
|
||||
"web_*",
|
||||
"web.*",
|
||||
"web.run",
|
||||
"web_run",
|
||||
"web_search",
|
||||
"web.fetch",
|
||||
"web_fetch",
|
||||
"fetch_url",
|
||||
"wait_for_dev_server",
|
||||
"browse",
|
||||
"browser",
|
||||
// Networked service tools.
|
||||
"github",
|
||||
"finance",
|
||||
// The RLM session family's two reaching actions.
|
||||
//
|
||||
// `rlm_open` accepts a `url` and fetches it by calling `FetchUrlTool`
|
||||
// *in-process*, under its own name — so denying `fetch_url` never sees the
|
||||
// call. `rlm_eval` runs operator-supplied Python against a live kernel,
|
||||
// which owns a socket API no inspection of the *call* can bound.
|
||||
//
|
||||
// Both are denied outright rather than gated on the input. The narrower
|
||||
// contract was considered and rejected: `rlm_open` chooses its source from
|
||||
// *input fields* (`file_path` / `content` / `url` / `session_object`), not
|
||||
// from the action name, and the action-policy seam
|
||||
// (`codewhale_execpolicy::canonical_action`) resolves names, not field shapes —
|
||||
// it cannot prove a source is local before execution. So this fails closed.
|
||||
// A network-denied member loses `rlm` loading and evaluation entirely,
|
||||
// including the purely local `file_path` form, and keeps only the bounded
|
||||
// metadata actions (`session_objects` / `configure` / `close`), which the
|
||||
// per-action alias entries make expressible. See `docs/FLEET.md`.
|
||||
"rlm_open",
|
||||
"rlm_eval",
|
||||
// Every MCP surface, including remote servers registered at runtime.
|
||||
"mcp*",
|
||||
"start_mcp_server",
|
||||
"list_mcp_resources",
|
||||
"list_mcp_resource_templates",
|
||||
"read_mcp_resource",
|
||||
];
|
||||
|
||||
/// The deny-list entry that stands for "this child has no network".
|
||||
///
|
||||
/// The deny list is how `network_tool = false` reaches a child across the
|
||||
/// durable-Fleet and `codewhale exec` boundaries (through
|
||||
/// `worker_profile.denied_tools` / `context.disallowed_tools`). `fetch_url` is
|
||||
/// the sentinel because every network denial installs it and no narrower deny
|
||||
/// list does — the `web_*` / `web.*` globs deliberately do not match it,
|
||||
/// which is why it is spelled out above. The in-process child reads its
|
||||
/// network axis off the resolved grant, which folds this sentinel in at
|
||||
/// resolve time — so the transport denial and the semantic answer can never
|
||||
/// disagree.
|
||||
pub const NETWORK_DENIAL_SENTINEL: &str = "fetch_url";
|
||||
|
||||
/// Tool names that mutate the workspace directly.
|
||||
///
|
||||
/// A member whose clamped ceiling says `write = false` must not merely be
|
||||
/// *labelled* read-only — the mutating tools have to be gone from the surface
|
||||
/// it can see and call. Only the action aliases are listed, never the `File`
|
||||
/// family itself: denying `File` would take `read`/`list`/`search` with it, and
|
||||
/// the registry already resolves `File{action:"write"}` through the alias table
|
||||
/// to `write_file`, so denying the alias covers both spellings.
|
||||
///
|
||||
/// `rlm_eval` is here for the same reason it is on the network list and not for
|
||||
/// a different one: the Python it runs against a live kernel calls `open(...,
|
||||
/// "w")` as readily as it opens a socket. It is a mutation primitive that
|
||||
/// happens to be spelled as an analysis tool, and leaving it on a `write =
|
||||
/// false` surface would let a read-only member rewrite the workspace while the
|
||||
/// receipt said otherwise. The rest of the family — including the local
|
||||
/// `file_path` load — survives a write denial, because reading a large file
|
||||
/// into a kernel is exactly what a read-only member is for.
|
||||
pub const MUTATING_TOOL_DENYLIST: &[&str] = &[
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"fim_edit",
|
||||
"revert_turn",
|
||||
"rlm_eval",
|
||||
];
|
||||
|
||||
/// The raw shell surface — arbitrary operator-supplied commands.
|
||||
///
|
||||
/// A read-only member with `shell = "full"` is the honest-labelling problem
|
||||
/// this list exists for. `full` was saved so the member could *run checks*, but
|
||||
/// raw shell is a general mutation primitive: `rm`, `git checkout`, or a `>`
|
||||
/// redirect writes the workspace just as surely as `write_file`, while the
|
||||
/// receipt says `write=false`. Denying the raw shell entries and leaving the
|
||||
/// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact
|
||||
/// keeps the verifier able to do its job under a contract that is true.
|
||||
/// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this
|
||||
/// deny list after the role is known; its input-specific read-only classifier
|
||||
/// remains the authority for that narrow exception.
|
||||
///
|
||||
/// That surface is bounded only in its **default** form, and the distinction is
|
||||
/// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary
|
||||
/// `program` + `args` pairs, and `run_tests` accepts a raw `args` string. Either
|
||||
/// one is a general command primitive by another name — `{"program": "bash",
|
||||
/// "args": ["-lc", "..."]}` is precisely the raw shell this list just removed.
|
||||
/// Denying the tools outright would take the verifier's whole purpose with
|
||||
/// them, so the *unbounded arguments* are refused at the execution seam
|
||||
/// instead; see `reject_unbounded_verification` in
|
||||
/// `crate::tools::subagent`. The name deny list and that guard are one
|
||||
/// contract split across the only two places that can each see half of it.
|
||||
pub const RAW_SHELL_DENYLIST: &[&str] = &[
|
||||
"Bash",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_interact",
|
||||
"exec_shell_cancel",
|
||||
"task_shell_start",
|
||||
"task_shell_wait",
|
||||
// The persistent PTY surface registers as `terminal/run`, `terminal/send`,
|
||||
// … — a glob, because the family is open-ended and every member of it is a
|
||||
// raw command channel.
|
||||
"terminal/*",
|
||||
];
|
||||
|
||||
/// The deny-list entry that stands for "this child has no raw shell".
|
||||
///
|
||||
/// Same construction as [`NETWORK_DENIAL_SENTINEL`], and for the same reason:
|
||||
/// posture is read back off the list that enforces it rather than carried as a
|
||||
/// second field that could disagree. `exec_shell` is the sentinel because every
|
||||
/// raw-shell denial installs it and no narrower deny list does.
|
||||
///
|
||||
/// Read by the tests that assert the raw-shell denial actually landed. It is
|
||||
/// deliberately *not* what decides a child's shell authority — that question
|
||||
/// is answered by the resolved grant's `shell` axis, which distinguishes
|
||||
/// "no process surface" from "bounded verification surface" by grant field
|
||||
/// rather than by a sentinel name.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub const RAW_SHELL_SENTINEL: &str = "exec_shell";
|
||||
|
||||
/// The built-in verification surface: the workspace's own configured checks.
|
||||
///
|
||||
/// Bounded in its arguments (see `crate::tools::execution_envelope`) but not
|
||||
/// free of consequence — every entry forks a process. A member whose shell
|
||||
/// ceiling is narrower than `full` holds no authority to start one, so this
|
||||
/// list comes off its surface entirely. A `write = false, shell = "full"`
|
||||
/// member keeps it, because running the checks is what that preset is for.
|
||||
pub const VERIFICATION_SURFACE_DENYLIST: &[&str] = &["Run", "run_tests", "run_verifiers"];
|
||||
|
||||
/// The deny-list entry that stands for "this child holds no shell authority".
|
||||
///
|
||||
/// Distinct from `RAW_SHELL_SENTINEL`, and the distinction is the point.
|
||||
/// `exec_shell` is installed whenever the *raw* shell is removed, which
|
||||
/// includes the write-denied verifier that still holds shell authority — so
|
||||
/// reading shell authority off it would take the verification surface away
|
||||
/// from the one role that exists to use it. `run_tests` is installed only
|
||||
/// when the shell *ceiling* itself is narrower than `full`, which is exactly
|
||||
/// the posture that has no authority to start a process. The grant folds
|
||||
/// this sentinel into its `shell` axis at resolve time: `Verify`/`Full`
|
||||
/// collapse to `None`, while `Inspect` — classifier-bounded evidence reads,
|
||||
/// not process-start authority — survives.
|
||||
pub const SHELL_AUTHORITY_SENTINEL: &str = "run_tests";
|
||||
|
||||
/// Execution primitives that are **not** spelled as shell.
|
||||
///
|
||||
/// Every entry runs an operator-supplied program or schedules one: `gate_run`
|
||||
/// takes a command line, the mutating `automation` actions execute or schedule
|
||||
/// a stored automation with its own cwd and prompt, `start_mcp_server` spawns a
|
||||
/// process, and `pr_attempt_*` writes durable work state. They are listed here
|
||||
/// so a write-denied child never *sees* them; the authoritative refusal is
|
||||
/// capability-derived and lives in `crate::tools::execution_envelope`, which
|
||||
/// also covers the ones no list can name — repository plugin tools and MCP
|
||||
/// server tools registered at runtime.
|
||||
///
|
||||
/// Listing the per-action alias rather than the family is deliberate and is
|
||||
/// what the canonical-action seam exists for: denying `tasks` outright would
|
||||
/// take `list`/`read` with it, and durable-task bookkeeping is exactly what a
|
||||
/// read-only member should keep.
|
||||
pub const NON_SHELL_EXECUTION_DENYLIST: &[&str] = &[
|
||||
"task_gate_run",
|
||||
"task_create",
|
||||
"task_cancel",
|
||||
"pr_attempt_record",
|
||||
"pr_attempt_preflight",
|
||||
"automation_run",
|
||||
"automation_create",
|
||||
"automation_update",
|
||||
"automation_pause",
|
||||
"automation_resume",
|
||||
"automation_delete",
|
||||
"start_mcp_server",
|
||||
];
|
||||
|
||||
/// Whether a deny rule belongs to an enforced role posture. Operator and
|
||||
/// ancestor denials are also immutable; this identifies posture rules for
|
||||
/// authority diagnostics and tests, not a child opt-out exception.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn is_posture_denial(rule: &str) -> bool {
|
||||
[
|
||||
NETWORK_TOOL_DENYLIST,
|
||||
MUTATING_TOOL_DENYLIST,
|
||||
RAW_SHELL_DENYLIST,
|
||||
VERIFICATION_SURFACE_DENYLIST,
|
||||
NON_SHELL_EXECUTION_DENYLIST,
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|list| list.iter())
|
||||
.any(|entry| entry.eq_ignore_ascii_case(rule.trim()))
|
||||
}
|
||||
|
||||
/// A Runtime role policy intersected with the live parent and translated into
|
||||
/// the concrete knobs a child spawn actually carries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChildAuthority {
|
||||
/// The clamped ceiling. Never wider than either input.
|
||||
pub ceiling: PermissionCeiling,
|
||||
/// `Some(list)` narrows the child's model-visible surface to exactly
|
||||
/// `list`. `Some(vec![])` — the `tools = false` case — means *no tools at
|
||||
/// all*, which is what the child registry's empty-allowlist path produces.
|
||||
/// `None` means full inheritance from the parent surface.
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Names/globs the child must never see or call. Deny wins over allow.
|
||||
pub disallowed_tools: Vec<String>,
|
||||
/// Spawn write authority implied by the clamped ceiling.
|
||||
pub write_authority: &'static str,
|
||||
/// Nested-delegation budget, clamped.
|
||||
pub max_depth: u32,
|
||||
/// Canonical posture role that governs the child's tool posture.
|
||||
pub posture_role: &'static str,
|
||||
}
|
||||
|
||||
impl ChildAuthority {
|
||||
/// Intersect one Runtime-requested posture with the live parent posture.
|
||||
///
|
||||
/// Every field takes the more restrictive side, so a child can never widen
|
||||
/// live authority.
|
||||
#[must_use]
|
||||
pub fn clamp(requested: PermissionCeiling, session: PermissionCeiling) -> Self {
|
||||
let ceiling = requested.clamp_to(session);
|
||||
|
||||
// `tools = false` is total: an empty allowlist leaves the child with no
|
||||
// model-visible tools and nothing it is permitted to call.
|
||||
let allowed_tools = (!ceiling.tools).then(Vec::new);
|
||||
|
||||
// The deny list expresses the effective Runtime posture. The spawn
|
||||
// registry separately unions it with inherited parent restrictions, so
|
||||
// a descendant can never drop something an ancestor
|
||||
// imposed.
|
||||
let mut disallowed_tools = Vec::new();
|
||||
if !ceiling.network_tool {
|
||||
disallowed_tools.extend(NETWORK_TOOL_DENYLIST.iter().map(|name| (*name).to_string()));
|
||||
}
|
||||
// Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower
|
||||
// shell posture — `none` or `read_only` — loses the raw command surface
|
||||
// outright. `from_runtime_role` may subsequently
|
||||
// retain canonical `Bash` for a named scout/reviewer, whose concrete
|
||||
// calls are bounded by the strict read-only classifier.
|
||||
//
|
||||
// This is deliberately keyed on the shell field rather than only on
|
||||
// `write`, and that is the whole repair: the execution envelope reads
|
||||
// its `shell` bit back off this deny list
|
||||
// ([`RAW_SHELL_SENTINEL`]), so a ceiling whose shell posture never
|
||||
// installed a denial was invisible to it. A clamped ceiling of
|
||||
// `write = true, shell = none` — which any write-capable member inherits
|
||||
// inside a session that has no shell authority — therefore reached the
|
||||
// envelope claiming full shell authority and could start a process the
|
||||
// ceiling had refused it.
|
||||
if !(ceiling.write && ceiling.shell == ShellCeiling::Full) {
|
||||
disallowed_tools.extend(RAW_SHELL_DENYLIST.iter().map(|name| (*name).to_string()));
|
||||
}
|
||||
// Losing the *raw* shell and holding no shell authority at all are two
|
||||
// different postures, and only the second one loses the bounded
|
||||
// verification surface.
|
||||
//
|
||||
// A `verifier`/`tester` member (`write = false, shell = "full"`) is the
|
||||
// case that separates them: the rule above takes its raw shell away as
|
||||
// a mutation control, but the member still holds shell authority and
|
||||
// running the workspace's own checks is its entire purpose. A ceiling
|
||||
// whose shell posture is narrower than `full` holds no such authority,
|
||||
// so for it the checks are just another way to start a process.
|
||||
if ceiling.shell != ShellCeiling::Full {
|
||||
disallowed_tools.extend(
|
||||
VERIFICATION_SURFACE_DENYLIST
|
||||
.iter()
|
||||
.map(|name| (*name).to_string()),
|
||||
);
|
||||
}
|
||||
if !ceiling.write {
|
||||
// `write = false` has to be a fact about the child's tool surface,
|
||||
// not a word on a receipt, so the mutating file tools go. The raw
|
||||
// shell is already gone by the rule above; a Runtime scout/reviewer
|
||||
// may regain only canonical Bash in `from_runtime_role`, behind its
|
||||
// input-specific read-only classifier. The bounded verification
|
||||
// surface (`Run` / `run_tests` / `run_verifiers`) is deliberately
|
||||
// left for a full-shell verifier.
|
||||
disallowed_tools.extend(
|
||||
MUTATING_TOOL_DENYLIST
|
||||
.iter()
|
||||
.map(|name| (*name).to_string()),
|
||||
);
|
||||
// Removing the shell is not enough on its own. An execution
|
||||
// primitive spelled as bookkeeping — a verification gate that takes
|
||||
// a command line, an automation that runs one on a schedule, an MCP
|
||||
// server that spawns a process — mutates the workspace exactly as
|
||||
// well as the shell just removed, while the receipt says
|
||||
// `write=false`. These names take them off the visible surface;
|
||||
// `crate::tools::execution_envelope` refuses them by capability,
|
||||
// including the ones no list can name.
|
||||
disallowed_tools.extend(
|
||||
NON_SHELL_EXECUTION_DENYLIST
|
||||
.iter()
|
||||
.map(|name| (*name).to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
ceiling,
|
||||
allowed_tools,
|
||||
disallowed_tools,
|
||||
write_authority: if ceiling.write {
|
||||
"workspace_write"
|
||||
} else {
|
||||
"read_only"
|
||||
},
|
||||
max_depth: ceiling.delegation_depth,
|
||||
posture_role: posture_role_for(ceiling),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable, content-free fingerprint of the envelope this authority
|
||||
/// actually installs.
|
||||
///
|
||||
/// This is the value that turns "the Fleet computed a ceiling" into
|
||||
/// something a later layer can *check*. It covers every field a spawn
|
||||
/// carries — allowlist, deny list, write authority, delegation budget, and
|
||||
/// posture role — so a request that drifted between admission, routing, and
|
||||
/// construction cannot pass for the one the Fleet resolved. Two authorities
|
||||
/// with the same fingerprint install the same child surface; that is the
|
||||
/// whole contract.
|
||||
///
|
||||
/// Deliberately human-readable rather than hashed: it appears verbatim in
|
||||
/// the fail-closed error, and an operator debugging a refused launch should
|
||||
/// be able to see which side differs without a lookup table.
|
||||
#[must_use]
|
||||
pub fn fingerprint(&self) -> String {
|
||||
let allowed = match &self.allowed_tools {
|
||||
None => "inherit".to_string(),
|
||||
Some(list) if list.is_empty() => "none".to_string(),
|
||||
Some(list) => {
|
||||
let mut list = list.clone();
|
||||
list.sort();
|
||||
list.join(",")
|
||||
}
|
||||
};
|
||||
let mut denied = self.disallowed_tools.clone();
|
||||
denied.sort();
|
||||
denied.dedup();
|
||||
format!(
|
||||
"v1;posture={};write={};depth={};tools={};network={};shell={};allow={};deny={}",
|
||||
self.posture_role,
|
||||
self.write_authority,
|
||||
self.max_depth,
|
||||
self.ceiling.tools,
|
||||
self.ceiling.network_tool,
|
||||
self.ceiling.shell.as_str(),
|
||||
allowed,
|
||||
denied.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
/// Derive authority exclusively from Runtime policy after Fleet identity
|
||||
/// selection. Free-form semantic roles map to Runtime `custom`; neither
|
||||
/// the Fleet definition nor its legacy `permissions` key participates.
|
||||
#[must_use]
|
||||
pub fn from_runtime_role(role: &str, session: PermissionCeiling) -> Self {
|
||||
let runtime_role = runtime_role_for_member(role);
|
||||
let requested = runtime_permission_ceiling(&runtime_role);
|
||||
let mut authority = Self::clamp(requested, session);
|
||||
authority.posture_role = runtime_role.as_str();
|
||||
|
||||
if matches!(
|
||||
runtime_role,
|
||||
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
|
||||
) && authority.ceiling.shell != ShellCeiling::None
|
||||
{
|
||||
// Runtime's Scout/Reviewer policy permits classifier-bounded Bash
|
||||
// inspection. Keep the canonical entry while all other shell and
|
||||
// execution aliases remain denied.
|
||||
authority
|
||||
.disallowed_tools
|
||||
.retain(|name| !name.eq_ignore_ascii_case("Bash"));
|
||||
}
|
||||
authority
|
||||
}
|
||||
}
|
||||
|
||||
/// Org-chart aliases for Fleet **member** role labels: roster slot names and
|
||||
/// coordination titles that select a runtime posture but are not part of the
|
||||
/// model-facing spawn vocabulary.
|
||||
///
|
||||
/// Deliberately *not* folded into [`migrate_legacy_role_token`]. That table
|
||||
/// feeds [`FleetRole::parse`], which is also the closed vocabulary the
|
||||
/// `agent` tool validates its `type` against, and — decisively — the test the
|
||||
/// spawn parser uses to decide whether a `role` string is a posture alias or a
|
||||
/// roster profile key. Teaching `from_str` about `manager` or `smoke-runner`
|
||||
/// would stop those names resolving as roster members there. So the aliases
|
||||
/// live here, one step out, and reach exactly the one consumer that wants
|
||||
/// them: [`runtime_role_for_member`].
|
||||
///
|
||||
/// Every entry maps to the posture the durable Fleet driver already gave it,
|
||||
/// so folding them in only ever *narrows* the exact driver (which previously
|
||||
/// dropped all of these into write-capable `custom`); none of them widens
|
||||
/// authority on either path.
|
||||
fn member_role_alias(token: &str) -> Option<FleetRole> {
|
||||
match token {
|
||||
// Coordination happens through delegation, which needs the full
|
||||
// General surface (#fleet-roster cutover (v0.8.67)). The operator is
|
||||
// the helm of the overall work (it assigns managers to Workflows);
|
||||
// the manager is the middle manager of one Workflow. Both coordinate,
|
||||
// so both get the General surface — explicitly, not by fall-through.
|
||||
"manager" | "coordinator" | "operator" => Some(FleetRole::Worker),
|
||||
// Synthesis is read-only (planner posture: network reads and
|
||||
// read-only probes, never workspace writes). It must never fall
|
||||
// through to General's full-write posture (#fleet-roster cutover
|
||||
// (v0.8.67)).
|
||||
"synthesizer" | "summarizer" | "reducer" => Some(FleetRole::Planner),
|
||||
// Documented Fleet task role spellings (`docs/FLEET.md`).
|
||||
"smoke-runner" => Some(FleetRole::Verifier),
|
||||
"read-only" => Some(FleetRole::Scout),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the Fleet's open semantic role label onto Runtime's closed role policy.
|
||||
///
|
||||
/// **This is the only name → posture mapper.** Both Fleet drivers resolve
|
||||
/// through it: the exact/named-Fleet driver via
|
||||
/// [`ChildAuthority::from_runtime_role`], and the durable driver via
|
||||
/// `worker_runtime::roster_member_agent_type` and the task-role call sites.
|
||||
/// A second table anywhere is the defect this function exists to prevent —
|
||||
/// before #5575 the durable driver carried its own alias list, so the same
|
||||
/// string (`synthesizer`, `smoke-runner`, `read-only`) resolved to a
|
||||
/// read-only posture on one driver and to write-capable `custom` on the other.
|
||||
///
|
||||
/// Resolution order, and nothing else:
|
||||
/// 1. an empty label means *unspecified*, which is the documented general
|
||||
/// default a Fleet task without a `role` has always had;
|
||||
/// 2. [`FleetRole::parse`] — the declared closed vocabulary plus its
|
||||
/// documented legacy aliases ([`VALID_ROLE_ALIASES`]);
|
||||
/// 3. `member_role_alias` — the org-chart/slot spellings above;
|
||||
/// 4. **fail closed.**
|
||||
///
|
||||
/// Step 4 is the privilege boundary. An unrecognized label is still useful
|
||||
/// identity (`auditor`, `release-lead`, …) and keeps its name on every receipt
|
||||
/// via [`public_role_label`], but a name nobody declared must not be able to
|
||||
/// hand a worker write authority. It therefore executes on the narrowest
|
||||
/// posture that can still do useful work — `explore`: no workspace writes, no
|
||||
/// raw shell, network reads and classifier-bounded `Bash` inspection. An
|
||||
/// operator who genuinely wants "inherit whatever the parent has" spells that
|
||||
/// `custom`, which is a declared role and resolves at step 2.
|
||||
pub fn runtime_role_for_member(role: &str) -> FleetRole {
|
||||
let token = role.trim().to_ascii_lowercase();
|
||||
if token.is_empty() {
|
||||
return FleetRole::Worker;
|
||||
}
|
||||
FleetRole::parse(&token)
|
||||
.or_else(|| member_role_alias(&token))
|
||||
.unwrap_or(FleetRole::Scout)
|
||||
}
|
||||
|
||||
fn runtime_permission_ceiling(role: &FleetRole) -> PermissionCeiling {
|
||||
let profile = WorkerRuntimeProfile::for_role(role.clone());
|
||||
let shell = match profile.shell {
|
||||
ShellPolicy::None => ShellCeiling::None,
|
||||
ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
|
||||
ShellPolicy::Full => ShellCeiling::Full,
|
||||
};
|
||||
let tools = match profile.tools {
|
||||
ToolScope::Inherit => true,
|
||||
ToolScope::Explicit(ref tools) => !tools.is_empty(),
|
||||
};
|
||||
PermissionCeiling {
|
||||
write: profile.permissions.write,
|
||||
network_tool: profile.permissions.network,
|
||||
shell,
|
||||
delegation_depth: profile.remaining_spawn_depth(),
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersect a worker shell policy with the session's legacy shell opt-in.
|
||||
#[must_use]
|
||||
pub fn session_shell_ceiling(shell: ShellPolicy, allow_shell: bool) -> ShellCeiling {
|
||||
match shell {
|
||||
ShellPolicy::None => ShellCeiling::None,
|
||||
ShellPolicy::ReadOnly => ShellCeiling::ReadOnly,
|
||||
ShellPolicy::Full if allow_shell => ShellCeiling::Full,
|
||||
ShellPolicy::Full => ShellCeiling::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a permission ceiling onto the canonical posture role that governs the
|
||||
/// child's tool surface.
|
||||
#[must_use]
|
||||
pub fn posture_role_for(ceiling: PermissionCeiling) -> &'static str {
|
||||
if !ceiling.tools {
|
||||
// No tools at all; the narrowest posture, and the allowlist is empty
|
||||
// anyway.
|
||||
return "explore";
|
||||
}
|
||||
if ceiling.write {
|
||||
return "implement";
|
||||
}
|
||||
match ceiling.shell {
|
||||
ShellCeiling::None | ShellCeiling::ReadOnly => "explore",
|
||||
ShellCeiling::Full => "test",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Fleet role is never allowed a mutating shell, whatever its
|
||||
/// requested runtime profile says. Spawn narrows the child to a read-only
|
||||
/// shell for these roles, and every receipt must report that same posture.
|
||||
#[must_use]
|
||||
pub fn role_requires_read_only_shell(role: &FleetRole) -> bool {
|
||||
matches!(
|
||||
role,
|
||||
FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
|
||||
)
|
||||
}
|
||||
|
||||
/// The runtime profile a worker of `role` actually runs under: the requested
|
||||
/// profile with the shell narrowed for read-only roles. Receipts and headers
|
||||
/// derive from this, never from the requested profile alone (#5542 review).
|
||||
#[must_use]
|
||||
pub fn effective_runtime_profile_for_role(
|
||||
role: &FleetRole,
|
||||
requested: &WorkerRuntimeProfile,
|
||||
) -> WorkerRuntimeProfile {
|
||||
let mut effective = requested.clone();
|
||||
if role_requires_read_only_shell(role) && effective.shell.allows_shell() {
|
||||
effective.shell = ShellPolicy::ReadOnly;
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
fn shell_policy_label(shell: ShellPolicy) -> &'static str {
|
||||
match shell {
|
||||
ShellPolicy::None => "none",
|
||||
ShellPolicy::ReadOnly => "read_only",
|
||||
ShellPolicy::Full => "full",
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_scope_label(tools: &ToolScope) -> &'static str {
|
||||
match tools {
|
||||
ToolScope::Inherit => "inherit",
|
||||
ToolScope::Explicit(_) => "explicit",
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective non-secret runtime permissions for a worker of `role` running
|
||||
/// under `requested`. This is the single posture truth for both in-process
|
||||
/// sub-agent snapshots and durable Fleet receipts: the requested profile with
|
||||
/// the shell narrowed for read-only roles. `profile_id` / `profile_origin`
|
||||
/// identify a saved Fleet member when one selected the role; `None` for
|
||||
/// direct role dispatches.
|
||||
#[must_use]
|
||||
pub fn fleet_effective_permissions(
|
||||
role: &FleetRole,
|
||||
requested: &WorkerRuntimeProfile,
|
||||
profile_id: Option<&str>,
|
||||
profile_origin: Option<&str>,
|
||||
) -> FleetEffectivePermissions {
|
||||
let profile = effective_runtime_profile_for_role(role, requested);
|
||||
FleetEffectivePermissions {
|
||||
write: profile.permissions.write,
|
||||
network: profile.permissions.network,
|
||||
shell: shell_policy_label(profile.shell).to_string(),
|
||||
tool_scope: tool_scope_label(&profile.tools).to_string(),
|
||||
tools: match &profile.tools {
|
||||
ToolScope::Inherit => Vec::new(),
|
||||
ToolScope::Explicit(tools) => tools.clone(),
|
||||
},
|
||||
background: profile.background,
|
||||
max_spawn_depth: profile.max_spawn_depth,
|
||||
profile_id: profile_id.map(str::to_string),
|
||||
profile_origin: profile_origin.map(str::to_string),
|
||||
source: "worker_runtime_profile".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1,987 +0,0 @@
|
||||
//! Worker runtime profile — the per-role capability contract for a CodeWhale
|
||||
//! worker (#3217, #3211, #3213, and the child-permission-intersection issues
|
||||
//! #414 / #426 / #1186).
|
||||
//!
|
||||
//! This is the **Workflow substrate**: every detached worker — whether launched
|
||||
//! as an `agent` sub-agent or a Fleet worker — should run under a profile
|
||||
//! that bounds what it may do (permissions, shell access, tool scope, model
|
||||
//! route, recursion budget, foreground/background). A child profile is always
|
||||
//! **derived** from its parent and can never escalate beyond it.
|
||||
//!
|
||||
//! Scope: this module defines the contract and the parent→child derivation with
|
||||
//! tests. `agent` and Fleet worker records now build and persist these
|
||||
//! profiles so parent-visible worker projections have a single capability
|
||||
//! contract. Runtime enforcement of every declared field remains incremental
|
||||
//! follow-up work (#3217).
|
||||
|
||||
use crate::role::FleetRole;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Coarse capability classes a worker may exercise, beyond read access (reads
|
||||
/// are always permitted). A child may only ever hold a *subset* of its parent's
|
||||
/// capabilities.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PermissionSet {
|
||||
/// May modify the workspace (`write_file` / `edit_file` / `apply_patch`).
|
||||
pub write: bool,
|
||||
/// May use network-capable tools (web search/fetch, networked MCP servers).
|
||||
pub network: bool,
|
||||
}
|
||||
|
||||
impl PermissionSet {
|
||||
/// Full capabilities (write + network).
|
||||
pub const fn full() -> Self {
|
||||
Self {
|
||||
write: true,
|
||||
network: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only: no write, no network.
|
||||
pub const fn read_only() -> Self {
|
||||
Self {
|
||||
write: false,
|
||||
network: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only inspection: read-only on the workspace, but network-capable.
|
||||
///
|
||||
/// The read-only investigator posture (scout/reviewer): it must not
|
||||
/// mutate the workspace, but real read-only inspection needs
|
||||
/// `git`/`gh`/web reach — the old `read_only()` default left such lanes
|
||||
/// with no way to run any command or reach any remote, which made default
|
||||
/// scout lanes useless for the inspection they exist for.
|
||||
pub const fn read_only_with_network() -> Self {
|
||||
Self {
|
||||
write: false,
|
||||
network: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersection: a capability is granted only if **both** sets grant it.
|
||||
/// This is the core non-escalation primitive — `parent.intersect(child)`
|
||||
/// can never produce a capability the parent lacks.
|
||||
#[must_use]
|
||||
pub fn intersect(self, other: Self) -> Self {
|
||||
Self {
|
||||
write: self.write && other.write,
|
||||
network: self.network && other.network,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell access policy — the replacement for the legacy per-worker shell boolean
|
||||
/// (#3217). Ordered from most to least restrictive so `min` yields the safer of
|
||||
/// two policies.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShellPolicy {
|
||||
/// No shell access.
|
||||
None,
|
||||
/// Read-only / non-mutating commands only (the policy enforcement lives in
|
||||
/// the exec/sandbox layer; this is the declared intent).
|
||||
ReadOnly,
|
||||
/// Full shell access.
|
||||
Full,
|
||||
}
|
||||
|
||||
impl ShellPolicy {
|
||||
/// Convert the legacy top-level shell opt-in into the typed shell policy.
|
||||
#[must_use]
|
||||
pub const fn from_legacy_allow_shell(allow_shell: bool) -> Self {
|
||||
if allow_shell { Self::Full } else { Self::None }
|
||||
}
|
||||
|
||||
/// Whether any shell tools should be exposed under this policy.
|
||||
#[must_use]
|
||||
pub const fn allows_shell(self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
|
||||
/// The more restrictive (safer) of two policies. A child can never exceed
|
||||
/// its parent's shell policy.
|
||||
#[must_use]
|
||||
pub fn min_with(self, other: Self) -> Self {
|
||||
if self <= other { self } else { other }
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tools a worker may call. Mirrors the existing `AgentWorkerToolProfile`
|
||||
/// (`Inherited` / `Explicit`) so the two can be reconciled when this is wired in.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolScope {
|
||||
/// Inherit the parent's tool surface.
|
||||
Inherit,
|
||||
/// Only the explicitly listed tool names.
|
||||
Explicit(Vec<String>),
|
||||
}
|
||||
|
||||
/// File-system authority axis of a [`ChildGrant`]. Ordered least to most
|
||||
/// permissive so `Ord::min` is the non-escalation primitive.
|
||||
///
|
||||
/// `None` is not "no writes" — it is "no file access of any kind", which is
|
||||
/// what a child with an empty tool surface experiences. `Read` permits
|
||||
/// evidence collection only; `Write` permits mutation inside the session's
|
||||
/// write policy (workspace boundary, exact-file claims, approval posture).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum FileGrant {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Process authority axis of a [`ChildGrant`]. Ordered least to most
|
||||
/// permissive so `Ord::min` is the non-escalation primitive.
|
||||
///
|
||||
/// `Inspect` is the read-only shell: canonical `bash` only, and only calls
|
||||
/// the agent read-only classifier proves mutation-free (the explore /
|
||||
/// reviewer / planner posture). `Verify` is the bounded built-in verification
|
||||
/// surface — default workspace checks, pure test selection, and bounded Git
|
||||
/// fetch/merge-tree — with no shell grammar (the test/verifier posture). A
|
||||
/// child asking for a surface its parent does not hold degrades to the
|
||||
/// narrower side (`Inspect ∩ Verify = Inspect`): bounded inspection is a
|
||||
/// subset of bounded process authority, never a widening.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ShellGrant {
|
||||
None,
|
||||
Inspect,
|
||||
Verify,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// The named tool surface a grant exposes, before the explicit scope narrows
|
||||
/// it. This is the role-preset axis of `tools` — distinct from
|
||||
/// [`ChildGrant::scope`], which is the caller's own allowlist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolSurface {
|
||||
/// Every registered tool the grant's other axes admit.
|
||||
Inherited,
|
||||
/// The read-only evidence surface — `readonly_evidence_tool` plus
|
||||
/// `agent` (delegation). The explore / reviewer preset.
|
||||
Evidence,
|
||||
}
|
||||
|
||||
/// The single authority object a delegated child runs under (#5633).
|
||||
///
|
||||
/// Roles are *presets over this object*, never a second permission system:
|
||||
/// [`ChildGrant::for_role`] gives the widest grant a role can hold, and
|
||||
/// [`ChildGrant::resolve`] intersects it with the effective parent-derived
|
||||
/// profile and the caller's explicit scope. One projection serves every
|
||||
/// consumer — the child's tool catalog, its dispatch refusals, and its
|
||||
/// capability envelope all read the same fields, so a tool that is visible
|
||||
/// is callable and a tool that is denied never appears.
|
||||
///
|
||||
/// This is deliberately a *projection*, not a second persisted record:
|
||||
/// [`WorkerRuntimeProfile`] remains the wire/persistence shape and
|
||||
/// `ChildAuthority` /
|
||||
/// `ToolAuthorityEnvelope`
|
||||
/// remain the transports that carry the same authority across the durable
|
||||
/// Fleet and `codewhale exec` subprocess boundaries. What this object retires
|
||||
/// is the *semantic* duplication — posture sentinel entries read back off a
|
||||
/// deny list, and role-keyed re-derivation inside the registry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChildGrant {
|
||||
/// File-system authority.
|
||||
pub files: FileGrant,
|
||||
/// Process authority.
|
||||
pub shell: ShellGrant,
|
||||
/// May reach the network through any surface.
|
||||
pub network: bool,
|
||||
/// May drive the operator's machine (computer-use / desktop tools).
|
||||
/// Children never hold this today; the field is declared so every
|
||||
/// surface reports the same denial rather than implying a grant.
|
||||
pub desktop: bool,
|
||||
/// The named tool surface before explicit scope narrowing.
|
||||
pub surface: ToolSurface,
|
||||
/// The caller's explicit allowlist, already intersected with the parent's
|
||||
/// scope at spawn. `Some(vec![])` — the `tools = false` posture — permits
|
||||
/// nothing at all.
|
||||
pub scope: Option<Vec<String>>,
|
||||
/// May delegate children of its own (remaining spawn depth).
|
||||
pub spawn: bool,
|
||||
}
|
||||
|
||||
impl ChildGrant {
|
||||
/// The widest grant a role can ever hold — the role preset.
|
||||
///
|
||||
/// This is the role-to-authority table, expressed once: everything else
|
||||
/// only narrows it. Read-only roles stay read-only on the workspace by
|
||||
/// intent; network reach is a read, and a worker cut off from it for no
|
||||
/// role reason cannot do its job. `desktop` is never in any preset.
|
||||
#[must_use]
|
||||
pub fn for_role(role: &FleetRole) -> Self {
|
||||
let (files, shell, surface) = match role {
|
||||
// Read-only investigators: evidence collection only, with the
|
||||
// classifier-bounded inspection shell.
|
||||
FleetRole::Scout | FleetRole::Reviewer => {
|
||||
(FileGrant::Read, ShellGrant::Inspect, ToolSurface::Evidence)
|
||||
}
|
||||
// Planner: analysis only, same bounded shell, broader read
|
||||
// surface — a plan may consult any read the session offers.
|
||||
FleetRole::Planner => (FileGrant::Read, ShellGrant::Inspect, ToolSurface::Inherited),
|
||||
// Counsel only: reads to ground advice, never acts — no process
|
||||
// surface at all (#4752).
|
||||
FleetRole::Consultant => (FileGrant::Read, ShellGrant::None, ToolSurface::Inherited),
|
||||
// Verifier: bounded workspace checks and bounded Git reference
|
||||
// reads, no shell grammar.
|
||||
FleetRole::Verifier => (FileGrant::Read, ShellGrant::Verify, ToolSurface::Inherited),
|
||||
// Doers, and Custom: the preset asks for everything; the parent
|
||||
// profile and explicit scope do all the narrowing.
|
||||
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => {
|
||||
(FileGrant::Write, ShellGrant::Full, ToolSurface::Inherited)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
files,
|
||||
shell,
|
||||
network: true,
|
||||
desktop: false,
|
||||
surface,
|
||||
scope: None,
|
||||
spawn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the grant a child actually runs under: the role preset
|
||||
/// intersected with the effective parent-derived profile, carrying the
|
||||
/// caller's explicit scope and the remaining delegation depth.
|
||||
///
|
||||
/// Every field takes the more restrictive side, so the result can never
|
||||
/// name authority the parent lacked. This is the in-process counterpart
|
||||
/// of [`crate::role::ChildAuthority::clamp`].
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
role: &FleetRole,
|
||||
profile: &WorkerRuntimeProfile,
|
||||
scope: Option<Vec<String>>,
|
||||
can_spawn: bool,
|
||||
) -> Self {
|
||||
let preset = Self::for_role(role);
|
||||
let profile_shell = match profile.shell {
|
||||
ShellPolicy::None => ShellGrant::None,
|
||||
ShellPolicy::ReadOnly => ShellGrant::Inspect,
|
||||
ShellPolicy::Full => ShellGrant::Full,
|
||||
};
|
||||
// The posture deny lists are the transport the ceiling clamps install;
|
||||
// the grant folds their sentinels into the semantic axes so the two
|
||||
// can never disagree. `fetch_url` stands for "no network"; `run_tests`
|
||||
// stands for "no shell authority at all" — it removes process-start
|
||||
// grants (`Verify`/`Full` → `None`) while `Inspect` survives, because
|
||||
// classifier-bounded evidence reads were never shell authority.
|
||||
// `exec_shell` (raw shell gone) is deliberately *not* folded: a
|
||||
// write-denied verifier installs it while keeping shell authority.
|
||||
let denied = |name: &str| {
|
||||
codewhale_execpolicy::tool_rules::tool_denied(
|
||||
Some(profile.denied_tools.as_slice()),
|
||||
name,
|
||||
)
|
||||
};
|
||||
let resolved_shell = preset.shell.min(profile_shell);
|
||||
let shell = if denied(crate::role::SHELL_AUTHORITY_SENTINEL) {
|
||||
match resolved_shell {
|
||||
ShellGrant::Inspect => ShellGrant::Inspect,
|
||||
_ => ShellGrant::None,
|
||||
}
|
||||
} else {
|
||||
resolved_shell
|
||||
};
|
||||
let no_tools = matches!(scope.as_deref(), Some([]));
|
||||
Self {
|
||||
files: if no_tools {
|
||||
FileGrant::None
|
||||
} else {
|
||||
preset.files.min(if profile.permissions.write {
|
||||
FileGrant::Write
|
||||
} else {
|
||||
FileGrant::Read
|
||||
})
|
||||
},
|
||||
shell,
|
||||
network: preset.network
|
||||
&& profile.permissions.network
|
||||
&& !denied(crate::role::NETWORK_DENIAL_SENTINEL),
|
||||
desktop: false,
|
||||
surface: preset.surface,
|
||||
scope,
|
||||
spawn: can_spawn,
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport shell policy the registered surface and `BashTool`
|
||||
/// enforce for this grant.
|
||||
///
|
||||
/// `Verify` maps to `ShellPolicy::Full`: the bounded verification surface
|
||||
/// is a process-start authority the profile transports as Full, while the
|
||||
/// grant itself — not the transport — decides that raw shell names never
|
||||
/// reach the catalog. Keeping the transport at Full also preserves the
|
||||
/// ceiling a verifier may pass to its own children.
|
||||
#[must_use]
|
||||
pub const fn shell_policy(&self) -> ShellPolicy {
|
||||
match self.shell {
|
||||
ShellGrant::None => ShellPolicy::None,
|
||||
ShellGrant::Inspect => ShellPolicy::ReadOnly,
|
||||
ShellGrant::Verify | ShellGrant::Full => ShellPolicy::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a worker's model is selected. New model-facing spawns default to the
|
||||
/// parent/session model; a child only takes a smaller/faster family sibling when
|
||||
/// the parent explicitly asks for that route.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModelRoute {
|
||||
/// Same model as the parent / session.
|
||||
Inherit,
|
||||
/// Explicitly request a smaller/faster same-family sibling when known.
|
||||
Faster,
|
||||
/// Legacy persisted route from the old hidden auto-router. New spawns do
|
||||
/// not emit this; runtime treats it like `Faster` for compatibility.
|
||||
Auto,
|
||||
/// An explicit model id, validated against the active provider at spawn time.
|
||||
Fixed(String),
|
||||
}
|
||||
|
||||
/// The capability contract a single worker runs under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerRuntimeProfile {
|
||||
pub role: FleetRole,
|
||||
pub permissions: PermissionSet,
|
||||
pub shell: ShellPolicy,
|
||||
pub tools: ToolScope,
|
||||
pub model: ModelRoute,
|
||||
/// Explicit provider override; `None` inherits the parent/session provider.
|
||||
pub provider: Option<String>,
|
||||
/// Explicit reasoning/thinking tier; `None` inherits the parent/session tier.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// Tool deny-list inherited from the parent session's `--disallowed-tools`
|
||||
/// (#4042). Deny always wins over allow, even over the explicit allowlist
|
||||
/// and the role posture. Entries support wildcard matching: an exact name
|
||||
/// (`exec_shell`) or a `prefix*` glob (`mcp_*`), compared case-insensitively.
|
||||
///
|
||||
/// A child can only ever *add* entries — `derive_child()` takes the union of
|
||||
/// the parent's and the child's deny lists, so a descendant can never drop a
|
||||
/// restriction an ancestor imposed. The legacy `inherit_disallowed_tools:
|
||||
/// false` input remains accepted but cannot remove this ceiling.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub denied_tools: Vec<String>,
|
||||
/// Absolute recursion ceiling. Older profiles stored a remaining allowance;
|
||||
/// interpreting that smaller value as absolute fails closed on recovery.
|
||||
/// `spawn_depth` records this worker's position on the same axis.
|
||||
pub max_spawn_depth: u32,
|
||||
#[serde(default)]
|
||||
pub spawn_depth: u32,
|
||||
/// Whole-run wall time, including queued, model and tool work.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wall_time_secs: Option<u64>,
|
||||
/// Persisted wall-clock deadline; continuations cannot restart the clock.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wall_deadline_ms: Option<u64>,
|
||||
/// Optional model-turn cap. Zero means unbounded, matching the normal
|
||||
/// Codex and GrokBuild agent loop; an operator may still set a cap.
|
||||
#[serde(default = "default_general_max_steps")]
|
||||
pub max_steps: u32,
|
||||
/// Whether the worker runs detached (background) or inline (foreground).
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
impl WorkerRuntimeProfile {
|
||||
/// Default model turns for every role: unbounded unless explicitly capped.
|
||||
pub const READ_ONLY_MAX_STEPS: u32 = 0;
|
||||
pub const GENERAL_MAX_STEPS: u32 = 0;
|
||||
|
||||
/// Return the default model-turn cap for this role (zero = unbounded).
|
||||
#[must_use]
|
||||
pub const fn default_max_steps(role: FleetRole) -> u32 {
|
||||
match role {
|
||||
FleetRole::Scout
|
||||
| FleetRole::Reviewer
|
||||
| FleetRole::Planner
|
||||
| FleetRole::Verifier
|
||||
| FleetRole::Consultant => Self::READ_ONLY_MAX_STEPS,
|
||||
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => Self::GENERAL_MAX_STEPS,
|
||||
}
|
||||
}
|
||||
|
||||
/// The default profile for a role — the per-role posture. Mirrors the role
|
||||
/// stances documented in `docs/SUBAGENTS.md` (explore/plan/review are
|
||||
/// read-only; verifier runs tests; implementer/general write).
|
||||
#[must_use]
|
||||
pub fn for_role(role: FleetRole) -> Self {
|
||||
// A role's default is what the role *intends*, expressed as the widest
|
||||
// posture the role can be given; the parent's effective posture is the
|
||||
// ceiling (`derive_child` intersects, never widens). Read-only roles
|
||||
// stay read-only on the workspace by intent. Nothing else is taken
|
||||
// away by default: network reach is a read, and a worker cut off from
|
||||
// the network or from shell for no role reason cannot do its job.
|
||||
let (permissions, shell) = match role {
|
||||
// Read-only investigators: no workspace writes, but network reach
|
||||
// and the bounded verification surface so a scout/reviewer lane
|
||||
// can run git/gh/web inspection. Raw shell stays denied by the
|
||||
// registry clamp (read-only classifier), so this widens capability
|
||||
// without widening mutation authority.
|
||||
FleetRole::Scout | FleetRole::Reviewer => {
|
||||
(PermissionSet::read_only_with_network(), ShellPolicy::Full)
|
||||
}
|
||||
// Planner: analysis only. Reads the workspace and the web and may
|
||||
// run read-only shell probes (`git log`, `rg`) under the read-only
|
||||
// classifier; never mutates.
|
||||
FleetRole::Planner => (
|
||||
PermissionSet::read_only_with_network(),
|
||||
ShellPolicy::ReadOnly,
|
||||
),
|
||||
// Consultant: counsel only. Reads (workspace and web) to ground
|
||||
// its advice; never acts on the workspace, so no shell (#4752).
|
||||
FleetRole::Consultant => (PermissionSet::read_only_with_network(), ShellPolicy::None),
|
||||
// Verifier: doesn't modify code, but runs the bounded built-in
|
||||
// verification surface (test/check selections) under a full shell
|
||||
// ceiling clamped by ChildAuthority: writes are denied and
|
||||
// unbounded shell forms are refused (#5186). The old wording
|
||||
// promised "runs the test suite" without saying the surface is
|
||||
// bounded. See the roster description and VERIFIER_AGENT_INTRO.
|
||||
FleetRole::Verifier => (PermissionSet::read_only_with_network(), ShellPolicy::Full),
|
||||
// Doers, and Custom: inherit the parent's effective posture. A
|
||||
// custom worker is narrowed by its explicit tool list and by the
|
||||
// spawning call, not by a silent locked-down default.
|
||||
FleetRole::Builder | FleetRole::Worker | FleetRole::Custom => {
|
||||
(PermissionSet::full(), ShellPolicy::Full)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
role: role.clone(),
|
||||
permissions,
|
||||
shell,
|
||||
tools: ToolScope::Inherit,
|
||||
model: ModelRoute::Inherit,
|
||||
provider: None,
|
||||
// A Consultant is asked for judgement, so it defaults to the highest
|
||||
// reasoning tier rather than inheriting the session's (#4752).
|
||||
// Still only a default: an explicit spawn-time or profile value
|
||||
// wins via `derive_child`, same as every other role.
|
||||
reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()),
|
||||
denied_tools: Vec::new(),
|
||||
max_spawn_depth: codewhale_protocol::fleet::DEFAULT_SPAWN_DEPTH,
|
||||
spawn_depth: 0,
|
||||
wall_time_secs: None,
|
||||
wall_deadline_ms: None,
|
||||
max_steps: Self::default_max_steps(role.clone()),
|
||||
background: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a child profile from this (parent) profile and a `requested` child
|
||||
/// profile. The result is the **intersection** of the two — it can never
|
||||
/// grant the child something the parent lacks (#414 / #426 / #1186):
|
||||
///
|
||||
/// - permissions are AND-ed,
|
||||
/// - shell takes the more restrictive policy,
|
||||
/// - an explicit parent tool set bounds the child's tool set,
|
||||
/// - the spawn-depth budget decrements by one level and clamps to the ceiling,
|
||||
/// - the tool deny-list is the **union** of the two — a child may add
|
||||
/// restrictions but never drop one an ancestor imposed (#4042).
|
||||
///
|
||||
/// The child keeps its own requested role, model route, and
|
||||
/// foreground/background preference (these don't grant capability), but its
|
||||
/// provider falls back to the parent's when unset.
|
||||
#[must_use]
|
||||
pub fn derive_child(&self, requested: &WorkerRuntimeProfile) -> WorkerRuntimeProfile {
|
||||
let permissions = self.permissions.intersect(requested.permissions);
|
||||
let shell = self.shell.min_with(requested.shell);
|
||||
// Deny-lists union: a child can never drop a restriction an ancestor
|
||||
// imposed. Wildcard entries are merged verbatim (no expansion).
|
||||
let mut denied_tools = self.denied_tools.clone();
|
||||
for rule in &requested.denied_tools {
|
||||
if !denied_tools.contains(rule) {
|
||||
denied_tools.push(rule.clone());
|
||||
}
|
||||
}
|
||||
let tools = match (&self.tools, &requested.tools) {
|
||||
// Parent restricts to a set → the child can only narrow within it.
|
||||
(ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit(
|
||||
child
|
||||
.iter()
|
||||
.filter(|name| parent.contains(name))
|
||||
.cloned()
|
||||
.collect(),
|
||||
),
|
||||
(ToolScope::Explicit(parent), ToolScope::Inherit) => {
|
||||
ToolScope::Explicit(parent.clone())
|
||||
}
|
||||
// Parent inherits the full surface → the child's request stands.
|
||||
(ToolScope::Inherit, child) => child.clone(),
|
||||
};
|
||||
// Depth stays absolute; only the current position increments. Every
|
||||
// authority projection compares the position against this same ceiling.
|
||||
let max_spawn_depth = requested.max_spawn_depth.min(self.max_spawn_depth);
|
||||
WorkerRuntimeProfile {
|
||||
role: requested.role.clone(),
|
||||
permissions,
|
||||
shell,
|
||||
tools,
|
||||
model: requested.model.clone(),
|
||||
provider: requested.provider.clone().or_else(|| self.provider.clone()),
|
||||
reasoning_effort: requested
|
||||
.reasoning_effort
|
||||
.clone()
|
||||
.or_else(|| self.reasoning_effort.clone()),
|
||||
denied_tools,
|
||||
max_spawn_depth,
|
||||
spawn_depth: self.spawn_depth.saturating_add(1),
|
||||
wall_time_secs: narrow_optional_limit(self.wall_time_secs, requested.wall_time_secs),
|
||||
wall_deadline_ms: narrow_optional_limit(
|
||||
self.wall_deadline_ms,
|
||||
requested.wall_deadline_ms,
|
||||
),
|
||||
max_steps: narrow_model_steps(self.max_steps, requested.max_steps),
|
||||
background: requested.background,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remaining generations, projected from the one absolute ceiling.
|
||||
#[must_use]
|
||||
pub fn remaining_spawn_depth(&self) -> u32 {
|
||||
self.max_spawn_depth.saturating_sub(self.spawn_depth)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_spawn_child(&self) -> bool {
|
||||
self.spawn_depth < self.max_spawn_depth
|
||||
}
|
||||
}
|
||||
|
||||
/// Omission inherits; neither a child request nor a replay can widen a cap.
|
||||
pub fn narrow_optional_limit<T: Ord>(inherited: Option<T>, requested: Option<T>) -> Option<T> {
|
||||
match (inherited, requested) {
|
||||
(Some(inherited), Some(requested)) => Some(inherited.min(requested)),
|
||||
(inherited, requested) => inherited.or(requested),
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero is the operator/internal unbounded sentinel, never a widening request.
|
||||
pub fn narrow_model_steps(inherited: u32, requested: u32) -> u32 {
|
||||
narrow_optional_limit(
|
||||
(inherited > 0).then_some(inherited),
|
||||
(requested > 0).then_some(requested),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
const fn default_general_max_steps() -> u32 {
|
||||
WorkerRuntimeProfile::GENERAL_MAX_STEPS
|
||||
}
|
||||
|
||||
impl Default for WorkerRuntimeProfile {
|
||||
fn default() -> Self {
|
||||
Self::for_role(FleetRole::Worker)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationIdentity {
|
||||
pub owner_session_id: String,
|
||||
pub session_boot_id: String,
|
||||
pub public_child_id: String,
|
||||
pub child_id: String,
|
||||
pub run_id: String,
|
||||
pub generation: u32,
|
||||
pub initial_submission_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChildActivationDelivery {
|
||||
#[default]
|
||||
Parent,
|
||||
Caller,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FrozenChildConfiguration {
|
||||
pub persona: Option<String>,
|
||||
pub denied_tools: Vec<String>,
|
||||
pub delivery: ChildActivationDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildInitialAdmission {
|
||||
pub submission_id: String,
|
||||
pub turn_id: String,
|
||||
pub message_index: usize,
|
||||
pub prefix_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ChildActivationStopReason {
|
||||
Completed,
|
||||
Aborted,
|
||||
Error,
|
||||
MaxTokens,
|
||||
Refusal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationOutput {
|
||||
pub transcript_message_index: usize,
|
||||
pub content_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationManifest {
|
||||
pub identity: ChildActivationIdentity,
|
||||
pub configuration: Option<FrozenChildConfiguration>,
|
||||
pub admission: Option<ChildInitialAdmission>,
|
||||
#[serde(default)]
|
||||
pub output_captured: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<ChildActivationStopReason>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<ChildActivationOutput>,
|
||||
}
|
||||
|
||||
/// Unified pre-launch manifest for a child agent (#414).
|
||||
///
|
||||
/// Everything needed to provision, launch, and resume a child — prompt, role,
|
||||
/// model, tools, permissions, workspace boundary, budget, and identity — comes
|
||||
/// from this single persisted record. No field is derived ad-hoc at launch time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildLaunchManifest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activation: Option<ChildActivationManifest>,
|
||||
pub owner_session: String,
|
||||
pub child_id: String,
|
||||
pub profile: WorkerRuntimeProfile,
|
||||
pub prompt: String,
|
||||
pub cwd: Option<String>,
|
||||
pub worktree: bool,
|
||||
pub writable_roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub writable_files: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub coordination_contracts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_artifact: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deliverables: Vec<String>,
|
||||
pub resume_identity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generation: u32,
|
||||
/// Agent id this child was resumed from via `resume_from`, if any.
|
||||
/// Carries provenance across continuation chains so receipts can trace
|
||||
/// the lineage without inspecting the transcript.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resume_from_agent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permission_intersection_never_escalates() {
|
||||
let parent = PermissionSet::read_only();
|
||||
let greedy_child = PermissionSet::full();
|
||||
// Even though the child asks for everything, the read-only parent wins.
|
||||
let got = parent.intersect(greedy_child);
|
||||
assert_eq!(got, PermissionSet::read_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_policy_min_takes_the_safer() {
|
||||
assert_eq!(
|
||||
ShellPolicy::ReadOnly.min_with(ShellPolicy::Full),
|
||||
ShellPolicy::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
ShellPolicy::None.min_with(ShellPolicy::ReadOnly),
|
||||
ShellPolicy::None
|
||||
);
|
||||
assert_eq!(
|
||||
ShellPolicy::Full.min_with(ShellPolicy::Full),
|
||||
ShellPolicy::Full
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_role_postures_match_role_stances() {
|
||||
let explore = WorkerRuntimeProfile::for_role(FleetRole::Scout);
|
||||
assert!(!explore.permissions.write, "explore must not write");
|
||||
assert!(
|
||||
explore.permissions.network,
|
||||
"explore/read-only inspection lanes keep network reach"
|
||||
);
|
||||
assert_eq!(
|
||||
explore.shell,
|
||||
ShellPolicy::Full,
|
||||
"explore/read-only inspection lanes hold shell authority so the bounded verification surface survives the clamp (raw shell still requires write)"
|
||||
);
|
||||
assert_eq!(
|
||||
explore.model,
|
||||
ModelRoute::Inherit,
|
||||
"explore should not silently downgrade the child model"
|
||||
);
|
||||
|
||||
let implementer = WorkerRuntimeProfile::for_role(FleetRole::Builder);
|
||||
assert!(implementer.permissions.write, "implementer writes");
|
||||
assert_eq!(implementer.shell, ShellPolicy::Full);
|
||||
|
||||
let verifier = WorkerRuntimeProfile::for_role(FleetRole::Verifier);
|
||||
assert!(
|
||||
!verifier.permissions.write,
|
||||
"verifier reports, does not patch"
|
||||
);
|
||||
assert_eq!(
|
||||
verifier.shell,
|
||||
ShellPolicy::Full,
|
||||
"verifier holds shell authority for the bounded verification surface (unbounded forms are refused by the policy seam)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_step_budgets_are_unbounded_by_default_and_profile_owned() {
|
||||
for role in [
|
||||
FleetRole::Scout,
|
||||
FleetRole::Reviewer,
|
||||
FleetRole::Planner,
|
||||
FleetRole::Verifier,
|
||||
FleetRole::Builder,
|
||||
FleetRole::Worker,
|
||||
FleetRole::Custom,
|
||||
] {
|
||||
assert_eq!(WorkerRuntimeProfile::for_role(role.clone()).max_steps, 0);
|
||||
assert_eq!(
|
||||
WorkerRuntimeProfile::for_role(role.clone()).max_steps,
|
||||
WorkerRuntimeProfile::default_max_steps(role)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4752: Consultant is counsel, not labour. Its posture has to be read-only
|
||||
/// and shell-less by construction, not by the caller remembering to pass
|
||||
/// `write_authority: read-only`.
|
||||
#[test]
|
||||
fn consultant_is_read_only_shell_less_and_high_reasoning_by_default() {
|
||||
let consultant = WorkerRuntimeProfile::for_role(FleetRole::Consultant);
|
||||
|
||||
assert!(
|
||||
!consultant.permissions.write,
|
||||
"a consultant advises, it never writes"
|
||||
);
|
||||
assert_eq!(
|
||||
consultant.shell,
|
||||
ShellPolicy::None,
|
||||
"a consultant has no reason to run commands"
|
||||
);
|
||||
assert_eq!(
|
||||
consultant.reasoning_effort.as_deref(),
|
||||
Some("high"),
|
||||
"the point of asking a consultant is the reasoning tier"
|
||||
);
|
||||
assert_eq!(
|
||||
consultant.model,
|
||||
ModelRoute::Inherit,
|
||||
"tier is a reasoning-effort default, not a hardcoded model"
|
||||
);
|
||||
assert_eq!(
|
||||
consultant.max_steps,
|
||||
WorkerRuntimeProfile::READ_ONLY_MAX_STEPS,
|
||||
"consultants are unbounded by default like every other role"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reasoning default must not become a ceiling: an explicit request
|
||||
/// still wins, exactly as it does for every other role.
|
||||
#[test]
|
||||
fn an_explicit_reasoning_tier_overrides_the_consultant_default() {
|
||||
let parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Consultant);
|
||||
requested.reasoning_effort = Some("max".to_string());
|
||||
|
||||
let child = parent.derive_child(&requested);
|
||||
|
||||
assert_eq!(child.reasoning_effort.as_deref(), Some("max"));
|
||||
assert!(!child.permissions.write, "still read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_cannot_escalate_beyond_a_readonly_parent() {
|
||||
// Scout now carries the read-only inspection posture: no writes, but network reach
|
||||
// and full shell authority (bounded verification surface; raw shell
|
||||
// still requires write at the clamp).
|
||||
let parent = WorkerRuntimeProfile::for_role(FleetRole::Scout); // read-only inspection
|
||||
let greedy = WorkerRuntimeProfile::for_role(FleetRole::Builder); // wants write + full shell
|
||||
let child = parent.derive_child(&greedy);
|
||||
assert!(
|
||||
!child.permissions.write,
|
||||
"a read-only parent cannot bear a writing child"
|
||||
);
|
||||
assert!(
|
||||
child.permissions.network,
|
||||
"child inherits the read-only inspection parent's network reach"
|
||||
);
|
||||
assert_eq!(
|
||||
child.shell,
|
||||
ShellPolicy::Full,
|
||||
"child shell clamped to parent's read-only inspection posture"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_explicit_tools_are_bounded_by_parent() {
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.tools = ToolScope::Explicit(vec!["read_file".into(), "grep_files".into()]);
|
||||
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
requested.tools = ToolScope::Explicit(vec!["read_file".into(), "write_file".into()]);
|
||||
let child = parent.derive_child(&requested);
|
||||
match child.tools {
|
||||
ToolScope::Explicit(names) => {
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["read_file".to_string()],
|
||||
"write_file not in parent set is dropped"
|
||||
);
|
||||
}
|
||||
ToolScope::Inherit => panic!("expected explicit tool scope"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_spawn_depth_is_preserved_and_position_increments() {
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.max_spawn_depth = 2;
|
||||
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
requested.max_spawn_depth = 99; // tries to grab more than the parent has
|
||||
let child = parent.derive_child(&requested);
|
||||
assert_eq!(
|
||||
child.max_spawn_depth, 2,
|
||||
"the absolute ceiling is unchanged, never the requested 99"
|
||||
);
|
||||
assert_eq!(child.spawn_depth, 1);
|
||||
assert!(child.can_spawn_child());
|
||||
|
||||
let mut leaf_parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
leaf_parent.max_spawn_depth = 1;
|
||||
let grandchild = leaf_parent.derive_child(&requested);
|
||||
assert_eq!(grandchild.max_spawn_depth, 1);
|
||||
assert_eq!(grandchild.spawn_depth, 1);
|
||||
assert!(
|
||||
!grandchild.can_spawn_child(),
|
||||
"budget exhausted at the leaf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_provider_falls_back_to_parent() {
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.provider = Some("moonshot".to_string());
|
||||
let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout); // provider None
|
||||
let child = parent.derive_child(&requested);
|
||||
assert_eq!(child.provider.as_deref(), Some("moonshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_reasoning_effort_uses_requested_then_parent() {
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.reasoning_effort = Some("low".to_string());
|
||||
|
||||
let requested = WorkerRuntimeProfile::for_role(FleetRole::Scout);
|
||||
let inherited = parent.derive_child(&requested);
|
||||
assert_eq!(inherited.reasoning_effort.as_deref(), Some("low"));
|
||||
|
||||
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Scout);
|
||||
requested.reasoning_effort = Some("max".to_string());
|
||||
let overridden = parent.derive_child(&requested);
|
||||
assert_eq!(overridden.reasoning_effort.as_deref(), Some("max"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_denied_tools_union_never_drops_parent_restriction() {
|
||||
// A child may only *add* deny entries; it can never drop a restriction
|
||||
// an ancestor imposed (#4042 non-escalation invariant).
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.denied_tools = vec!["exec_shell".into(), "mcp_*".into()];
|
||||
|
||||
// Child asks for its own deny list and (tryingly) tries to omit the
|
||||
// parent's exec_shell — the union keeps both.
|
||||
let mut requested = WorkerRuntimeProfile::for_role(FleetRole::Builder);
|
||||
requested.denied_tools = vec!["write_file".into()];
|
||||
|
||||
let child = parent.derive_child(&requested);
|
||||
assert!(child.denied_tools.contains(&"exec_shell".to_string()));
|
||||
assert!(child.denied_tools.contains(&"mcp_*".to_string()));
|
||||
assert!(child.denied_tools.contains(&"write_file".to_string()));
|
||||
}
|
||||
|
||||
/// Every built-in role keeps network reads by default: a worker cut off
|
||||
/// from the network for no role reason cannot do its job. Only workspace
|
||||
/// mutation is a role intent.
|
||||
#[test]
|
||||
fn every_role_default_keeps_network_reads_and_only_read_only_roles_withhold_writes() {
|
||||
for role in [
|
||||
FleetRole::Scout,
|
||||
FleetRole::Reviewer,
|
||||
FleetRole::Planner,
|
||||
FleetRole::Verifier,
|
||||
FleetRole::Consultant,
|
||||
FleetRole::Builder,
|
||||
FleetRole::Worker,
|
||||
FleetRole::Custom,
|
||||
] {
|
||||
let profile = WorkerRuntimeProfile::for_role(role.clone());
|
||||
assert!(
|
||||
profile.permissions.network,
|
||||
"{role:?} must keep network reads"
|
||||
);
|
||||
let read_only_by_intent = matches!(
|
||||
role,
|
||||
FleetRole::Scout
|
||||
| FleetRole::Reviewer
|
||||
| FleetRole::Planner
|
||||
| FleetRole::Verifier
|
||||
| FleetRole::Consultant
|
||||
);
|
||||
assert_eq!(
|
||||
profile.permissions.write, !read_only_by_intent,
|
||||
"{role:?} write default"
|
||||
);
|
||||
}
|
||||
// Custom inherits (full ceiling); Planner probes read-only shell;
|
||||
// Consultant never acts on the workspace.
|
||||
assert_eq!(
|
||||
WorkerRuntimeProfile::for_role(FleetRole::Custom).shell,
|
||||
ShellPolicy::Full
|
||||
);
|
||||
assert_eq!(
|
||||
WorkerRuntimeProfile::for_role(FleetRole::Planner).shell,
|
||||
ShellPolicy::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
WorkerRuntimeProfile::for_role(FleetRole::Consultant).shell,
|
||||
ShellPolicy::None
|
||||
);
|
||||
}
|
||||
|
||||
/// The parent's effective posture is the ceiling: a full-default child role
|
||||
/// under a read-only, no-network, read-only-shell parent inherits exactly
|
||||
/// that, never more.
|
||||
#[test]
|
||||
fn derive_child_inherits_the_parent_ceiling_and_never_widens() {
|
||||
let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
|
||||
parent.permissions = PermissionSet::read_only();
|
||||
parent.shell = ShellPolicy::ReadOnly;
|
||||
for role in [FleetRole::Custom, FleetRole::Builder, FleetRole::Worker] {
|
||||
let child = parent.derive_child(&WorkerRuntimeProfile::for_role(role.clone()));
|
||||
assert!(!child.permissions.write, "{role:?} widened write");
|
||||
assert!(!child.permissions.network, "{role:?} widened network");
|
||||
assert_eq!(child.shell, ShellPolicy::ReadOnly, "{role:?} widened shell");
|
||||
}
|
||||
// And a full parent hands a doer its full posture.
|
||||
let full = WorkerRuntimeProfile::for_role(FleetRole::Worker)
|
||||
.derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Custom));
|
||||
assert!(full.permissions.write && full.permissions.network);
|
||||
assert_eq!(full.shell, ShellPolicy::Full);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "codewhale-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for Codewhale"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
serde.workspace = true
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "codewhale-app-server-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT AND Apache-2.0"
|
||||
repository.workspace = true
|
||||
description = "Client wire contracts and schema exports for Codewhale App Server"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
schema = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
jsonschema.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "codewhale-app-server-schema"
|
||||
path = "src/bin/export.rs"
|
||||
required-features = ["schema"]
|
||||