Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 ba1d13c1d4 fix: make first-run download and trust choices clearer
Retry installer downloads up to three times, remove failed partials,
and use explicit digit choices for directory trust. Keep configured
provider errors while clarifying the fresh custom-route placeholder.
Align the Chinese trust keybindings and mirrored release notes.

Original author: CodeWhale Bot <[email protected]>
Carried only these three original commits:
  cb1505a37773270451cacd66de438221d7a08e1e
  20bfae466dbd131373f0d0e2a08f9e090779c654
  2c1e49c99901ff7a9a2f791e8e3f1ecd3a371c3e

The old gen-strings --check current claim is withdrawn: prior reviews
found the app check red on both base and candidate. No app-string
qualification is claimed here. Hard-link delivery and the original
saved-working-endpoint report remain unresolved separate acceptance.

Integrated-source checks: installer safety 76/76; website contract 33/33;
prebuild, fmt and diff checks pass. preflight passes. Rust gates pending.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:52:26 -08:00
CodeWhale Bot bf43019b24 fix(api): keep repeated hook labels distinct in plugin index
Use the existing per-event ordinal for hook row identity. Hook names are optional labels and validation deliberately retains repeated names. The route regression covers repeated explicit labels and a label matching the unnamed fallback.

Validation: plugin-index route tests: 2 passed, 0 failed, 844 filtered out; cargo fmt --all -- --check and git diff --check passed; scripts/preflight.sh --check passed. macOS linker reported the existing __eh_frame size warning. Refs CW-7875.
2026-11-12 22:31:45 -08:00
CodeWhale BotandClaude Sonnet 5.5 de339044aa fix(config): show the telemetry setting default as on, matching the runtime
The settings schema declared telemetry default false while the resolver
(telemetry_consent_from_env) treats an unset value as on, so the settings
API reported default false for a default-on policy.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line the review wrote
(CW-7987 REVIEW.md finding 2; the packet's line was not used). This is the
committed part only: CW-7987 stays open for the active-version readback and
the ingest bundle rebuilt from the frozen candidate. Review: farm runs
20261112T110021Z-gtelrev-config-37102e (codewhale-config, 21 run, 21 passed,
823 skipped) and 20261112T110024Z-gtelrev-telem-751b20 (codewhale-telemetry,
53 run, 53 passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 22:31:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 203b54986d feat(router): preserve explicit per-question decision abstention
An explicit abstained:true on a System One answer now survives decoding and the
routing receipt, and can never authorize Auto (tier), the thinking effort, a
shadow noul read or a shadow chat intent, even at a zero confidence floor. A
non-boolean abstained value fails answer decoding and is refused like any other
malformed answer. Provider usage evidence and the declared fallback are
unchanged. The custom decision endpoint half of the original change is not
included.

Receipt field abstained_questions touches crates/models, cost_status and the
tui activity_detail fixture outside the slice owner list; it is additive with a
serde default.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line from the review (CW-7992 REVIEW.md finding 1). Review: farm run 20261112T112213Z-leg01review-tip-faf9ea, 42 run, 42 passed, 7181 skipped; counterfactual run 20261112T112313Z-leg01review-cf-032c60 fails 2 of 3 with the guards removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 22:31:42 -08:00
CodeWhale BotandClaude Sonnet 5.5 0b643a7340 test(engine): expire the dropped-dispatch deadline only after the client call is entered (CW-8127)
The dropped-dispatch test gave the nested call a real 100 ms deadline. On a
slow runner that deadline could expire before bind_dispatch settled the
unknown-usage record, so the biased wall-clock select won with zero records
(left: 0, right: 1). Give the call a 600 s deadline, wait until the fixture
client is entered (the record is settled before the client is called), then
pause tokio time and advance 601 s so the deadline always fires after dispatch.
The wait selects on entered against the dispatch with a panic if the dispatch
ends first, inside a 10 s real-time timeout, so a regression that stops the
dispatch before the client fails at once instead of hanging. Every assertion is
unchanged. Product code is unchanged.

nextest stress 40/40 passed, 6 tests per iteration, target test 40/40 (farm run
20261112T113538Z-cw8127r1-new-33edc8); the old test under the same stress failed
1 of 20 at left: 0 (farm run 20261112T104528Z-cw8127-old-7569e0).
clippy -p codewhale-engine --all-targets --all-features -D warnings clean (farm
run 20261112T113538Z-cw8127r1-clippy-373b1e); cargo fmt --check and
scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:31:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 e14b4393f8 fix(app-server): plugin index names plugins by id, labels tools, signals cut name lists (CW-8049 PL-02 repair 1)
/v1/commands rows carry plugin_id (not the name) and workspace_sourced; the
index projects an active plugin's commands from those rows, labels native and
language-server plugins as tool, sources project commands as project, and
reports a cut or control-character name list under problems. Doc comment
removed.

Checks (Boat farm, Linux x86_64, at c3bc310042): nextest -p codewhale-app-server
-p codewhale-tui -E 'test(/plugin_index|command_catalog|get_v1_commands|user_registry|commands::discovery/)':
51 run, 51 passed, 6713 skipped (run 20261112T080110Z-plidx4-test-26f07e);
clippy -D warnings for engine, app-server and tui: clean
(run 20261112T080110Z-plidx4-clippy-124ba8). fmt, runtime-routes and facts
were the worker's local checks; preflight was not run on that tip.

Landing (push 18): rebased onto push 17; agent-docs.generated.json conflicted
with push 17's regeneration and was regenerated with derive-facts at each of
the two picks. docs/RUNTIME_ROUTES.md: check-runtime-routes PASS, no change.
CHANGELOG line from the round-2 review wording.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:31:40 -08:00
CodeWhale BotandClaude Sonnet 5.5 c7cf758da4 feat(app-server): one read route lists every plugin kind (CW-8049 PL-02)
GET /v1/apps/plugin-index projects the plugin registry, the built-in
switch catalog, skills, connectors, hooks and hand-written commands into
one list with kind, source and state. Each item embeds the row its owner's
route returns by calling that owner's handler, so a field an owner adds
appears here without a second edit; an owner that fails is named under
unavailable and the rest still list. A plugin item carries the names of the
skills, connectors, hooks, commands and profiles it adds, and those are
not listed again as items. Replaces nothing; the owners keep their routes.

A user command row now says which plugin added it (UserCommand.plugin,
CommandCatalogEntry.plugin, filled from the registry's plugin authority),
so a plugin's markdown command is not shown as hand-written. The wiring
in runtime_api.rs (mod line, route line, that field) and the
command_catalog and tui hunks are separable from plugin_index.rs.

Checks: farm nextest 6 run, 6 passed; farm clippy -D warnings clean for
engine, app-server, tui; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:31:38 -08:00
CodeWhale BotandClaude Fable 5.2 2f31fe1900 refactor(catalog): one model listing for both commands, and the inventory stops judging windows by name
With the readings proved equal on one captured catalog, the second copies
go:

- `route_preferences::list_models` no longer assembles its own roster
  (catalog rows, saved default, sort, dedup). It calls
  `provider_lake::listed_models_for_route`, the same function `models`
  prints.
- The routing inventory no longer asks the bundled catalog whether a model
  name has a context window to decide if a number is verified. The
  resolved route's own source decides; a route that does not resolve is
  unverified.

Farm (Linux x86_64), this commit: catalog_parity 1 test run: 1 passed;
config and engine lib tests filtered
`catalog model pricing cost_status route`: 1316 passed, 0 failed; clippy
-D warnings for config and engine (all targets, all features) and for cli
and app-server (all targets): clean.

Landing (push 18): CHANGELOG line for the pair (REF-05, CW-7971) from the
round-2 review, added in this commit.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 22:31:37 -08:00
CodeWhale BotandClaude Fable 5.2 cfc6182f03 fix(catalog): both model commands, the Runtime API, routing and cost read one captured catalog the same way
The new check loads one captured Models.dev catalog (the bundled seed,
sha256 7c48830c…d256) and an id-only Anthropic roster into a sealed home,
then reads three routes through `model list` / `model resolve`, `models`,
`GET /v1/providers/{id}/models`, the routing inventory and the cost
envelope: the official Anthropic route, a custom endpoint serving a
same-named model, and the Z.ai plan endpoint. It prints every reading and
fails on any disagreement.

On the previous source it found three:

- `model list` skipped a custom endpoint unless its table was literally
  named `custom`. It now lists every configured custom route under the
  route's own name.
- `model resolve` refused a custom endpoint's saved model ("no registered
  model") because the legacy registry was asked about an endpoint that owns
  its own roster. The registry is no longer consulted for those routes; the
  route resolver and a fetched roster still decide.
- The routing inventory gave a custom endpoint the context window and
  output limit of a same-named model and marked the window verified. It
  now takes the window, its source and the output limit from the resolved
  route, so a borrowed number is marked unverified and an unpublished
  output limit stays unknown.

`provider_lake::listed_models_for_route` is the list body of `models`
moved out unchanged so the check can read it.

One spelling difference is left and reported, not asserted: the inventory
normalises a listed `glm-5.1` to `GLM-5.1`.

One disagreement is also named, not hidden: a custom endpoint has no
published window, so routing still reports 200,000 for a saved model whose
name matches a known one (the router's long-context tag reads it), marked
unverified, while the Runtime API reports unknown. The check records the
Runtime reading as unknown and allows exactly that pair as a known
difference; any other window disagreement fails.

The check also pins the charge side: a custom endpoint gets no charge
(`UnknownBillingBasis`); the Z.ai plan route gets no charge
(`NotMoneyMetered`) even for a model that has a list rate, and both
surfaces show that same list rate; an id-only first-party model with no
price row reads unknown on both surfaces and produces no charge.

Farm (Linux x86_64): config, engine and app-server lib tests filtered
`catalog_parity catalog model pricing cost_status route`: 1444 passed,
0 failed; clippy -D warnings for config, engine, cli and app-server (all
targets, all features): clean.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:31:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 85e9cff918 fix(acp): a plugin tool's nested approval reaches the editor as a permission request that names the nested tool (CW-8050)
When a plugin tool asks the core to run a tool that needs approval, the ACP
projection resolves the nested execution id to the admitted plugin call and
shows the request on it. The request is built from the approval.required
payload (nested tool name, input, summary) so title, kind and rawInput show
what is being approved, and it carries no status so the running plugin call is
not reset to pending. The gates test and journey test live in acp_journey and
assert the second request names write and carries nested.txt.

Docs (RUNTIME_API, EXTENSIONS, zh_hans) and web/data/agent-docs.generated.json
updated.

Checks: nextest -p codewhale-app-server -E 'test(acp)' on the build farm,
48 run, 48 passed, 815 skipped; cargo fmt --check clean; preflight --check OK.

Landing (push 18): rebased onto push 17. The zh_hans EXTENSIONS and
RUNTIME_API paragraphs conflicted with push 17's CW-8071 wording of the same
ACP paragraph; kept push 17's text and added this change's sentences where
the English copies have them. agent-docs.generated.json regenerated with
derive-facts. CHANGELOG line from the round-2 review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 22:31:34 -08:00
49 changed files with 291 additions and 399 deletions

No files matched your search

+17 -47
View File
@@ -810,20 +810,6 @@ assert.doesNotMatch(
// 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 timeoutMinutes(value) {
const literal = /^\d+$/.test(value);
const conditional = value.match(
/^\$\{\{\s*startsWith\(matrix\.os,\s*'macos-'\)\s*&&\s*(\d+)\s*\|\|\s*(\d+)\s*\}\}$/,
);
if (!literal && !conditional) return null;
const macos = Number(literal ? value : conditional[1]);
const other = Number(literal ? value : conditional[2]);
if (![macos, other].every((cap) => Number.isInteger(cap) && cap > 0 && cap <= 360)) {
return null;
}
return { macos, other };
}
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
@@ -837,8 +823,7 @@ function jobsWithoutTimeout(source) {
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;
const timeout = lines[j].match(/^ timeout-minutes:\s*(.+?)\s*$/);
if (timeout && timeoutMinutes(timeout[1])) capped = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
@@ -877,41 +862,26 @@ for (const [name, source] of [
);
}
function jobTimeout(source, job, platform = "other") {
// 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: (.+)$`, "m"),
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
const caps = timeoutMinutes(match[1].trim());
assert.ok(caps, `${job} must declare a bounded timeout`);
return caps[platform];
return Number(match[1]);
}
const macosTimeout = "${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}";
assert.deepEqual(timeoutMinutes(macosTimeout), { macos: 360, other: 210 });
for (const invalid of [
"0", "361", "Infinity", "${{ matrix.timeout }}",
"${{ startsWith(matrix.os, 'macos-') && 361 || 210 }}",
"${{ startsWith(matrix.os, 'macos-') && 360 || 0 }}",
]) {
assert.equal(timeoutMinutes(invalid), null, `${invalid} must not count as a bounded cap`);
}
assert.deepEqual(
jobsWithoutTimeout(`jobs:\n capped:\n timeout-minutes: ${macosTimeout}\n`),
[],
);
assert.equal(jobTimeout(artifacts, "build", "macos"), 360);
assert.equal(jobTimeout(artifacts, "build"), 210);
const artifactCacheSetup = artifacts.match(
/ - uses: mozilla-actions\/sccache-action@[^\n]+\n(?:(?! - ).*\n)*/,
);
assert.ok(artifactCacheSetup, "artifact compiler cache setup must be explicit");
assert.match(artifactCacheSetup[0], /^ if: runner\.os == 'Linux'$/m);
assert.match(
namedStep(artifacts, "Enable sccache"),
/^ if: steps\.sccache\.outcome == 'success' && runner\.os == 'Linux'$/m,
);
// 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);
@@ -924,7 +894,7 @@ for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
}
assert.equal(
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
"nightly and non-macOS release builds must share the cold-build allowance",
"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.
+2 -1
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
@@ -21,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
+1 -1
View File
@@ -544,7 +544,7 @@ pub enum ResolvedProviderReadiness {
impl ResolvedProviderReadiness {
pub fn label(&self) -> Cow<'static, str> {
match self {
Self::MissingKey => Cow::Borrowed("missing key"),
Self::MissingKey => Cow::Borrowed("needs key"),
Self::MissingLogin => Cow::Borrowed("missing login"),
Self::ExternalConsentPendingSelection => {
Cow::Borrowed("external consent · select to check")
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Espai de treball ",
"OnboardTrustRiskHint": "Els fitxers no confiables poden intentar redirigir l'agent mitjançant injecció de prompts.",
"OnboardTrustEffectHint": "La confiança es registra per a aquesta instal·lació de Codewhale i habilita les eines i els hooks de l'espai de treball.",
"OnboardTrustEnterHint": "Prem 1 o Y per confiar-hi, 2 o U per continuar sense confiar-hi, o 3, N o Esc per sortir de Codewhale.",
"OnboardTrustEnterHint": "Prem 1 per confiar-hi, 2 per continuar sense confiar-hi, o 3 o Esc per sortir de Codewhale.",
"OnboardTrustUntrustedNotice": "S'ha continuat sense confiança a l'espai de treball — les eines i els hooks continuen restringits.",
"RedactionGateActionConfirm": "confirmar i desactivar",
"RedactionGateActionKeep": "mantenir l'emmascarament",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Nicht vertrauenswürdige Dateien können versuchen, den Agenten per Prompt Injection umzuleiten.",
"OnboardTrustEffectHint": "Vertrauen wird für diese Codewhale-Installation gespeichert und aktiviert Workspace-Tools und -Hooks.",
"OnboardTrustEnterHint": "Drücke 1 oder Y zum Vertrauen, 2 oder U, um ohne Vertrauen fortzufahren, oder 3, N oder Esc, um Codewhale zu beenden.",
"OnboardTrustEnterHint": "Drücke 1 zum Vertrauen, 2, um ohne Vertrauen fortzufahren, oder 3 oder Esc, um Codewhale zu beenden.",
"OnboardTrustUntrustedNotice": "Fortfahren ohne Workspace-Vertrauen — Tools und Hooks bleiben eingeschränkt.",
"RedactionGateActionConfirm": "bestätigen und deaktivieren",
"RedactionGateActionKeep": "Schwärzung beibehalten",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Untrusted files can try to redirect the agent through prompt injection.",
"OnboardTrustEffectHint": "Trust is recorded for this Codewhale installation and enables workspace tools and hooks.",
"OnboardTrustEnterHint": "Press 1 or Y to trust, 2 or U to continue without trusting, or 3, N, or Esc to quit Codewhale.",
"OnboardTrustEnterHint": "Press 1 to trust, 2 to continue without trusting, or 3 or Esc to quit Codewhale.",
"OnboardTrustUntrustedNotice": "Continuing without workspace trust — tools and hooks stay restricted.",
"RedactionGateActionConfirm": "confirm and disable",
"RedactionGateActionKeep": "keep masking on",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Los archivos no confiables pueden intentar redirigir al agente mediante inyección de prompt.",
"OnboardTrustEffectHint": "La confianza se registra para esta instalación de Codewhale y habilita las herramientas y hooks del workspace.",
"OnboardTrustEnterHint": "Pulsa 1 o Y para confiar, 2 o U para continuar sin confiar, o 3, N o Esc para salir de Codewhale.",
"OnboardTrustEnterHint": "Pulsa 1 para confiar, 2 para continuar sin confiar, o 3 o Esc para salir de Codewhale.",
"OnboardTrustUntrustedNotice": "Continuando sin confiar en el workspace: las herramientas y los hooks siguen restringidos.",
"RedactionGateActionConfirm": "confirmar y desactivar",
"RedactionGateActionKeep": "mantener enmascaramiento",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Des fichiers non fiables peuvent tenter de détourner l'agent par injection de prompt.",
"OnboardTrustEffectHint": "La confiance est enregistrée pour cette installation de Codewhale et active les outils et hooks du workspace.",
"OnboardTrustEnterHint": "Appuyez sur 1 ou Y pour faire confiance, 2 ou U pour continuer sans confiance, ou 3, N ou Esc pour quitter Codewhale.",
"OnboardTrustEnterHint": "Appuyez sur 1 pour faire confiance, 2 pour continuer sans confiance, ou 3 ou Esc pour quitter Codewhale.",
"OnboardTrustUntrustedNotice": "Poursuite sans confiance du workspace — les outils et hooks restent restreints.",
"RedactionGateActionConfirm": "confirmer et désactiver",
"RedactionGateActionKeep": "garder le masquage",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "वर्कस्पेस ",
"OnboardTrustRiskHint": "अविश्वसनीय फ़ाइलें प्रॉम्प्ट इंजेक्शन से एजेंट को भटका सकती हैं।",
"OnboardTrustEffectHint": "ट्रस्ट इस Codewhale इंस्टॉलेशन के लिए दर्ज होता है और वर्कस्पेस टूल व हुक सक्षम करता है।",
"OnboardTrustEnterHint": "विश्वास के लिए 1 या Y, बिना विश्वास के जारी रखने के लिए 2 या U, या Codewhale बंद करने के लिए 3, N या Esc दबाएँ।",
"OnboardTrustEnterHint": "विश्वास के लिए 1, बिना विश्वास के जारी रखने के लिए 2, या Codewhale बंद करने के लिए 3 या Esc दबाएँ।",
"OnboardTrustUntrustedNotice": "वर्कस्पेस ट्रस्ट के बिना जारी — टूल और हुक प्रतिबंधित रहेंगे।",
"RedactionGateActionConfirm": "पुष्टि करें और अक्षम करें",
"RedactionGateActionKeep": "मास्किंग बनाए रखें",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "File tak tepercaya dapat mencoba mengalihkan agen lewat prompt injection.",
"OnboardTrustEffectHint": "Kepercayaan dicatat untuk instalasi Codewhale ini dan mengaktifkan alat serta hook workspace.",
"OnboardTrustEnterHint": "Tekan 1 atau Y untuk mempercayai, 2 atau U untuk lanjut tanpa mempercayai, atau 3, N, atau Esc untuk keluar dari Codewhale.",
"OnboardTrustEnterHint": "Tekan 1 untuk mempercayai, 2 untuk lanjut tanpa mempercayai, atau 3 atau Esc untuk keluar dari Codewhale.",
"OnboardTrustUntrustedNotice": "Melanjutkan tanpa kepercayaan workspace — alat dan hook tetap dibatasi.",
"RedactionGateActionConfirm": "konfirmasi dan nonaktifkan",
"RedactionGateActionKeep": "pertahankan penyembunyian",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "ワークスペース ",
"OnboardTrustRiskHint": "信頼できないファイルは、プロンプトインジェクションでエージェントを誘導しようとする場合があります。",
"OnboardTrustEffectHint": "信頼はこの Codewhale インストールに記録され、ワークスペースのツールとフックが有効になります。",
"OnboardTrustEnterHint": "1 または Y で信頼、2 または U で信頼せずに続行、3・N・Esc で Codewhale を終了します。",
"OnboardTrustEnterHint": "1 で信頼、2 で信頼せずに続行、3 または Esc で Codewhale を終了します。",
"OnboardTrustUntrustedNotice": "ワークスペースを信頼せずに続行します — ツールとフックは制限されたままです。",
"RedactionGateActionConfirm": "確認して無効化",
"RedactionGateActionKeep": "秘匿化を維持",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "작업 공간 ",
"OnboardTrustRiskHint": "신뢰할 수 없는 파일은 프롬프트 주입으로 에이전트를 다른 방향으로 유도할 수 있습니다.",
"OnboardTrustEffectHint": "신뢰는 이 Codewhale 설치에 기록되며 작업 공간 도구와 훅을 활성화합니다.",
"OnboardTrustEnterHint": "1 또는 Y로 신뢰, 2 또는 U로 신뢰 없이 계속, 3·N·Esc로 Codewhale을 종료합니다.",
"OnboardTrustEnterHint": "1로 신뢰, 2로 신뢰 없이 계속, 3 또는 Esc로 Codewhale을 종료합니다.",
"OnboardTrustUntrustedNotice": "작업 공간을 신뢰하지 않고 계속합니다 — 도구와 훅은 제한된 상태로 유지됩니다.",
"RedactionGateActionConfirm": "확인 후 끄기",
"RedactionGateActionKeep": "마스킹 유지",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Arquivos não confiáveis podem tentar redirecionar o agente por injeção de prompt.",
"OnboardTrustEffectHint": "A confiança fica registrada nesta instalação do Codewhale e habilita ferramentas e hooks do workspace.",
"OnboardTrustEnterHint": "Pressione 1 ou Y para confiar, 2 ou U para continuar sem confiar, ou 3, N ou Esc para sair do Codewhale.",
"OnboardTrustEnterHint": "Pressione 1 para confiar, 2 para continuar sem confiar, ou 3 ou Esc para sair do Codewhale.",
"OnboardTrustUntrustedNotice": "Continuando sem confiar no workspace — ferramentas e hooks permanecem restritos.",
"RedactionGateActionConfirm": "confirmar e desativar",
"RedactionGateActionKeep": "manter mascaramento",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Рабочая область ",
"OnboardTrustRiskHint": "Недоверенные файлы могут попытаться перенаправить агента через инъекцию промптов.",
"OnboardTrustEffectHint": "Доверие записывается для этой установки Codewhale и включает инструменты и хуки рабочей области.",
"OnboardTrustEnterHint": "Нажмите 1 или Y — доверить, 2 или U — продолжить без доверия, 3, N или Esc — выйти из Codewhale.",
"OnboardTrustEnterHint": "Нажмите 1 — доверить, 2 — продолжить без доверия, 3 или Esc — выйти из Codewhale.",
"OnboardTrustUntrustedNotice": "Продолжение без доверия к рабочей области — инструменты и хуки останутся ограниченными.",
"RedactionGateActionConfirm": "подтвердить и отключить",
"RedactionGateActionKeep": "оставить маскирование",
+1 -1
View File
@@ -1016,7 +1016,7 @@
"OnboardTrustLocationPrefix": "Робоча область ",
"OnboardTrustRiskHint": "Недовірені файли можуть спробувати перенаправити агента через ін'єкцію промптів.",
"OnboardTrustEffectHint": "Довіру записано для цього встановлення Codewhale; вона вмикає інструменти та хуки робочої області.",
"OnboardTrustEnterHint": "Натисніть 1 або Y, щоб довірити; 2 або U, щоб продовжити без довіри; або 3, N чи Esc, щоб вийти з Codewhale.",
"OnboardTrustEnterHint": "Натисніть 1, щоб довірити; 2, щоб продовжити без довіри; або 3 чи Esc, щоб вийти з Codewhale.",
"OnboardTrustUntrustedNotice": "Продовження без довіри до робочої області — інструменти та хуки лишаються обмеженими.",
"RedactionGateActionConfirm": "підтвердити й вимкнути",
"RedactionGateActionKeep": "залишити маскування",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "Workspace ",
"OnboardTrustRiskHint": "Tệp không đáng tin có thể tìm cách chuyển hướng agent bằng prompt injection.",
"OnboardTrustEffectHint": "Trạng thái tin cậy được ghi cho bản cài Codewhale này và bật công cụ cùng hook của workspace.",
"OnboardTrustEnterHint": "Nhấn 1 hoặc Y để tin cậy, 2 hoặc U để tiếp tục mà không tin cậy, hoặc 3, N, Esc để thoát Codewhale.",
"OnboardTrustEnterHint": "Nhấn 1 để tin cậy, 2 để tiếp tục mà không tin cậy, hoặc 3 hay Esc để thoát Codewhale.",
"OnboardTrustUntrustedNotice": "Tiếp tục mà không tin cậy workspace — công cụ và hook vẫn bị hạn chế.",
"RedactionGateActionConfirm": "xác nhận và tắt",
"RedactionGateActionKeep": "giữ che khóa",
+1 -1
View File
@@ -1039,7 +1039,7 @@
"OnboardTrustLocationPrefix": "工作区 ",
"OnboardTrustRiskHint": "不受信任的文件可能会通过提示词注入来误导智能体。",
"OnboardTrustEffectHint": "信任状态会记录到此 Codewhale 安装中,并启用工作区工具与钩子。",
"OnboardTrustEnterHint": "按 1 或 Y 信任,按 2 或 U 在不信任的情况下继续,按 3、N 或 Esc 退出 Codewhale。",
"OnboardTrustEnterHint": "按 1 信任,按 2 在不信任的情况下继续,按 3 或 Esc 退出 Codewhale。",
"OnboardTrustUntrustedNotice": "在未信任工作区的情况下继续 — 工具与钩子保持受限。",
"RedactionGateActionConfirm": "确认并关闭",
"RedactionGateActionKeep": "保持脱敏开启",
+1 -1
View File
@@ -1037,7 +1037,7 @@
"OnboardTrustLocationPrefix": "工作區 ",
"OnboardTrustRiskHint": "不受信任的檔案可能會透過提示詞注入來誤導代理。",
"OnboardTrustEffectHint": "信任狀態會記錄到此 Codewhale 安裝中,並啟用工作區工具與掛鉤。",
"OnboardTrustEnterHint": "按 1 或 Y 信任,按 3 或 U 在不信任的情況下繼續,按 2、N 或 Esc 退出 Codewhale。",
"OnboardTrustEnterHint": "按 1 信任,按 2 在不信任的情況下繼續,按 3 或 Esc 退出 Codewhale。",
"OnboardTrustUntrustedNotice": "在未信任工作區的情況下繼續 — 工具與掛鉤保持受限。",
"RedactionGateActionConfirm": "確認並關閉",
"RedactionGateActionKeep": "保持脫敏開啟",
+2 -1
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
@@ -21,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
+1 -1
View File
@@ -5296,7 +5296,7 @@ mod tests {
fn locked_model_keeps_keyboard_focus_visible_without_becoming_selectable() {
let mut picker = test_picker();
picker.model_rows[0].selectable = false;
picker.model_rows[0].blocked_reason = Some("missing key".to_string());
picker.model_rows[0].blocked_reason = Some("needs key".to_string());
let area = Rect::new(0, 0, 100, 32);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
+3 -6
View File
@@ -138,12 +138,9 @@ fn action_hints(app: &App) -> Vec<ActionHint> {
ActionHint::new("Esc", app.tr(MessageId::OnboardActionBack).to_string()),
],
OnboardingState::TrustDirectory => vec![
ActionHint::new(
"1/Y",
app.tr(MessageId::OnboardTrustActionTrust).to_string(),
),
ActionHint::new("2/U", app.tr(MessageId::OnboardTrustActionSkip).to_string()),
ActionHint::new("3/N", app.tr(MessageId::OnboardTrustActionQuit).to_string()),
ActionHint::new("1", app.tr(MessageId::OnboardTrustActionTrust).to_string()),
ActionHint::new("2", app.tr(MessageId::OnboardTrustActionSkip).to_string()),
ActionHint::new("3", app.tr(MessageId::OnboardTrustActionQuit).to_string()),
],
OnboardingState::Ready => vec![
// Only keys this screen handles: a `/rc` hint here could not be
@@ -1,9 +1,10 @@
//! Workspace trust prompt for onboarding.
//!
//! One decision: trust the instructions and files in this folder, or
//! continue without trust. The explicit 1/Y · 2/U · 3/N keys stay — trusting
//! continue without trust. The explicit 1 · 2 · 3 keys stay — trusting
//! a workspace is a security boundary and must never happen by reflex.
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
@@ -11,6 +12,25 @@ use crate::tui::app::App;
use codewhale_localization::MessageId;
use codewhale_palette as palette;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrustChoice {
Trust,
ContinueUntrusted,
Quit,
}
pub(crate) fn choice_for_key(code: KeyCode, modifiers: KeyModifiers) -> Option<TrustChoice> {
if !(modifiers - KeyModifiers::SHIFT).is_empty() {
return None;
}
match code {
KeyCode::Char('1') => Some(TrustChoice::Trust),
KeyCode::Char('2') => Some(TrustChoice::ContinueUntrusted),
KeyCode::Char('3') | KeyCode::Esc => Some(TrustChoice::Quit),
_ => None,
}
}
/// Wrap a path-bearing line at `/` boundaries so a deep workspace never
/// hard-splits mid-component under ratatui's whitespace-only `Wrap`.
/// Continuation lines are indented to read as one location.
@@ -113,6 +133,50 @@ mod tests {
use codewhale_runtime::config::Config;
use std::path::PathBuf;
#[test]
fn only_the_digits_and_esc_act_on_the_trust_screen() {
for sentence in [
"not yet",
"no thank you",
"nope, maybe later",
"Let me look first. No thanks, n N y Y u U",
] {
for ch in sentence.chars() {
assert_eq!(
choice_for_key(KeyCode::Char(ch), KeyModifiers::NONE),
None,
"{ch:?} in {sentence:?}"
);
}
}
for modifier in [KeyModifiers::SUPER, KeyModifiers::META] {
assert_eq!(choice_for_key(KeyCode::Char('1'), modifier), None);
}
for ch in ['y', 'Y'] {
assert_eq!(
choice_for_key(KeyCode::Char(ch), KeyModifiers::CONTROL),
None
);
assert_eq!(choice_for_key(KeyCode::Char(ch), KeyModifiers::ALT), None);
}
assert_eq!(
choice_for_key(KeyCode::Char('1'), KeyModifiers::NONE),
Some(TrustChoice::Trust)
);
assert_eq!(
choice_for_key(KeyCode::Char('2'), KeyModifiers::NONE),
Some(TrustChoice::ContinueUntrusted)
);
assert_eq!(
choice_for_key(KeyCode::Char('3'), KeyModifiers::NONE),
Some(TrustChoice::Quit)
);
assert_eq!(
choice_for_key(KeyCode::Esc, KeyModifiers::NONE),
Some(TrustChoice::Quit)
);
}
#[test]
fn prompt_names_the_workspace_boundary_and_effects() {
let options = TuiOptions {
@@ -163,7 +227,7 @@ mod tests {
.collect::<Vec<_>>()
.join(" ");
for expected in ["1/Y", "2/U", "3/N"] {
for expected in ["1", "2", "3"] {
assert!(
rail.contains(expected),
"missing {expected} in rail: {rail}"
+49 -4
View File
@@ -737,7 +737,15 @@ impl ProviderDashboardRow {
billing_presentation:
codewhale_engine::route_billing::BillingPresentation::Unknown,
maturity: ProviderMaturity::for_provider(provider),
messages: vec![format!("provider admission failed: {error}")],
messages: if provider == ProviderKind::Custom
&& !is_active
&& configured.is_none()
&& provider_descriptor(&provider_id).is_none()
{
Vec::new()
} else {
vec![format!("provider admission failed: {error}")]
},
external_credential_status: None,
is_active,
has_key: false,
@@ -1140,6 +1148,9 @@ impl ProviderDashboardRow {
/// repeats. One owner so the renderer and the tests cannot disagree about
/// what a provider's state reads as.
fn detail_state_line(&self) -> String {
if self.is_custom_placeholder() {
return "needs endpoint".to_string();
}
let auth = match self.auth_status {
ProviderAuthStatus::Missing | ProviderAuthStatus::Configured => String::new(),
status => format!(" | {}", status.label()),
@@ -1164,7 +1175,7 @@ impl ProviderDashboardRow {
fn list_row_hint(&self, view: ProviderListView) -> String {
match view {
// `readiness` already reads as prose ("key saved · not checked",
// "missing key"); `auth_status` said the same thing again in
// "needs key"); `auth_status` said the same thing again in
// machine spelling ("key:configured", "key:not-set").
ProviderListView::Configured => self.readiness.label().to_string(),
ProviderListView::Catalog => {
@@ -6241,6 +6252,40 @@ mod tests {
);
}
#[test]
fn fresh_install_custom_slot_reads_needs_endpoint_without_legacy_or_error() {
let picker = ProviderPickerView::new(ProviderKind::Custom, &Config::default());
let row = picker
.rows
.iter()
.find(|row| row.provider_id == ProviderKind::Custom.as_str())
.expect("custom slot");
assert!(row.is_custom_placeholder());
assert_eq!(row.detail_state_line(), "needs endpoint");
assert!(!row.detail_state_line().contains("legacy"));
assert!(row.messages.is_empty(), "{:?}", row.messages);
}
#[test]
fn selected_custom_route_without_an_endpoint_keeps_its_admission_error() {
let mut config = Config::default();
config.provider = Some("custom".to_string());
let picker = ProviderPickerView::new(ProviderKind::Custom, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider_id == ProviderKind::Custom.as_str())
.expect("custom slot");
assert!(!row.is_custom_placeholder());
assert!(
row.messages
.iter()
.any(|m| m.contains("provider admission failed")),
"{:?}",
row.messages
);
}
#[test]
fn default_view_shows_only_configured_providers() {
// #3830: with nothing but the active provider set up, the default
@@ -8223,9 +8268,9 @@ mod tests {
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert_eq!(row.readiness.label(), "missing key");
assert_eq!(row.readiness.label(), "needs key");
let hint = row.detail_state_line();
assert!(hint.contains("missing key"));
assert!(hint.contains("needs key"));
assert!(!hint.contains("key:not-set"));
assert!(!hint.contains("needs-auth"));
assert!(!hint.contains("auth:missing"));
+16 -23
View File
@@ -6429,31 +6429,24 @@ pub(crate) async fn run_event_loop(
onboarding::finish_ready_and_open_composer(app);
open_theme_picker(app);
}
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1')
if app.onboarding == OnboardingState::TrustDirectory =>
{
if let Err(err) = complete_trust_directory_onboarding(app, config) {
app.status_message = Some(format!("Failed to trust workspace: {err}"));
_ if app.onboarding == OnboardingState::TrustDirectory => {
match onboarding::trust_directory::choice_for_key(key.code, key.modifiers) {
Some(onboarding::trust_directory::TrustChoice::Trust) => {
if let Err(err) = complete_trust_directory_onboarding(app, config) {
app.status_message =
Some(format!("Failed to trust workspace: {err}"));
}
}
Some(onboarding::trust_directory::TrustChoice::ContinueUntrusted) => {
continue_without_trusting_directory(app);
}
Some(onboarding::trust_directory::TrustChoice::Quit) => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
None => {}
}
}
// Number keys mirror the footer's reading order (1 trust,
// 2 continue untrusted, 3 quit) so the displayed digits
// are sequential instead of 1/3/2.
KeyCode::Char('u') | KeyCode::Char('U') | KeyCode::Char('2')
if app.onboarding == OnboardingState::TrustDirectory =>
{
continue_without_trusting_directory(app);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('3')
if app.onboarding == OnboardingState::TrustDirectory =>
{
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
KeyCode::Esc if app.onboarding == OnboardingState::TrustDirectory => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
_ => {}
}
continue;
+3 -2
View File
@@ -317,8 +317,9 @@ then `Enter` to submit or `Esc` to return to the options without sending.
| `Esc` | Step back one screen |
| `1`–`9` | Pick a language (Language step) |
| `0`–`9` | Pick a provider (Provider step; SGLang, vLLM, and Ollama are keyless by default) |
| `y` / `Y` | Trust the workspace (Trust step) |
| `n` / `N` | Skip the trust prompt |
| `1` | Trust the workspace (Trust step) |
| `2` | Continue without trusting (Trust step) |
| `3` / `Esc` | Quit Codewhale (Trust step); letters do nothing there |
## v0.8.29 audit notes
+3 -2
View File
@@ -251,8 +251,9 @@ Hotbar 的触发键刻意只限 `Alt-1` 到 `Alt-8`。在 macOS 键盘上,这
| `Esc` | 后退一屏 |
| `1`–`9` | 选择语言(语言步骤) |
| `0`–`9` | 选择提供商(提供商步骤;SGLang、vLLM 和 Ollama 默认无需密钥) |
| `y` / `Y` | 信任工作区(信任步骤) |
| `n` / `N` | 跳过信任提示 |
| `1` | 信任工作区(信任步骤) |
| `2` | 不信任并继续(信任步骤) |
| `3` / `Esc` | 退出 Codewhale(信任步骤);字母键在此不执行任何操作 |
## v0.8.29 审计说明
+1 -1
View File
@@ -152,7 +152,7 @@ codewhale auth set --provider moonshot --api-key "你的中国区Kimi API key"
**第三步:在 Codewhale 里切换**
1. 输入 `/provider` 打开提供商选择器 → 选 **moonshot**(可能显示 missing key,不用管,继续选)
1. 输入 `/provider` 打开提供商选择器 → 选 **moonshot**(可能显示 needs key,不用管,继续选)
2. 如果提示输入 key,就粘贴你的中国区 key
3. 选模型:`/model kimi-k3`(最强,1M 上下文)或 `/model kimi-k2.7-code`(默认稳定)
4. 发一条测试消息,能正常回复就说明切换成功
+32
View File
@@ -71,6 +71,38 @@ cp "$INSTALL_TEST_ASSETS/$asset" "$output"
return { root, home, bin, archive, assets, destination, env, run, prepare, untouched };
}
function flakyCurl(f, failures) {
const counter = path.join(f.root, "curl-calls");
const real = fs.readFileSync(path.join(f.bin, "curl"), "utf8");
executable(path.join(f.bin, "curl"), `#!/bin/sh
n=$(cat "${counter}" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "${counter}"
if [ "$n" -le ${failures} ]; then echo "curl: (35) SSL_connect: SSL_ERROR_SYSCALL" >&2; exit 35; fi
${real.replace("#!/bin/sh\n", "")}`);
f.env.CODEWHALE_INSTALL_RETRY_DELAY = "0";
return counter;
}
test("website: a download that fails twice with a connection error is retried and the install succeeds", t => {
const f = fixture(t, "website");
const counter = flakyCurl(f, 2);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(counter, "utf8").trim(), "4");
assert.equal(fs.readFileSync(path.join(f.destination, "codewhale"), "utf8"), bytes);
});
test("website: three failed attempts end with the retry hint and install nothing", t => {
const f = fixture(t, "website");
const counter = flakyCurl(f, 99);
const result = f.run();
assert.notEqual(result.status, 0);
assert.equal(fs.readFileSync(counter, "utf8").trim(), "3");
assert.ok(result.stderr.includes("Download failed. This is usually a network blip; run the same command again."), result.stderr);
assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
});
for (const kind of ["website", "archive"]) {
test(`${kind}: fresh installation verifies bytes, executable modes, and PATH shadowing`, t => {
const f = fixture(t, kind);
+2 -9
View File
@@ -9,21 +9,14 @@ GITHUB_TOKEN=
GITHUB_REPO=codewhale-hq/CodeWhale
# Optional — required to manually invoke /api/cron
# (cloudflare cron triggers don't need this; they set cf-cron). When set it must
# be 32 to 512 characters with at least 8 distinct, and a shorter value repeated
# is refused; otherwise the trigger stays disabled.
# Generate one with: openssl rand -base64 33
# (cloudflare cron triggers don't need this; they set cf-cron).
CRON_SECRET=
# Optional — defaults to deepseek-flash.
DEEPSEEK_MODEL=deepseek-flash
DEEPSEEK_BASE_URL=https://api.deepseek.com
# Admin panel auth. Set to a random secret of 32 to 512 characters with at
# least 8 distinct characters, and a shorter value repeated is refused (anything
# else leaves admin sign-in disabled); enter it in the /admin login form.
# Changing it signs out open admin sessions.
# Generate one with: openssl rand -base64 33
# Admin panel auth. Set to a random secret; enter it in the /admin login form.
MAINTAINER_TOKEN=
# GitHub PAT for posting comments via /admin. Needs issues:write scope.
+3 -5
View File
@@ -87,8 +87,8 @@ form. The form posts to `/api/admin/login`; the token never goes in the URL.
|---|---|---|
| `DEEPSEEK_API_KEY` | Yes | DeepSeek API key for the community agent |
| `GITHUB_TOKEN` | Optional | Fine-grained PAT for GitHub API (raises rate limit) |
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation (32 to 512 characters, at least 8 distinct, and a shorter value repeated is refused; otherwise the trigger stays disabled); attempts are rate limited (5 per minute per location) |
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel; 32 to 512 characters with at least 8 distinct, and a shorter value repeated is refused (otherwise sign-in stays disabled); changing it signs out open sessions |
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation |
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel |
| `MAINTAINER_GITHUB_PAT` | Optional | GitHub PAT with `issues:write` scope for posting comments |
## Initial deployment
@@ -103,9 +103,7 @@ One-time setup before the first `npm run deploy`:
Copy the returned `id` values into the matching `CURATED_KV` and
`NEXT_INC_CACHE_KV` bindings in `cloudflare.config.ts`.
2. **Set secrets:** generate `MAINTAINER_TOKEN` and `CRON_SECRET` with
`openssl rand -base64 33` (any random value of 32 to 512 characters with at
least 8 distinct and not a shorter value repeated works; `openssl rand -hex 32` is fine).
2. **Set secrets:**
```bash
npx wrangler secret put DEEPSEEK_API_KEY
npx wrangler secret put MAINTAINER_TOKEN
+2 -2
View File
@@ -20,10 +20,10 @@ Env (mirrors `.env.example`):
| `DEEPSEEK_API_KEY` | DeepSeek platform key (`sk-...`) | only for the `/api/cron` tasks (summarization + community agent) |
| `GITHUB_TOKEN` | Fine-grained PAT, public-repo read scope | optional (raises rate limit 60 → 5000 req/h) |
| `GITHUB_REPO` | Defaults to `codewhale-hq/CodeWhale` | optional |
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation (32-512 characters, 8+ distinct; a shorter value repeated is refused)| optional (Cloudflare cron triggers don't need it) |
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation | optional (Cloudflare cron triggers don't need it) |
| `DEEPSEEK_MODEL` | Defaults to `deepseek-flash` | optional |
| `DEEPSEEK_BASE_URL` | Defaults to `https://api.deepseek.com` | optional |
| `MAINTAINER_TOKEN` | Admin panel auth (32-512 characters, 8+ distinct; a shorter value repeated is refused; changing it signs out sessions); enter it in the `/admin` login form | only for `/admin` |
| `MAINTAINER_TOKEN` | Admin panel auth; enter it in the `/admin` login form | only for `/admin` |
| `MAINTAINER_GITHUB_PAT` | PAT with `issues:write`, for posting comments via `/admin` | only for `/admin` posting |
| `NEXT_PUBLIC_GITEE_ENABLED` | Set to `1` once the Gitee mirror exists; blank hides Gitee links | optional |
+2 -4
View File
@@ -4,7 +4,6 @@ import { ConnectionBanner } from "@/components/connection-banner";
import { EmptyState, ErrorState } from "@/components/surface-state";
import { WhalePose } from "@/components/whale-pose";
import { getAgentEnv, listDrafts, validateSession, type AgentDraft } from "@/lib/community-agent";
import { MAINTAINER_TOKEN_UNAVAILABLE, maintainerTokenUsable } from "@/lib/maintainer-token";
import { AdminClient } from "./admin-client";
export const dynamic = "force-dynamic";
@@ -78,8 +77,7 @@ export default async function AdminPage({
const env = await getAgentEnv();
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
if (env.MAINTAINER_TOKEN) console.warn(MAINTAINER_TOKEN_UNAVAILABLE);
if (!env.MAINTAINER_TOKEN) {
return (
<div className="route-state">
<ConnectionBanner locale={locale} />
@@ -100,7 +98,7 @@ export default async function AdminPage({
const cookieStore = await cookies();
const sid = cookieStore.get("mt_sid")?.value;
const authed = await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN);
const authed = await validateSession(env.CURATED_KV, sid);
if (!authed) {
return <LoginForm locale={locale} error={err === "1"} />;
+5 -5
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { getAgentEnv, safeEqual, createSession } from "@/lib/community-agent";
import { MAINTAINER_TOKEN_UNAVAILABLE, MAX_SECRET_CHARS, maintainerTokenUsable } from "@/lib/maintainer-token";
import { FormBodyError, readBoundedUrlEncodedForm } from "@/lib/bounded-form";
export const dynamic = "force-dynamic";
const ALLOWED_LOCALES = new Set(["en", "zh"]);
const MAX_LOGIN_BODY_BYTES = 4_096;
const MAX_TOKEN_CHARS = 512;
function pickLocale(value: string | null | undefined): string {
if (!value) return "en";
@@ -18,8 +18,8 @@ export async function POST(req: Request) {
const url = new URL(req.url);
const localeFromQuery = pickLocale(url.searchParams.get("locale"));
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
return new NextResponse(MAINTAINER_TOKEN_UNAVAILABLE, {
if (!env.MAINTAINER_TOKEN) {
return new NextResponse("Not configured", {
status: 503,
headers: { "Cache-Control": "no-store" },
});
@@ -58,7 +58,7 @@ export async function POST(req: Request) {
}
const submitted = form.get("token") ?? "";
const locale = pickLocale(form.get("locale") ?? localeFromQuery);
if (submitted.length > MAX_SECRET_CHARS) {
if (submitted.length > MAX_TOKEN_CHARS) {
return new NextResponse("Token too long", {
status: 413,
headers: { "Cache-Control": "no-store" },
@@ -73,7 +73,7 @@ export async function POST(req: Request) {
});
}
const sid = await createSession(env.CURATED_KV, env.MAINTAINER_TOKEN);
const sid = await createSession(env.CURATED_KV);
if (!sid) {
return new NextResponse("Session storage unavailable", {
status: 503,
+1 -1
View File
@@ -9,7 +9,7 @@ export async function POST(request: Request) {
try {
const authEnv = await getAgentEnv();
const sid = request.headers.get("cookie")?.split(";").map(c => c.trim()).find(c => c.startsWith("mt_sid="))?.slice(7);
if (!await validateSession(authEnv.CURATED_KV, sid, authEnv.MAINTAINER_TOKEN)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
if (!authEnv.MAINTAINER_TOKEN || !await validateSession(authEnv.CURATED_KV, sid)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
const env = await merchEnv(), config = parseConfig(env.MERCH_CONFIG_JSON);
if (!config || request.headers.get("origin") !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Use the approved store origin.");
const raw = await readBoundedBody(request, 8192);
+1 -1
View File
@@ -39,7 +39,7 @@ async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: bo
}
}
if (!sid || !(await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN))) {
if (!sid || !(await validateSession(env.CURATED_KV, sid))) {
return { ok: false, status: 401, error: "unauthorized" };
}
return { ok: true };
+1 -26
View File
@@ -4,7 +4,6 @@ import type { AgentEnv } from "@/lib/community-agent-tasks";
import { runFactsDrift } from "@/lib/facts-drift";
import { runLinkCheck, runSemanticDrift } from "@/lib/content-watch";
import { safeEqual } from "@/lib/community-agent";
import { CRON_SECRET_UNAVAILABLE, secretStrongEnough } from "@/lib/maintainer-token";
export const dynamic = "force-dynamic";
@@ -31,33 +30,9 @@ export async function GET(req: Request) {
);
}
if (!secretStrongEnough(env.CRON_SECRET)) {
console.warn(CRON_SECRET_UNAVAILABLE);
return NextResponse.json(
{ error: CRON_SECRET_UNAVAILABLE },
{ status: 503, headers: { "Cache-Control": "no-store" } }
);
}
try {
if (!env.ADMIN_LOGIN_LIMITER) throw new Error("Missing cron limiter");
const { success } = await env.ADMIN_LOGIN_LIMITER.limit({ key: "codewhale-web:cron-secret" });
if (!success) {
return NextResponse.json(
{ error: "too many attempts" },
{ status: 429, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
);
}
} catch {
return NextResponse.json(
{ error: "manual trigger temporarily unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
);
}
const auth = req.headers.get("x-cron-secret") ?? "";
if (!(await safeEqual(auth, env.CRON_SECRET))) {
return NextResponse.json({ error: "unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } });
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
+4 -4
View File
@@ -25,8 +25,8 @@
},
{
"path": "docs/KEYBINDINGS.md",
"sha256": "5b3a71595db86ed30eb79675c2b934d60dce897f0202142794be849537a36da9",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
"sha256": "2f19e2ce24852b7034ed3a2998259d4f51f0d458a83cf225f37abc98d9b129d7",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
}
]
},
@@ -57,8 +57,8 @@
},
{
"path": "docs/KEYBINDINGS.md",
"sha256": "5b3a71595db86ed30eb79675c2b934d60dce897f0202142794be849537a36da9",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
"sha256": "2f19e2ce24852b7034ed3a2998259d4f51f0d458a83cf225f37abc98d9b129d7",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / clear a non-empty draft (recoverable — see `Ctrl-Z`) / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | Complete a slash command, `@` file mention or prompt suggestion. Tab never changes the mode |\n| `Shift+Tab` | Step the permission dial: Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask. Plan is the Plan mode; every other stop is Work with that posture. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open). During a turn the change, including into or out of Plan, applies at the agent's next step; a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During Line truncated
}
]
},
+4 -10
View File
@@ -14,7 +14,7 @@ vi.mock("@/lib/community-agent", async (importOriginal) => {
import { POST } from "@/app/api/admin/login/route";
import { createSession, safeEqual, validateSession } from "@/lib/community-agent";
const FIXTURE_TOKEN = "local-admin-integration-fixture-0123456789";
const FIXTURE_TOKEN = "local-admin-integration-fixture";
const sessions = new Map<string, string>();
const kv = {
put: vi.fn(async (key: string, value: string) => { sessions.set(key, value); }),
@@ -57,9 +57,9 @@ describe("admin login with real credential and session helpers", () => {
const sid = response.cookies.get("mt_sid")?.value;
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(sessions.size).toBe(1);
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
expect(await validateSession(sessionKv, sid)).toBe(true);
expect(safeEqual).toHaveBeenCalledExactlyOnceWith(FIXTURE_TOKEN, FIXTURE_TOKEN);
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv, FIXTURE_TOKEN);
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv);
const cookie = response.headers.get("Set-Cookie")?.toLowerCase();
expect(cookie).toContain("httponly");
expect(cookie).toContain("secure");
@@ -69,13 +69,7 @@ describe("admin login with real credential and session helpers", () => {
expect.any(String), expect.any(String), { expirationTtl: 86400 },
]);
sessions.clear();
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(false);
});
it("stops honouring a session after the maintainer token is rotated", async () => {
const sid = (await POST(request())).cookies.get("mt_sid")?.value;
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
expect(await validateSession(sessionKv, sid, "rotated-integration-fixture-9876543210")).toBe(false);
expect(await validateSession(sessionKv, sid)).toBe(false);
});
it("rejects a wrong credential without creating a session", async () => {
+4 -38
View File
@@ -5,9 +5,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/community-agent", () => mocks);
import { POST } from "@/app/api/admin/login/route";
const STRONG_TOKEN = "local-fixture-token-0123456789-abcdefghij";
const LONE_SURROGATES = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
function request(token = "wrong", ip = "127.0.0.1") {
return new Request("https://codewhale.net/api/admin/login?locale=en", {
method: "POST",
@@ -17,7 +14,7 @@ function request(token = "wrong", ip = "127.0.0.1") {
}
beforeEach(() => {
vi.resetAllMocks();
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: STRONG_TOKEN, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: "configured", ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.limit.mockResolvedValue({ success: true });
mocks.safeEqual.mockResolvedValue(false);
mocks.createSession.mockResolvedValue("session-id");
@@ -38,7 +35,7 @@ describe("admin login attempt boundary", () => {
});
it("does not parse or compare credentials after refusal", async () => {
mocks.limit.mockResolvedValue({ success: false });
const req = request(STRONG_TOKEN);
const req = request("configured");
const read = vi.spyOn(req, "text");
expect((await POST(req)).status).toBe(429);
expect(read).not.toHaveBeenCalled();
@@ -46,47 +43,16 @@ describe("admin login attempt boundary", () => {
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("fails closed if the binding is missing or unavailable", async () => {
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: STRONG_TOKEN });
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: "configured" });
expect((await POST(request())).status).toBe(503);
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
expect((await POST(request())).status).toBe(503);
expect(mocks.safeEqual).not.toHaveBeenCalled();
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("refuses to run with a configured token below the minimum strength", async () => {
const weakTokens = [
"x", "configured", "a".repeat(31), "a".repeat(32), " ".repeat(40), "password".repeat(4),
"Password1".repeat(4), "0123456".repeat(5), "\u{1F433}".repeat(16), "L".repeat(600), LONE_SURROGATES,
];
for (const weak of weakTokens) {
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: weak, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
const response = await POST(request(weak));
expect(response.status).toBe(503);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(await response.text()).toContain("at least 32 characters");
}
expect(mocks.limit).not.toHaveBeenCalled();
expect(mocks.safeEqual).not.toHaveBeenCalled();
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("accepts a token of exactly the minimum length", async () => {
const exact = "abcdefghijklmnopqrstuvwxyz012345";
expect(exact).toHaveLength(32);
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: exact, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
expect((await POST(request(exact))).status).toBe(303);
});
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
expect(new Set(hex).size).toBeLessThan(16);
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
expect((await POST(request(hex))).status).toBe(303);
});
it("retains the authenticated session and redirect after an allowed attempt", async () => {
mocks.safeEqual.mockResolvedValue(true);
const response = await POST(request(STRONG_TOKEN));
const response = await POST(request("configured"));
expect(response.status).toBe(303);
expect(response.headers.get("Location")).toBe("https://codewhale.net/en/admin");
const cookie = response.headers.get("Set-Cookie");
+1 -1
View File
@@ -144,7 +144,7 @@ function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) {
mocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
...(lock ? { DRAFT_CLAIM_LOCK: lock } : {}),
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
MAINTAINER_TOKEN: "configured",
MAINTAINER_GITHUB_PAT: "ghp_test",
GITHUB_REPO: "codewhale-hq/CodeWhale",
});
+23 -46
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createSession, logUsage, validateSession } from "./community-agent";
import { logUsage, validateSession } from "./community-agent";
describe("validateSession", () => {
let mockKV: {
@@ -20,88 +20,65 @@ describe("validateSession", () => {
const VALID_SID = "a".repeat(40);
const SESSION_PREFIX = "session:admin:";
const TOKEN = "local-fixture-token-0123456789-abcdefghij";
const ROTATED = "rotated-fixture-token-9876543210-zyxwvutsrq";
it("should return false if kv is undefined", async () => {
const result = await validateSession(undefined, VALID_SID, TOKEN);
const result = await validateSession(undefined, VALID_SID);
expect(result).toBe(false);
});
it("should return false if sid is undefined or null", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, undefined, TOKEN)).toBe(false);
expect(await validateSession(kv, null, TOKEN)).toBe(false);
expect(await validateSession(kv, undefined)).toBe(false);
expect(await validateSession(kv, null)).toBe(false);
});
it("should return false if sid is empty", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "", TOKEN)).toBe(false);
expect(await validateSession(kv, "")).toBe(false);
});
it("should return false if sid length is less than 40", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "a".repeat(39), TOKEN)).toBe(false);
expect(await validateSession(kv, "a".repeat(39))).toBe(false);
});
it("should return false if sid length is greater than 64", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "a".repeat(65), TOKEN)).toBe(false);
expect(await validateSession(kv, "a".repeat(65))).toBe(false);
});
it("should return false if sid contains invalid characters", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
// Length 40 but contains invalid char '!'
expect(await validateSession(kv, "a".repeat(39) + "!", TOKEN)).toBe(false);
expect(await validateSession(kv, "a".repeat(39) + "!")).toBe(false);
});
it("should return false if session is not found in KV", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
mockKV.get.mockResolvedValue(null);
const result = await validateSession(kv, VALID_SID, TOKEN);
const result = await validateSession(kv, VALID_SID);
expect(result).toBe(false);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
});
it("should return true for a session minted under the configured token", async () => {
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => { store.set(key, value); },
} as unknown as Parameters<typeof validateSession>[0];
const sid = await createSession(kv, TOKEN);
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
});
it("should refuse the same session once the configured token changes", async () => {
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => { store.set(key, value); },
} as unknown as Parameters<typeof validateSession>[0];
const sid = await createSession(kv, TOKEN);
expect(await validateSession(kv, sid, ROTATED)).toBe(false);
expect(await validateSession(kv, sid, "short")).toBe(false);
expect(await validateSession(kv, sid, undefined)).toBe(false);
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
});
it("should refuse a stored record with no token fingerprint or unparseable content", async () => {
it("should return true if session is found in KV", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
for (const raw of [JSON.stringify({ createdAt: Date.now() }), "{}", "not json", JSON.stringify({ tokenFingerprint: 7 })]) {
mockKV.get.mockResolvedValue(raw);
expect(await validateSession(kv, VALID_SID, TOKEN)).toBe(false);
}
mockKV.get.mockResolvedValue(JSON.stringify({ createdAt: Date.now() }));
const result = await validateSession(kv, VALID_SID);
expect(result).toBe(true);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
});
it("should not mint a session under a weak token", async () => {
const kv = mockKV as unknown as Parameters<typeof createSession>[0];
for (const weak of [undefined, "", "short", "a".repeat(32)]) {
expect(await createSession(kv, weak)).toBeNull();
}
expect(mockKV.put).not.toHaveBeenCalled();
it("should work with a 64 character sid", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
mockKV.get.mockResolvedValue("{}");
const sid = "b".repeat(64);
const result = await validateSession(kv, sid);
expect(result).toBe(true);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + sid);
});
});
+6 -26
View File
@@ -12,7 +12,6 @@
import type { DraftClaimLockNamespace, DraftClaimLockStub, DraftLockAction } from "./draft-claim-lock";
import { maintainerTokenUsable } from "./maintainer-token";
import { requestChat, type ChatMessage, type DeepSeekEnv } from "./deepseek";
export type { DeepSeekEnv } from "./deepseek";
@@ -723,7 +722,6 @@ export async function deleteDraft(kv: KVNamespace | undefined, key: string): Pro
const SESSION_PREFIX = "session:admin:";
const SESSION_TTL_SEC = 60 * 60 * 24; // 24h
const SESSION_TOKEN_DOMAIN = "codewhale-web:admin-session:v1:";
function toBase64Url(bytes: Uint8Array): string {
let s = "";
@@ -740,39 +738,21 @@ export async function safeEqual(a: string, b: string): Promise<boolean> {
return diff === 0;
}
async function tokenFingerprint(token: string): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(SESSION_TOKEN_DOMAIN + token)));
let hex = "";
for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
return hex;
}
export async function createSession(kv: KVNamespace | undefined, token: string | undefined): Promise<string | null> {
if (!kv || !maintainerTokenUsable(token)) return null;
export async function createSession(kv: KVNamespace | undefined): Promise<string | null> {
if (!kv) return null;
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const sid = toBase64Url(bytes);
const value = JSON.stringify({ createdAt: Date.now(), tokenFingerprint: await tokenFingerprint(token) });
const value = JSON.stringify({ createdAt: Date.now() });
await kv.put(SESSION_PREFIX + sid, value, { expirationTtl: SESSION_TTL_SEC });
return sid;
}
export async function validateSession(
kv: KVNamespace | undefined,
sid: string | undefined | null,
token: string | undefined,
): Promise<boolean> {
if (!kv || !sid || !maintainerTokenUsable(token)) return false;
export async function validateSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<boolean> {
if (!kv || !sid) return false;
if (!/^[A-Za-z0-9_-]{40,64}$/.test(sid)) return false;
const raw = await kv.get(SESSION_PREFIX + sid);
if (raw === null) return false;
let stored: unknown;
try {
stored = (JSON.parse(raw) as { tokenFingerprint?: unknown }).tokenFingerprint;
} catch {
return false;
}
return typeof stored === "string" && (await safeEqual(stored, await tokenFingerprint(token)));
return raw !== null;
}
export async function deleteSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<void> {
-76
View File
@@ -1,76 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ getEnv: vi.fn(), safeEqual: vi.fn(), limit: vi.fn() }));
vi.mock("@/lib/kv", () => ({ getEnv: mocks.getEnv }));
vi.mock("@/lib/community-agent", () => ({ safeEqual: mocks.safeEqual }));
vi.mock("@/lib/facts-drift", () => ({ runFactsDrift: vi.fn() }));
vi.mock("@/lib/content-watch", () => ({ runLinkCheck: vi.fn(), runSemanticDrift: vi.fn() }));
import { GET } from "@/app/api/cron/route";
function request(secret = "wrong", ip = "127.0.0.1") {
return new Request("https://codewhale.net/api/cron?task=nope", {
headers: { "x-cron-secret": secret, "CF-Connecting-IP": ip },
});
}
beforeEach(() => {
vi.resetAllMocks();
mocks.getEnv.mockResolvedValue({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef", ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
mocks.limit.mockResolvedValue({ success: true });
mocks.safeEqual.mockResolvedValue(false);
});
describe("manual cron trigger attempt boundary", () => {
it("bounds repeated guesses independently of the secret and forged IP", async () => {
let attempts = 0;
mocks.limit.mockImplementation(async () => ({ success: ++attempts <= 5 }));
for (let index = 0; index < 7; index++) {
const response = await GET(request(`guess-${index}`, `192.0.2.${index}`));
expect(response.status).toBe(index < 5 ? 401 : 429);
if (index >= 5) expect(response.headers.get("Retry-After")).toBe("60");
expect(response.headers.get("Cache-Control")).toBe("no-store");
}
expect(mocks.safeEqual).toHaveBeenCalledTimes(5);
expect(mocks.limit.mock.calls.map(([options]) => options.key)).toEqual(Array(7).fill("codewhale-web:cron-secret"));
});
it("does not compare the secret after refusal", async () => {
mocks.limit.mockResolvedValue({ success: false });
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(429);
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("fails closed if the binding is missing or unavailable", async () => {
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef" });
expect((await GET(request())).status).toBe(503);
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
expect((await GET(request())).status).toBe(503);
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("stays disabled without a configured secret and never consults the limiter", async () => {
mocks.getEnv.mockResolvedValue({ ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
expect((await GET(request())).status).toBe(503);
expect(mocks.limit).not.toHaveBeenCalled();
});
it("stays disabled for a secret below the minimum strength and never consults the limiter or compares", async () => {
const loneSurrogates = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
const weak = ["x", "a".repeat(32), " ".repeat(40), "password".repeat(4), "Password1".repeat(4), "\u{1F433}".repeat(16), "0123456".repeat(5), "L".repeat(600), loneSurrogates];
for (const secret of weak) {
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: secret, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
const response = await GET(request());
expect(response.status).toBe(503);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(JSON.stringify(await response.json())).toContain("at least 32 characters");
}
expect(mocks.limit).not.toHaveBeenCalled();
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
mocks.safeEqual.mockResolvedValue(true);
expect((await GET(request(hex))).status).toBe(400);
});
it("still reaches task validation after an allowed, correct attempt", async () => {
mocks.safeEqual.mockResolvedValue(true);
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(400);
});
});
-1
View File
@@ -32,7 +32,6 @@ export async function readBoundedKvJson(kv: KVStreamNamespace | undefined, key:
interface CloudflareEnv extends MerchEnv {
DRAFT_CLAIM_LOCK?: DraftClaimLockNamespace;
CURATED_KV?: KVNamespace & KVStreamNamespace;
ADMIN_LOGIN_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_SITE_ORIGIN?: string;
DEEPSEEK_API_KEY?: string;
-25
View File
@@ -1,25 +0,0 @@
export const MIN_SECRET_CHARS = 32;
export const MAX_SECRET_CHARS = 512;
export const MIN_SECRET_DISTINCT_CHARS = 8;
const LONE_SURROGATE = /\p{Cs}/u;
export const MAINTAINER_TOKEN_UNAVAILABLE =
`Maintainer sign-in is unavailable: MAINTAINER_TOKEN must be set to a secret of at least ${MIN_SECRET_CHARS} characters ` +
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused).`;
export const CRON_SECRET_UNAVAILABLE =
`manual trigger disabled: CRON_SECRET must be a secret of at least ${MIN_SECRET_CHARS} characters ` +
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused)`;
export function secretStrongEnough(value: string | undefined): value is string {
if (typeof value !== "string" || value.length > MAX_SECRET_CHARS || LONE_SURROGATE.test(value)) return false;
const chars = [...value];
if (chars.length < MIN_SECRET_CHARS || new Set(chars).size < MIN_SECRET_DISTINCT_CHARS) return false;
for (let period = 1; period <= value.length / 2; period++) {
if (value.slice(period) === value.slice(0, value.length - period)) return false;
}
return true;
}
export const maintainerTokenUsable = secretStrongEnough;
+2 -8
View File
@@ -155,8 +155,8 @@ describe("durable merch interest registration", () => {
describe("private merch interest list and deletion", () => {
async function signedIn(): Promise<{ auth: CommunityAgentEnv; headers: Record<string, string> }> {
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij" };
const sid = await createSession(auth.CURATED_KV, auth.MAINTAINER_TOKEN);
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-maintainer-fixture" };
const sid = await createSession(auth.CURATED_KV);
return { auth, headers: { Cookie: "mt_sid=" + sid } };
}
it("requires a real stored maintainer session for reading or deleting contacts", async () => {
@@ -165,12 +165,6 @@ describe("private merch interest list and deletion", () => {
expect(list).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
it.each(["short", "rotated-fixture-token-9876543210-zyxwvutsrq"])("refuses an existing session once the configured maintainer token changes to %s", async (replacement) => {
const { auth, headers } = await signedIn();
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest", { headers }), env, { ...auth, MAINTAINER_TOKEN: replacement });
expect(response.status).toBe(401);
expect(list).not.toHaveBeenCalled();
});
it("paginates only the private prefix and deletes an opaque id with same-origin CSRF", async () => {
await handleMerchInterest(request(), env);
await handleMerchInterest(request({ ...input(), email: "[email protected]" }), env);
+1 -1
View File
@@ -138,7 +138,7 @@ export async function handleMerchInterest(request: Request, env?: MerchInterestE
}
async function requireAdmin(request: Request, auth: CommunityAgentEnv) {
const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7);
if (!await validateSession(auth.CURATED_KV, sid, auth.MAINTAINER_TOKEN)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
}
export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> {
try {
+2 -2
View File
@@ -73,7 +73,7 @@ describe("public security boundaries", () => {
kv.values.set("agent:state:latest", JSON.stringify({ state: "running" }));
securityMocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
MAINTAINER_TOKEN: "configured",
});
const response = await adminPost(new Request("https://codewhale.net/api/admin/post", {
@@ -121,7 +121,7 @@ describe("public security boundaries", () => {
securityMocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
MAINTAINER_TOKEN: "configured",
MAINTAINER_GITHUB_PAT: "ghp_test",
GITHUB_REPO: "codewhale-hq/CodeWhale",
});
+16 -2
View File
@@ -85,12 +85,26 @@ download() {
url="$1"
out="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$out"
download_tool=curl
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$out"
download_tool=wget
else
fail "curl or wget is required"
fi
attempt=1
while :; do
if [ "$download_tool" = curl ]; then
curl -fsSL "$url" -o "$out" && return 0
else
wget -q "$url" -O "$out" && return 0
fi
rm -f "$out"
if [ "$attempt" -ge 3 ]; then
fail "Download failed. This is usually a network blip; run the same command again."
fi
attempt=$((attempt + 1))
sleep "${CODEWHALE_INSTALL_RETRY_DELAY:-1}"
done
}
sha256_file() {