mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 04:29:24 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1d13c1d4 | ||
|
|
bf43019b24 | ||
|
|
de339044aa | ||
|
|
203b54986d | ||
|
|
0b643a7340 | ||
|
|
e14b4393f8 | ||
|
|
c7cf758da4 | ||
|
|
2f31fe1900 | ||
|
|
cfc6182f03 | ||
|
|
85e9cff918 |
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "मास्किंग बनाए रखें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "秘匿化を維持",
|
||||
|
||||
@@ -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": "마스킹 유지",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "оставить маскирование",
|
||||
|
||||
@@ -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": "залишити маскування",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "保持脱敏开启",
|
||||
|
||||
@@ -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": "保持脫敏開啟",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 审计说明
|
||||
|
||||
|
||||
@@ -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. 发一条测试消息,能正常回复就说明切换成功
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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"} />;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user