From ed7ab36d38700c0af0e28f18495d3cee60c34769 Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Mon, 12 Oct 2026 10:39:30 +0700 Subject: [PATCH 1/3] fix(security): keep Claude tokens on sanctioned routes; drop the 9router Claude proxy - harness claude-code: exact-host allowlist (api.anthropic.com, Bedrock, Vertex, Foundry, loopback, EIGHTSYNC_SELF_HOSTED_GATEWAY), https except loopback, checked before any write; subscription/OAuth tokens only for api.anthropic.com; omp and Windows env get Console keys (sk-ant-api) only; token masked to last 4; probe uses x-api-key + anthropic-version via stdin - harness gateway: remove the cc/ provider that made omp pose as Claude Code, strip deployed 9router-cc blocks, provider-scoped key read/write, refuse sk-ant- keys, keep other providers, opt-in EIGHTSYNC_GATEWAY_ACCEPT=1 - harness add-model: refuse non-Console Anthropic tokens and Claude-protocol providers outside the allowlist; re-check registry entries on sync - forge: Console key only for anthropic, refuse claude_code provider, warn on existing claude_code OAuth credential, credentials file 0600 - codex-web: opt-in (EIGHTSYNC_CODEX_WEB_ACCEPT=1) - feynman: bridge skips every Claude/Anthropic model; find Pi packages under @earendil-works/* then @mariozechner/* (Feynman >= 0.5.35) - Claude 5.5 model IDs; setup-claude-key.{sh,ps1} only forward to `8sync harness claude-code` on stdin Co-Authored-By: Claude Opus 5.5 --- assets/configs/omp/gateway-models.yml | 62 +-- assets/extensions/feynman-omp.mjs | 11 +- crates/cli/src/verbs/codex_web.rs | 41 +- crates/cli/src/verbs/feynman.rs | 73 ++- crates/cli/src/verbs/forge.rs | 109 +++- crates/cli/src/verbs/harness/claude_code.rs | 337 +++++++++++- crates/cli/src/verbs/harness/custom_model.rs | 131 ++++- crates/cli/src/verbs/harness/gateway.rs | 536 ++++++++++++++++--- crates/cli/src/verbs/harness/mod.rs | 4 +- crates/cli/src/verbs/harness/model.rs | 14 +- scripts/setup-claude-key.ps1 | 99 +--- scripts/setup-claude-key.sh | 15 +- 12 files changed, 1141 insertions(+), 291 deletions(-) diff --git a/assets/configs/omp/gateway-models.yml b/assets/configs/omp/gateway-models.yml index 2593345..0e7dba0 100644 --- a/assets/configs/omp/gateway-models.yml +++ b/assets/configs/omp/gateway-models.yml @@ -1,67 +1,13 @@ # 9router gateway (~/.omp/agent/models.yml) — deployed by `8sync harness gateway apply`. # -# Claude = cc/ prefix (Anthropic Messages API); GPT/Codex = cx/ prefix (OpenAI API). -# omp masquerades as claude-cli so the cc/ prefix routes correctly through the proxy. -# -# IMPORTANT: Claude models use thinking.mode = anthropic-budget-effort so omp sends -# `thinking:{type:enabled,budget_tokens}`. The gateway REJECTS `type:adaptive` with -# "thinking.enabled.budget_tokens: Field required" (notably claude-sonnet-5). Do NOT -# change this back to adaptive. Efforts are minimal..high (NO xhigh). +# GPT/Codex = cx/ prefix (OpenAI API). No Claude provider here: Claude in omp goes only +# through a Console API key (`8sync harness claude-code`), never a third-party proxy. +# OPT-IN: routes a ChatGPT/Codex subscription through a third-party router (OpenAI terms +# not verified) — requires EIGHTSYNC_GATEWAY_ACCEPT=1. # # `__NINE_ROUTER_KEY__` is substituted at apply time from $NINE_ROUTER_KEY (or the # previously-deployed key). Edit modelRoles routing via `8sync harness model`, NOT here. providers: - 9router-cc: - baseUrl: https://9router.cloudgo.vn/v1 - apiKey: __NINE_ROUTER_KEY__ - api: anthropic-messages - authHeader: true - models: - - id: cc/claude-opus-4-8 - name: Claude Opus 4.8 (9router) - reasoning: true - input: [text, image] - contextWindow: 1000000 - maxTokens: 32000 - cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} - thinking: - mode: anthropic-budget-effort - efforts: [minimal, low, medium, high] - defaultLevel: high - - id: cc/claude-sonnet-5 - name: Claude Sonnet 5 (9router) - reasoning: true - input: [text, image] - contextWindow: 1000000 - maxTokens: 128000 - cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} - thinking: - mode: anthropic-budget-effort - efforts: [minimal, low, medium, high] - defaultLevel: high - - id: cc/claude-sonnet-4-6 - name: Claude Sonnet 4.6 (9router) - reasoning: true - input: [text, image] - contextWindow: 1000000 - maxTokens: 64000 - cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} - thinking: - mode: anthropic-budget-effort - efforts: [minimal, low, medium, high] - defaultLevel: high - - id: cc/claude-haiku-4-5-20251001 - name: Claude Haiku 4.5 (9router) - reasoning: true - input: [text, image] - contextWindow: 200000 - maxTokens: 64000 - cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} - thinking: - mode: anthropic-budget-effort - efforts: [minimal, low, medium, high] - defaultLevel: minimal - 9router-cx: baseUrl: https://9router.cloudgo.vn/v1 apiKey: __NINE_ROUTER_KEY__ diff --git a/assets/extensions/feynman-omp.mjs b/assets/extensions/feynman-omp.mjs index 866f176..4a49911 100644 --- a/assets/extensions/feynman-omp.mjs +++ b/assets/extensions/feynman-omp.mjs @@ -9,8 +9,15 @@ import { pathToFileURL } from 'node:url'; export default async function (pi) { const managed = JSON.parse(readFileSync(process.env.EIGHTSYNC_FEYNMAN_BRIDGE ?? new URL('../.8sync-omp.json', import.meta.url), 'utf8')); - const aiEntry = process.env.EIGHTSYNC_FEYNMAN_AI - ?? createRequire(process.env.FEYNMAN_BIN_PATH).resolve('@mariozechner/pi-ai'); + const resolveAi = () => { + const req = createRequire(process.env.FEYNMAN_BIN_PATH); + let err; + for (const spec of ['@earendil-works/pi-ai', '@mariozechner/pi-ai']) { + try { return req.resolve(spec); } catch (e) { err ??= e; } + } + throw err; + }; + const aiEntry = process.env.EIGHTSYNC_FEYNMAN_AI ?? resolveAi(); const { createAssistantMessageEventStream } = await import(pathToFileURL(aiEntry).href); const ompBin = process.env.EIGHTSYNC_OMP_BIN ?? managed.ompBin; let worker; diff --git a/crates/cli/src/verbs/codex_web.rs b/crates/cli/src/verbs/codex_web.rs index af32ff5..d2d7247 100644 --- a/crates/cli/src/verbs/codex_web.rs +++ b/crates/cli/src/verbs/codex_web.rs @@ -9,6 +9,9 @@ //! copied (the bridge is a same-user loopback listener). Browser automation of ChatGPT //! still carries the account-risk the upstream project documents; that is inherent. //! +//! OPT-IN: all actions except `status` (probe only, no wiring) and `off` are refused unless +//! `EIGHTSYNC_CODEX_WEB_ACCEPT=1` (see [`opted_in`]). +//! //! Actions (smart first positional): //! - `up` / `login` / `open` : launch the launcher, then poll until you finish login, then wire. //! - (none) / `status` : probe the server + report; auto-wire if it is ready and unwired. @@ -38,6 +41,12 @@ EXAMPLES 8sync codex-web wire (re)detect models and write the omp provider 8sync codex-web off remove the chatgpt-web provider from omp +OPT-IN (REQUIRED) + Everything except `status` / `off` is refused unless EIGHTSYNC_CODEX_WEB_ACCEPT=1 is set. + This browser-automates a ChatGPT subscription; OpenAI's terms are not verified and it + may violate them. 8 Sync rules forbid routing subscriptions into third-party clients. + Opting in is at your own risk. + USE CASE Use your ChatGPT Web subscription (incl. Pro) as a model inside omp — no API key, no Codex. The launcher opens a private browser profile for YOUR login; the local @@ -331,9 +340,23 @@ fn print_usage_hint(ids: &[String]) { } } +/// True only when the opt-in env var is exactly "1". +fn opted_in(env_value: Option<&str>) -> bool { + env_value == Some("1") +} + pub fn run(a: Args) -> Result<()> { let action = a.action.as_deref().unwrap_or("status").to_lowercase(); let port = a.port; + let accepted = opted_in(std::env::var("EIGHTSYNC_CODEX_WEB_ACCEPT").ok().as_deref()); + let read_only = matches!(action.as_str(), "status" | "" | "off" | "unwire" | "remove"); + if !accepted && !read_only { + ui::warn("codex-web is OPT-IN and was NOT run."); + ui::info("It browser-automates a ChatGPT Web subscription into a third-party client (omp) and installs a launcher from a third-party repo."); + ui::info("This may violate OpenAI's terms of use (not verified), and 8 Sync's rules forbid routing subscriptions into third-party clients."); + ui::info("Prefer an API key. To proceed at your own risk: EIGHTSYNC_CODEX_WEB_ACCEPT=1 8sync codex-web "); + bail!("codex-web requires EIGHTSYNC_CODEX_WEB_ACCEPT=1 (opt-in, at your own risk)"); + } match action.as_str() { "off" | "unwire" | "remove" => unwire_omp(), @@ -386,9 +409,12 @@ pub fn run(a: Args) -> Result<()> { for id in &ids { ui::info(&format!(" {id}")); } - // Auto-wire for convenience (the whole point: cho lẹ). - wire_omp(port, &ids)?; - print_usage_hint(&ids); + if accepted { + wire_omp(port, &ids)?; + print_usage_hint(&ids); + } else { + ui::info("not wiring omp: set EIGHTSYNC_CODEX_WEB_ACCEPT=1 to opt in (see --help)"); + } } Probe::UpNoModels => { ui::warn(&format!("bridge up on :{port} but NOT signed in / no models")); @@ -410,6 +436,15 @@ pub fn run(a: Args) -> Result<()> { mod tests { use super::*; + #[test] + fn opted_in_only_for_exactly_one() { + assert!(opted_in(Some("1"))); + assert!(!opted_in(None)); + assert!(!opted_in(Some(""))); + assert!(!opted_in(Some("0"))); + assert!(!opted_in(Some("true"))); + } + #[test] fn extract_ids_reads_openai_models_shape() { let j = serde_json::json!({ diff --git a/crates/cli/src/verbs/feynman.rs b/crates/cli/src/verbs/feynman.rs index cdead05..74c627a 100644 --- a/crates/cli/src/verbs/feynman.rs +++ b/crates/cli/src/verbs/feynman.rs @@ -115,6 +115,15 @@ fn pi_model(row: &Value) -> Result { Ok(Value::Object(model)) } +/// Claude models (subscription, gateway, legacy `9router-cc` proxy) never enter the bridge. +fn is_claude_model(provider: &str, id: &str, api: &str, base_url: &str) -> bool { + provider == "anthropic" + || provider == "9router-cc" + || id.to_ascii_lowercase().contains("claude") + || matches!(api, "anthropic" | "anthropic-messages") + || base_url.to_ascii_lowercase().contains("://api.anthropic.com") +} + fn bash_escape(value: &str) -> std::borrow::Cow<'_, str> { if value.contains('\'') { std::borrow::Cow::Owned(value.replace('\'', "'\\''")) @@ -152,7 +161,10 @@ fn auth_omp(agent_dir: &Path) -> Result<()> { // terms keep a subscription inside Claude Code / claude.ai; other tools // use a Console API key, which Feynman reads itself from ANTHROPIC_API_KEY. let id = row.get("id").and_then(Value::as_str).unwrap_or_default(); - if provider == "anthropic" || id.starts_with("claude") { + // `omp models --json` (18.9) has no `api`/`baseUrl` per row; checked when present. + let api = row.get("api").and_then(Value::as_str).unwrap_or_default(); + let base_url = row.get("baseUrl").and_then(Value::as_str).unwrap_or_default(); + if is_claude_model(provider, id, api, base_url) { skipped_claude = true; continue; } @@ -451,10 +463,13 @@ fn ensure_feynman_windows_esm_patch(feynman_bin: &Path) { } // Feynman's Pi 0.62 key parser drops modifiers it cannot detect, so pi-btw's // default `super+/` shortcut matched a bare "/" and swallowed every slash command. - let tui_keys = [ - bin_dir.join("node_modules/@companion-ai/feynman/node_modules/@mariozechner/pi-tui/dist/keys.js"), - bin_dir.join("node_modules/@companion-ai/feynman/.feynman/npm/node_modules/@mariozechner/pi-tui/dist/keys.js"), - ]; + let tui_keys: Vec = [ + bin_dir.join("node_modules/@companion-ai/feynman/node_modules"), + bin_dir.join("node_modules/@companion-ai/feynman/.feynman/npm/node_modules"), + ] + .iter() + .flat_map(|root| pi_package_candidates(root, "pi-tui", "keys.js")) + .collect(); let target = " if (!key)\n return null;\n return {\n key,\n ctrl: parts.includes(\"ctrl\"),"; let replacement = " if (!key)\n return null;\n if (parts.slice(0, -1).some((part) => part !== \"ctrl\" && part !== \"shift\" && part !== \"alt\"))\n return null;\n return {\n key,\n ctrl: parts.includes(\"ctrl\"),"; for p in tui_keys { @@ -466,10 +481,13 @@ fn ensure_feynman_windows_esm_patch(feynman_bin: &Path) { } // Repair only the truncating renderer variant; older Pi copies deliberately // throw on overflow and must retain that behavior. - let tui_renderers = [ - bin_dir.join("node_modules/@companion-ai/feynman/node_modules/@mariozechner/pi-tui/dist/tui.js"), - bin_dir.join("node_modules/@companion-ai/feynman/.feynman/npm/node_modules/@mariozechner/pi-tui/dist/tui.js"), - ]; + let tui_renderers: Vec = [ + bin_dir.join("node_modules/@companion-ai/feynman/node_modules"), + bin_dir.join("node_modules/@companion-ai/feynman/.feynman/npm/node_modules"), + ] + .iter() + .flat_map(|root| pi_package_candidates(root, "pi-tui", "tui.js")) + .collect(); let immutable_overflow = "const line = newLines[i];\n const isImage = isImageLine(line);\n if (!isImage && visibleWidth(line) > width) {\n line = truncateToWidth(line, width);"; let mutable_overflow = "let line = newLines[i];\n const isImage = isImageLine(line);\n if (!isImage && visibleWidth(line) > width) {\n line = truncateToWidth(line, width);"; for p in tui_renderers { @@ -513,16 +531,26 @@ fn get_feynman_runner(feynman_bin: &Path) -> (PathBuf, Vec) { } } +/// Upstream Pi packages moved `@mariozechner/*` -> `@earendil-works/*`; try the new scope first. +fn pi_package_candidates(node_modules: &Path, pkg: &str, file: &str) -> Vec { + ["@earendil-works", "@mariozechner"] + .iter() + .map(|scope| node_modules.join(scope).join(pkg).join("dist").join(file)) + .collect() +} + fn configure_omp_bridge(cmd: &mut Command, feynman_bin: &Path) -> Result<()> { let home = dirs::home_dir().context("cannot resolve home directory")?; let agent_dir = home.join(".feynman/agent"); let canonical = std::fs::canonicalize(feynman_bin).unwrap_or_else(|_| feynman_bin.to_path_buf()); let mut candidates = Vec::new(); if let Some(parent) = feynman_bin.parent() { - candidates.push(parent.join("node_modules/@companion-ai/feynman/node_modules/@mariozechner/pi-ai/dist/index.js")); + candidates.extend(pi_package_candidates( + &parent.join("node_modules/@companion-ai/feynman/node_modules"), "pi-ai", "index.js")); } if let Some(parent) = canonical.parent().and_then(Path::parent) { - candidates.push(parent.join("node_modules/@mariozechner/pi-ai/dist/index.js")); + candidates.extend(pi_package_candidates(&parent.join("node_modules"), "pi-ai", "index.js")); + candidates.extend(pi_package_candidates(&parent.join("app/node_modules"), "pi-ai", "index.js")); } let ai_entry = candidates.into_iter().find(|p| p.exists()) .context("cannot locate Feynman's pi-ai package for the omp bridge")?; @@ -759,6 +787,29 @@ fn set_600(p: &Path) { #[cfg(test)] mod tests { + #[test] + fn claude_models_are_excluded_from_bridge() { + assert!(is_claude_model("anthropic", "x", "", "")); + assert!(is_claude_model("9router-cc", "x", "", "")); + assert!(is_claude_model("9router-cx", "cc/claude-sonnet", "", "")); + assert!(is_claude_model("openai", "anthropic/Claude-opus", "", "")); + assert!(is_claude_model("x", "claude-3", "", "")); + assert!(!is_claude_model("9router-cx", "cx/gpt-5.5", "", "")); + assert!(is_claude_model("x", "m", "anthropic-messages", "")); + assert!(is_claude_model("x", "m", "anthropic", "")); + assert!(is_claude_model("x", "m", "", "https://api.anthropic.com/v1")); + assert!(!is_claude_model("x", "m", "openai-completions", "https://api.x.ai/v1")); + } + + #[test] + fn pi_package_candidates_prefer_earendil_then_mariozechner() { + let c = pi_package_candidates(Path::new("/x/node_modules"), "pi-ai", "index.js"); + assert_eq!(c, vec![ + PathBuf::from("/x/node_modules/@earendil-works/pi-ai/dist/index.js"), + PathBuf::from("/x/node_modules/@mariozechner/pi-ai/dist/index.js"), + ]); + } + use super::*; #[test] diff --git a/crates/cli/src/verbs/forge.rs b/crates/cli/src/verbs/forge.rs index d50d61e..6c80f04 100644 --- a/crates/cli/src/verbs/forge.rs +++ b/crates/cli/src/verbs/forge.rs @@ -686,10 +686,49 @@ fn sessions(env: &Env, root: &Path) -> Store { } fn mask(key: &str) -> String { - match (key.get(..8), key.len().checked_sub(6).and_then(|i| key.get(i..))) { - (Some(head), Some(tail)) if key.len() > 14 => format!("{head}...{tail}"), - _ => "***".to_string(), + super::harness::claude_code::mask_token(key) +} + +/// Write a secrets file readable only by the owner (0600 on unix, also tightening an existing file). +fn write_private(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + f.write_all(data) + } + #[cfg(not(unix))] + { + std::fs::write(path, data) + } +} + +/// Map `key` args to (provider, key). Anthropic needs a Console API key: Claude subscription +/// (OAuth) tokens stay in Claude Code (Anthropic Usage Policy / Commercial Terms), and Forge's +/// `claude_code` provider reuses Claude Code's OAuth client, so it is refused outright. +fn resolve_key(rest: &[String]) -> Result<(String, String)> { + let (provider, key) = match rest { + [key] if key.starts_with("sk-ant") => ("anthropic".to_string(), key.clone()), + [key] if key.starts_with("sk-or-") => ("open_router".to_string(), key.clone()), + [key] if key.starts_with("sk-") => ("openai".to_string(), key.clone()), + [_] => bail!("cannot infer the provider from this key — use: 8sync forge key "), + [provider, key, ..] => (provider.to_lowercase(), key.clone()), + [] => bail!("usage: 8sync forge key "), + }; + if provider == "claude_code" { + bail!("refused: Forge's `claude_code` provider impersonates Claude Code's OAuth client, which Anthropic's Usage Policy forbids. Use provider `anthropic` with a Console API key."); + } + if provider == "anthropic" && !super::harness::claude_code::is_console_key(&key) { + bail!("refused: Forge needs an Anthropic Console API key (sk-ant-api…). Claude subscription logins (sk-ant-oat…) stay in Claude Code only — see Anthropic Usage Policy / Commercial Terms."); } + Ok((provider, key)) +} + +/// True when a `claude_code` (OAuth) credential exists. +fn has_claude_code_cred(creds: &[serde_json::Value]) -> bool { + creds.iter().any(|c| c.get("id").and_then(|v| v.as_str()) == Some("claude_code")) } /// Store a provider API key in Forge's credential file (`~/.forge/.credentials.json`), @@ -713,17 +752,14 @@ fn cmd_key(rest: &[String]) -> Result<()> { let key = item.pointer("/auth_details/api_key").and_then(|k| k.as_str()); ui::ok(&format!("{id}: {}", key.map(mask).unwrap_or_else(|| "(oauth)".into()))); } + if has_claude_code_cred(&credentials) { + ui::warn("a `claude_code` (OAuth) credential is stored: it uses a Claude subscription through a third-party client, which Anthropic's terms and 8 Sync rules do not allow."); + ui::info(&format!("remove it: delete the entry with \"id\": \"claude_code\" from {} (not deleted automatically), then use `8sync forge key anthropic `", cred_file.display())); + } return Ok(()); } - let (provider, key) = match rest { - [key] if key.starts_with("sk-ant") => ("anthropic".to_string(), key.clone()), - [key] if key.starts_with("sk-or-") => ("open_router".to_string(), key.clone()), - [key] if key.starts_with("sk-") => ("openai".to_string(), key.clone()), - [_] => bail!("cannot infer the provider from this key — use: 8sync forge key "), - [provider, key, ..] => (provider.to_lowercase(), key.clone()), - [] => unreachable!(), - }; + let (provider, key) = resolve_key(rest)?; let entry = serde_json::json!({ "id": provider, "auth_details": { "api_key": key } }); match credentials.iter_mut().find(|c| c.get("id").and_then(|v| v.as_str()) == Some(&provider)) { @@ -731,7 +767,7 @@ fn cmd_key(rest: &[String]) -> Result<()> { None => credentials.push(entry), } std::fs::create_dir_all(&forge_dir)?; - std::fs::write(&cred_file, serde_json::to_string_pretty(&credentials)?).context("write .credentials.json")?; + write_private(&cred_file, serde_json::to_string_pretty(&credentials)?.as_bytes()).context("write .credentials.json")?; ui::ok(&format!("set {provider} API key ({}) → {}", mask(&key), cred_file.display())); ui::info("pick the model inside the shell with `:model` (or `8sync forge --tui`)"); Ok(()) @@ -741,11 +777,54 @@ fn cmd_key(rest: &[String]) -> Result<()> { mod tests { use super::{mask, shell_path}; + #[cfg(unix)] + #[test] + fn credentials_file_is_0600_even_if_preexisting() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("8sync-forge-cred-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join(".credentials.json"); + std::fs::write(&f, b"old").unwrap(); + std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap(); + super::write_private(&f, b"[]").unwrap(); + assert_eq!(std::fs::metadata(&f).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::read(&f).unwrap(), b"[]"); + let f2 = dir.join("new.json"); + super::write_private(&f2, b"x").unwrap(); + assert_eq!(std::fs::metadata(&f2).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn keys_never_print_in_full() { - assert_eq!(mask("sk-ant-usr-0123456789abcdef"), "sk-ant-u...abcdef"); - assert_eq!(mask("short-key"), "***"); - assert_eq!(mask("sk-ant-é0123456789"), "***"); // non-ASCII boundary: no panic + assert_eq!(mask("sk-ant-api03-abcd"), "…abcd"); + assert_eq!(mask("ab"), "…"); + assert_eq!(mask("sk-ant-é0123456789"), "…6789"); // non-ASCII: no panic + } + + fn v(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn anthropic_requires_console_key() { + assert_eq!(super::resolve_key(&v(&["sk-ant-api03-test"])).unwrap().0, "anthropic"); + assert!(super::resolve_key(&v(&["anthropic", "sk-ant-api03-test"])).is_ok()); + assert!(super::resolve_key(&v(&["sk-ant-oat01-test"])).is_err()); + assert!(super::resolve_key(&v(&["anthropic", "sk-ant-oat01-test"])).is_err()); + } + + #[test] + fn detects_claude_code_oauth_cred() { + let c = vec![serde_json::json!({"id":"claude_code"}), serde_json::json!({"id":"anthropic"})]; + assert!(super::has_claude_code_cred(&c)); + assert!(!super::has_claude_code_cred(&c[1..])); + } + + #[test] + fn claude_code_provider_refused() { + assert!(super::resolve_key(&v(&["claude_code", "sk-ant-api03-test"])).is_err()); + assert!(super::resolve_key(&v(&["Claude_Code", "x"])).is_err()); } #[test] diff --git a/crates/cli/src/verbs/harness/claude_code.rs b/crates/cli/src/verbs/harness/claude_code.rs index 722eac8..a521823 100644 --- a/crates/cli/src/verbs/harness/claude_code.rs +++ b/crates/cli/src/verbs/harness/claude_code.rs @@ -17,9 +17,12 @@ //! 1. Parses settings JSON (supports full schema, flat env map, or CLI flags). //! 2. Merges the env keys into `~/.claude/settings.json` (any endpoint: Claude Code's own LLM-gateway support). //! 3. On Windows: sets User persistent environment variables (official endpoint only). -//! 4. Official endpoint only: injects the 9 latest Claude models into `~/.omp/agent/models.yml` +//! 4. Official endpoint only: injects the Claude 5.5 family (opus/sonnet/haiku 5.5, fable 5.1) plus older models into `~/.omp/agent/models.yml` //! under `anthropic`, with `anthropic-budget-effort` thinking and 1M context, and -//! 5. sets omp's default role to `anthropic/claude-fable-5-1:high`, advisor `anthropic/claude-opus-5:high`. +//! 5. sets omp's default role to `anthropic/claude-fable-5-1:high`, advisor `anthropic/claude-opus-5-5:high`. +//! The omp write needs a Console API key (`sk-ant-api…`); OAuth/subscription tokens stay in Claude Code. +//! Base URLs outside an allowlist (Anthropic, Bedrock, Vertex, Foundry, localhost) are refused; +//! https is required except for localhost. //! A third-party gateway never reaches omp: Anthropic's Usage Policy (8sync rules) lets tools //! other than Claude Code use Claude only through a Console API key. //! 6. Verifies connection with a live probe. @@ -125,6 +128,95 @@ pub fn parse_claude_settings(raw: &str) -> Result { }) } +/// Last 4 chars only (`…abcd`); never the prefix. +pub fn mask_token(token: &str) -> String { + let n = token.chars().count(); + if n <= 4 { + return "…".to_string(); + } + format!("…{}", token.chars().skip(n - 4).collect::()) +} + +/// Anthropic Console API keys start with `sk-ant-api`; OAuth/subscription tokens do not. +pub fn is_console_key(token: &str) -> bool { + token.starts_with("sk-ant-api") +} + +/// Lowercased host of a base URL: scheme, userinfo, port and path stripped. +pub fn base_url_host(base_url: &str) -> Option { + let rest = base_url.trim(); + let rest = rest.split_once("://").map_or(rest, |(_, r)| r); + let auth = rest.split(['/', '?', '#']).next().unwrap_or(""); + let auth = auth.rsplit('@').next().unwrap_or(""); + let host = if let Some(r) = auth.strip_prefix('[') { + r.split(']').next().unwrap_or("") + } else { + auth.split(':').next().unwrap_or("") + }; + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() { None } else { Some(host) } +} + +fn host_allowed_builtin(host: &str) -> bool { + let label_ok = |l: &str| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + // exactly one region label between prefix and suffix + let one_label = |h: &str, pre: &str, suf: &str| { + h.strip_prefix(pre).and_then(|r| r.strip_suffix(suf)).is_some_and(label_ok) + }; + host == "api.anthropic.com" + || matches!(host, "localhost" | "127.0.0.1" | "::1") + || one_label(host, "bedrock-runtime.", ".amazonaws.com") + || host == "aiplatform.googleapis.com" + || host.strip_suffix("-aiplatform.googleapis.com").is_some_and(label_ok) + || host.strip_suffix(".services.ai.azure.com").is_some_and(label_ok) +} + +fn is_loopback(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1") +} + +/// `https://` is required for every host; plain `http://` only for loopback. +pub fn scheme_ok(base_url: &str, host: &str) -> bool { + let u = base_url.trim().to_ascii_lowercase(); + u.starts_with("https://") || (is_loopback(host) && u.starts_with("http://")) +} + +/// Whether the flow may write Claude credentials into omp's models.yml: +/// official host (exact) AND Console API key AND https. +pub fn omp_write_allowed(base_url: &str, token: &str) -> bool { + base_url_host(base_url).as_deref() == Some("api.anthropic.com") + && is_console_key(token) + && base_url.trim().to_ascii_lowercase().starts_with("https://") +} + +/// A non-Console `sk-ant-` token (subscription/OAuth) is only valid for Claude +/// Code talking to api.anthropic.com itself; never to localhost or a self-hosted proxy. +pub fn token_allowed_for_host(base_url: &str, token: &str) -> bool { + let t = token.trim(); + !t.starts_with("sk-ant-") || is_console_key(t) || base_url_host(base_url).as_deref() == Some("api.anthropic.com") +} + +/// Token safe to embed in a PowerShell command: `^[A-Za-z0-9_-]+$`. +#[cfg_attr(not(windows), allow(dead_code))] +pub fn persist_value_safe(v: &str) -> bool { + !v.is_empty() && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Allowlist for `ANTHROPIC_BASE_URL`. Escape hatch for a self-operated gateway: +/// env `EIGHTSYNC_SELF_HOSTED_GATEWAY=` must equal the URL host exactly. +pub fn gateway_allowed(base_url: &str) -> bool { + let Some(host) = base_url_host(base_url) else { return false }; + if !scheme_ok(base_url, &host) { + return false; + } + if host_allowed_builtin(&host) { + return true; + } + std::env::var("EIGHTSYNC_SELF_HOSTED_GATEWAY") + .map(|v| !v.trim().is_empty() && v.trim().to_ascii_lowercase() == host) + .unwrap_or(false) +} + /// Derive a friendly provider name from base URL (e.g. "https://api.apikey.fun" → "apikey-fun") pub fn provider_slug(base_url: &str) -> String { let clean = base_url @@ -146,8 +238,41 @@ pub fn provider_slug(base_url: &str) -> String { } } -/// Canonical modern Claude model catalog (the 9 current models). -pub const CLAUDE_MODELS_YAML: &str = r#" - id: claude-opus-5 +/// Canonical modern Claude model catalog (Claude 5.5 family first, then older models). +pub const CLAUDE_MODELS_YAML: &str = r#" - id: claude-opus-5-5 + name: Claude Opus 5.5 + reasoning: true + input: [text, image] + contextWindow: 1000000 # not verified + maxTokens: 128000 + cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} + thinking: + mode: anthropic-budget-effort + efforts: [low, medium, high, xhigh, max] + defaultLevel: medium + - id: claude-sonnet-5-5 + name: Claude Sonnet 5.5 + reasoning: true + input: [text, image] + contextWindow: 1000000 # not verified + maxTokens: 128000 + cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} + thinking: + mode: anthropic-budget-effort + efforts: [low, medium, high, xhigh, max] + defaultLevel: medium + - id: claude-haiku-5-5 + name: Claude Haiku 5.5 + reasoning: true + input: [text, image] + contextWindow: 200000 # not verified + maxTokens: 64000 + cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} + thinking: + mode: anthropic-budget-effort + efforts: [minimal, low, medium, high, xhigh] + defaultLevel: minimal + - id: claude-opus-5 name: Claude Opus 5 reasoning: true input: [text, image] @@ -294,9 +419,28 @@ pub fn harness_claude_code(env: &env_detect::Env, args: &[String]) -> Result<()> }; let slug = provider_slug(base_url); + if !gateway_allowed(base_url) { + bail!( + "refusing base URL host {:?}: not on the allowlist (api.anthropic.com, Amazon Bedrock, Google Vertex, Microsoft Foundry; https required, plain http only for localhost). \ + Routing Claude through a third-party gateway/proxy conflicts with Anthropic's Usage Policy (\"Do Not Abuse Our Platform\", https://www.anthropic.com/legal/aup) \ + and would send your code and token to an undisclosed third party. localhost is allowed for a gateway you run yourself (otherwise set EIGHTSYNC_SELF_HOSTED_GATEWAY=, https, host must equal the URL host exactly). \ + Local tools or proxies that re-route a Claude subscription (Pro/Max/OAuth) into other clients are still forbidden.", + base_url_host(base_url).unwrap_or_default() + ); + } + if !token_allowed_for_host(base_url, token) { + bail!( + "refusing: a Claude subscription/OAuth token (`sk-ant-` but not a Console key) may only go to api.anthropic.com — \ + sending it to {:?} would re-route a subscription through another endpoint (Anthropic Usage Policy, https://www.anthropic.com/legal/aup). \ + Use a Console API key (`sk-ant-api…`) or the provider's own credentials.", + base_url_host(base_url).unwrap_or_default() + ); + } + let console_key = is_console_key(token); + ui::info(&format!("detected endpoint: {}", base_url)); ui::info(&format!("provider slug: {}", slug)); - ui::info(&format!("auth token: {}...{}", &token[..token.len().min(8)], &token[token.len().saturating_sub(6)..])); + ui::info(&format!("auth token: {}", mask_token(token))); // 2. Merge into ~/.claude/settings.json: only these `env` keys change, so the // 8sync kit's hooks/permissions and the user's own settings survive. Claude @@ -334,11 +478,19 @@ pub fn harness_claude_code(env: &env_detect::Env, args: &[String]) -> Result<()> std::env::set_var("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", &settings.env.claude_code_disable_nonessential_traffic); std::env::set_var("CLAUDE_CODE_ATTRIBUTION_HEADER", &settings.env.claude_code_attribution_header); - let is_official = base_url.contains("api.anthropic.com"); + let is_official = base_url_host(base_url).as_deref() == Some("api.anthropic.com"); + let write_omp = omp_write_allowed(base_url, token); #[cfg(target_os = "windows")] { - if is_official { + let vals = [ + token.as_str(), + settings.env.claude_code_disable_nonessential_traffic.as_str(), + settings.env.claude_code_attribution_header.as_str(), + ]; + if write_omp && !vals.iter().all(|v| persist_value_safe(v)) { + ui::warn("skipping Windows env persistence: a value has characters outside [A-Za-z0-9_-]"); + } else if write_omp { let ps_cmd = format!( "[System.Environment]::SetEnvironmentVariable('ANTHROPIC_AUTH_TOKEN', '{}', [System.EnvironmentVariableTarget]::User); \ [System.Environment]::SetEnvironmentVariable('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', '{}', [System.EnvironmentVariableTarget]::User); \ @@ -354,8 +506,13 @@ pub fn harness_claude_code(env: &env_detect::Env, args: &[String]) -> Result<()> // Policy (8sync rules) lets tools other than Claude Code — omp here — use // Claude only through a Console API key, never a gateway's Claude access. if !is_official { - ui::warn("endpoint is not api.anthropic.com → Claude Code configured, omp left untouched. Tools other than Claude Code (omp, Forge, Feynman) use a Console API key; a gateway reselling Claude subscriptions breaks Anthropic's Usage Policy."); - report_probe(&v1_url, token); + ui::warn("endpoint is not api.anthropic.com → Claude Code configured, omp left untouched. Tools other than Claude Code (omp, Forge, Feynman) use a Console API key only."); + report_probe(&v1_url, token, console_key); + return Ok(()); + } + if !write_omp { + ui::warn("token is not a Console API key (sk-ant-api…) → Claude Code configured, omp models.yml untouched. Subscription/OAuth tokens stay in Claude Code only (and https is required)."); + report_probe(&v1_url, token, console_key); return Ok(()); } @@ -382,16 +539,19 @@ pub fn harness_claude_code(env: &env_detect::Env, args: &[String]) -> Result<()> let config_yml_path = agent_dir.join("config.yml"); if config_yml_path.exists() { let config_raw = std::fs::read_to_string(&config_yml_path).unwrap_or_default(); - let updated_config = set_model_roles(&config_raw, "anthropic/claude-fable-5-1:high", "anthropic/claude-opus-5:high"); + let updated_config = set_model_roles(&config_raw, "anthropic/claude-fable-5-1:high", "anthropic/claude-opus-5-5:high"); std::fs::write(&config_yml_path, updated_config)?; - ui::ok("set default modelRole to anthropic/claude-fable-5-1:high and advisor to anthropic/claude-opus-5:high"); + ui::ok("set default modelRole to anthropic/claude-fable-5-1:high and advisor to anthropic/claude-opus-5-5:high"); } // 6. Live probe test - report_probe(&v1_url, token); + report_probe(&v1_url, token, console_key); println!(); - println!("{}", "Active Models available in omp:".to_string()); + println!("Active Models available in omp:"); + println!(" • anthropic/claude-opus-5-5 (1M ctx, 128K max-out) [ADVISOR]"); + println!(" • anthropic/claude-sonnet-5-5 (1M ctx, 128K max-out)"); + println!(" • anthropic/claude-haiku-5-5 (200K ctx, 64K max-out)"); println!(" • anthropic/claude-opus-5 (1M ctx, 128K max-out)"); println!(" • anthropic/claude-fable-5-1 (1M ctx, 128K max-out) [DEFAULT]"); println!(" • anthropic/claude-fable-5 (1M ctx, 128K max-out)"); @@ -405,7 +565,15 @@ pub fn harness_claude_code(env: &env_detect::Env, args: &[String]) -> Result<()> Ok(()) } -fn report_probe(v1_url: &str, token: &str) { +fn report_probe(v1_url: &str, token: &str, console_key: bool) { + if !console_key { + ui::info("probe skipped: token is not a Console API key; a subscription token is only used inside Claude Code."); + return; + } + if !probe_supported(v1_url) { + ui::info("probe skipped: only api.anthropic.com and localhost use the Messages API header format; Bedrock/Vertex/Foundry need their own auth."); + return; + } ui::step("verifying connection with 1-token probe..."); match probe_endpoint(v1_url, token) { Ok(ms) => ui::ok(&format!("live probe OK ({ms}ms) — endpoint is responsive")), @@ -530,22 +698,43 @@ pub fn set_model_roles(raw: &str, default_role: &str, advisor_role: &str) -> Str lines.join("\n") + "\n" } +/// Probe only hosts that speak the Anthropic Messages API directly. +fn probe_supported(v1_url: &str) -> bool { + base_url_host(v1_url).is_some_and(|h| h == "api.anthropic.com" || is_loopback(&h)) +} + +/// Header lines sent to curl via stdin (`-H @-`), one per line. Messages API +/// headers for Console keys: x-api-key + anthropic-version. +fn probe_header_lines(token: &str) -> String { + format!("x-api-key: {token}\nanthropic-version: 2023-06-01\n") +} + /// Fast 1-token probe test against the messages endpoint. fn probe_endpoint(v1_url: &str, token: &str) -> Result { let endpoint = format!("{}/messages", v1_url.trim_end_matches('/')); let start = std::time::Instant::now(); - let output = Command::new("curl") + // Auth header goes through stdin (`-H @-`) so the token never shows in argv. + use std::io::Write; + use std::process::Stdio; + let mut child = Command::new("curl") .args([ "-s", "-X", "POST", &endpoint, - "-H", &format!("Authorization: Bearer {}", token), + "-H", "@-", "-H", "Content-Type: application/json", - "-d", r#"{"model":"claude-haiku-4-5","max_tokens":5,"messages":[{"role":"user","content":"ping"}]}"#, + "-d", r#"{"model":"claude-haiku-5-5","max_tokens":5,"messages":[{"role":"user","content":"ping"}]}"#, ]) - .output() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() .context("failed executing curl probe")?; + if let Some(mut si) = child.stdin.take() { + let _ = si.write_all(probe_header_lines(token).as_bytes()); + } + let output = child.wait_with_output().context("curl probe failed")?; let elapsed = start.elapsed().as_millis(); let body = String::from_utf8_lossy(&output.stdout); @@ -563,6 +752,22 @@ fn probe_endpoint(v1_url: &str, token: &str) -> Result { mod tests { use super::*; + #[test] + fn probe_headers_use_x_api_key_and_version() { + let h = probe_header_lines("test-key-placeholder"); + let lines: Vec<&str> = h.lines().collect(); + assert_eq!(lines, ["x-api-key: test-key-placeholder", "anthropic-version: 2023-06-01"]); + assert!(!h.to_ascii_lowercase().contains("authorization")); + } + + #[test] + fn probe_only_for_anthropic_and_localhost() { + assert!(probe_supported("https://api.anthropic.com/v1")); + assert!(probe_supported("http://localhost:8080/v1")); + assert!(!probe_supported("https://bedrock-runtime.us-east-1.amazonaws.com")); + assert!(!probe_supported("https://aiplatform.googleapis.com")); + } + #[test] fn parse_full_claude_settings_json() { let input = r#"{ @@ -616,8 +821,100 @@ mod tests { #[test] fn update_config_roles() { let raw = "setupVersion: 2\nmodelRoles:\n advisor: old/opus\n default: old/default\n"; - let updated = set_model_roles(raw, "anthropic/claude-fable-5-1:high", "anthropic/claude-opus-5:high"); + let updated = set_model_roles(raw, "anthropic/claude-fable-5-1:high", "anthropic/claude-opus-5-5:high"); assert!(updated.contains("default: anthropic/claude-fable-5-1:high")); - assert!(updated.contains("advisor: anthropic/claude-opus-5:high")); + assert!(updated.contains("advisor: anthropic/claude-opus-5-5:high")); } + + #[test] + fn gateway_allowlist() { + // env-touching: kept in ONE test (env is process-global, tests run in parallel) + std::env::remove_var("EIGHTSYNC_SELF_HOSTED_GATEWAY"); + for ok in [ + "https://api.anthropic.com", + "https://API.Anthropic.com:443/v1", + "https://bedrock-runtime.us-east-1.amazonaws.com", + "https://us-east5-aiplatform.googleapis.com/v1", + "https://aiplatform.googleapis.com", + "https://myres.services.ai.azure.com/anthropic", + "http://localhost:8080", + "https://localhost:8080", + "http://127.0.0.1:4000/v1", + "http://[::1]:4000", + ] { + assert!(gateway_allowed(ok), "{ok}"); + } + for bad in [ + "https://api.apikey.fun", + "http://api.anthropic.com", + "api.anthropic.com", + "http://bedrock-runtime.us-east-1.amazonaws.com", + "https://api.anthropic.com.evil.io", + "https://evilapi.anthropic.com", + "https://api.anthropic.com@evil.io", + "https://bedrock-runtime.us-east-1.amazonaws.com.evil.io", + "https://evil.io/api.anthropic.com", + "https://x.y.services.ai.azure.com", + "", + ] { + assert!(!gateway_allowed(bad), "{bad}"); + } + std::env::set_var("EIGHTSYNC_SELF_HOSTED_GATEWAY", "gw.example.test"); + assert!(gateway_allowed("https://gw.example.test:8443/v1")); + assert!(!gateway_allowed("https://other.example.test")); + assert!(!gateway_allowed("https://sub.gw.example.test")); + std::env::remove_var("EIGHTSYNC_SELF_HOSTED_GATEWAY"); + assert!(!gateway_allowed("https://gw.example.test")); + } + + #[test] + fn omp_write_gate() { + assert!(omp_write_allowed("https://api.anthropic.com", "sk-ant-api03-test")); + assert!(!omp_write_allowed("http://api.anthropic.com", "sk-ant-api03-test")); + assert!(!omp_write_allowed("https://api.anthropic.com", "sk-ant-oat01-test")); + assert!(!omp_write_allowed("https://bedrock-runtime.us-east-1.amazonaws.com", "sk-ant-api03-test")); + assert!(!omp_write_allowed("http://localhost:8080", "sk-ant-api03-test")); + } + + #[test] + fn persist_value_validation() { + assert!(persist_value_safe("sk-ant-api03-test_x")); + assert!(persist_value_safe("1")); + assert!(!persist_value_safe("")); + assert!(!persist_value_safe("a'b")); + assert!(!persist_value_safe("a b;c")); + assert!(!persist_value_safe("a$(x)")); + } + + #[test] + fn console_key_detection() { + assert!(is_console_key("sk-ant-api03-test")); + assert!(!is_console_key("sk-ant-oat01-test")); + assert!(!is_console_key("other")); + assert!(!is_console_key("")); + } + + #[test] + fn mask_shows_only_last_four() { + assert_eq!(mask_token("sk-ant-api03-abcd"), "…abcd"); + assert_eq!(mask_token("ab"), "…"); + } + + #[test] + fn models_yaml_has_55_family() { + for id in ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"] { + assert!(CLAUDE_MODELS_YAML.contains(&format!("id: {id}\n")), "{id}"); + } + } + #[test] + fn subscription_tokens_only_to_official_host() { + let oat = "sk-ant-oat01-test"; + assert!(token_allowed_for_host("https://api.anthropic.com", oat)); + assert!(!token_allowed_for_host("http://localhost:8080", oat)); + assert!(!token_allowed_for_host("https://my-gw.example.org", oat)); + assert!(!token_allowed_for_host("http://127.0.0.1:4000", " sk-ant-oat01-test")); + assert!(token_allowed_for_host("http://localhost:8080", "sk-ant-api03-test")); + assert!(token_allowed_for_host("http://localhost:8080", "local-gw-token")); + } + } diff --git a/crates/cli/src/verbs/harness/custom_model.rs b/crates/cli/src/verbs/harness/custom_model.rs index 9f7db01..8312554 100644 --- a/crates/cli/src/verbs/harness/custom_model.rs +++ b/crates/cli/src/verbs/harness/custom_model.rs @@ -97,8 +97,9 @@ fn add(env: &env_detect::Env, selector: &str, flags: Flags) -> Result<()> { let env_key = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); let api_key = flags .key - .filter(|s| !s.trim().is_empty()) - .or_else(|| std::env::var(&env_key).ok().filter(|s| !s.is_empty())) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| std::env::var(&env_key).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())) .unwrap_or_else(|| { ui::warn(&format!( "no API key (pass --key, or set ${env_key}) — wrote a placeholder; edit models.yml" @@ -107,6 +108,7 @@ fn add(env: &env_detect::Env, selector: &str, flags: Flags) -> Result<()> { }); let api = normalize_api(flags.api.as_deref()); + check_anthropic_policy(&api, &base_url, &api_key)?; let think = flags.think.map(|s| normalize_efforts(&s)).unwrap_or_default(); let m = CustomModel { @@ -194,9 +196,32 @@ fn split_selector(sel: &str) -> Result<(String, String)> { Ok((prov.trim().to_string(), model.trim().to_string())) } +/// Anthropic Usage Policy guard: Claude keys/protocol only reach Anthropic or an +/// approved cloud gateway (`gateway_allowed`); subscription (OAuth) tokens never. +fn check_anthropic_policy(api: &str, url: &str, key: &str) -> Result<()> { + use super::claude_code::{gateway_allowed, is_console_key}; + const WHY: &str = "Anthropic's Usage Policy / Commercial Terms do not allow routing Claude \ + through third-party resellers or subscription tokens (https://www.anthropic.com/legal/aup); \ + use a Console API key against api.anthropic.com, Bedrock, Vertex or Foundry"; + let key = key.trim(); + if key.starts_with("sk-ant-") { + if !is_console_key(key) { + bail!("refusing: `sk-ant-` key is not a Console API key (subscription/OAuth token). {WHY}"); + } + if !gateway_allowed(url) { + bail!("refusing: Anthropic key with non-approved --url `{url}`. {WHY}"); + } + } + if matches!(api, "anthropic" | "anthropic-messages") && !gateway_allowed(url) { + bail!("refusing: Claude protocol (--api {api}) with non-approved --url `{url}`. {WHY}"); + } + Ok(()) +} + /// Map a friendly `--api` value to the omp dialect string. Default: OpenAI. fn normalize_api(api: Option<&str>) -> String { - match api.map(str::trim).unwrap_or("openai") { + let api = api.map(|a| a.trim().to_lowercase()).unwrap_or_else(|| "openai".to_string()); + match api.as_str() { "anthropic" | "anthropic-messages" | "claude" => "anthropic-messages", "openai" | "openai-completions" | "" => "openai-completions", other => other, // pass through any exact dialect omp supports @@ -313,7 +338,8 @@ fn save_registry(env: &env_detect::Env, reg: &[CustomModel]) -> Result<()> { fn sync_models_yml(env: &env_detect::Env, reg: &[CustomModel]) -> Result<()> { let path = env.home.join(".omp/agent/models.yml"); let existing = std::fs::read_to_string(&path).unwrap_or_default(); - let merged = super::local_model::insert_block(&strip_block(&existing), &render_block(reg)); + let allowed = policy_filtered(reg); + let merged = super::local_model::insert_block(&strip_block(&existing), &render_block(&allowed)); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -321,6 +347,21 @@ fn sync_models_yml(env: &env_detect::Env, reg: &[CustomModel]) -> Result<()> { Ok(()) } +/// Drop (with a warning) registry entries that fail the Anthropic policy check, +/// so entries saved before the guard existed are never re-rendered into models.yml. +fn policy_filtered(reg: &[CustomModel]) -> Vec { + reg.iter() + .filter(|m| match check_anthropic_policy(&m.api, &m.base_url, &m.api_key) { + Ok(()) => true, + Err(e) => { + ui::warn(&format!("skipping `{}` in models.yml: {e}", m.selector)); + false + } + }) + .cloned() + .collect() +} + /// Remove the managed custom-models sentinel block (if present) from a body. pub(crate) fn strip_block(s: &str) -> String { let mut out = String::new(); @@ -390,7 +431,7 @@ fn render_block(reg: &[CustomModel]) -> String { b.push_str(&format!(" api: {}\n", head.api)); b.push_str(" models:\n"); for m in models { - let id = m.selector.splitn(2, '/').nth(1).unwrap_or(&m.selector); + let id = m.selector.split_once('/').map_or(m.selector.as_str(), |(_, id)| id); let input = if m.vision { "[text, image]" } else { "[text]" }; b.push_str(&format!(" - id: {id}\n")); b.push_str(&format!(" name: {id} (custom)\n")); @@ -453,3 +494,83 @@ fn usage() { ui::info("Writes a custom provider into ~/.omp/agent/models.yml; selector = /."); ui::info("For a local GGUF instead, use `8sync harness add-local-model`."); } + +#[cfg(test)] +mod policy_tests { + use super::check_anthropic_policy as chk; + + #[test] + fn oat_token_refused() { + assert!(chk("openai-completions", "https://api.anthropic.com", "sk-ant-oat01-test").is_err()); + } + #[test] + fn console_key_third_party_refused() { + assert!(chk("openai-completions", "https://reseller.example.com/v1", "sk-ant-api03-test").is_err()); + } + #[test] + fn console_key_anthropic_ok() { + assert!(chk("anthropic-messages", "https://api.anthropic.com", "sk-ant-api03-test").is_ok()); + } + #[test] + fn anthropic_api_reseller_refused() { + assert!(chk("anthropic-messages", "https://reseller.example.com", "other-key").is_err()); + } + #[test] + fn openai_api_other_key_ok() { + assert!(chk("openai-completions", "https://api.x.ai/v1", "xai-test").is_ok()); + } +} + +#[cfg(test)] +mod input_tests { + use super::*; + + fn model(sel: &str, url: &str, key: &str, api: &str) -> CustomModel { + CustomModel { + selector: sel.into(), + base_url: url.into(), + api_key: key.into(), + api: api.into(), + ctx: 1000, + max: 100, + vision: false, + think: String::new(), + } + } + + #[test] + fn api_is_lowercased_and_trimmed() { + assert_eq!(normalize_api(Some(" Anthropic ")), "anthropic-messages"); + assert_eq!(normalize_api(Some("CLAUDE")), "anthropic-messages"); + assert_eq!(normalize_api(Some("OpenAI")), "openai-completions"); + assert_eq!(normalize_api(None), "openai-completions"); + } + + #[test] + fn uppercase_anthropic_api_hits_policy() { + let api = normalize_api(Some("ANTHROPIC")); + assert!(check_anthropic_policy(&api, "https://reseller.example.com", "k").is_err()); + } + + #[test] + fn padded_oauth_key_hits_policy_after_trim() { + let key = " sk-ant-oat01-test \n".trim(); + assert!(check_anthropic_policy("openai-completions", "https://api.anthropic.com", key).is_err()); + // the policy trims too, so padding cannot skip the `sk-ant-` prefix check + assert!(check_anthropic_policy("openai-completions", "https://api.anthropic.com", " sk-ant-oat01-test").is_err()); + } + + #[test] + fn registry_entries_failing_policy_are_skipped() { + let reg = vec![ + model("xai/grok", "https://api.x.ai/v1", "xai-test", "openai-completions"), + model("bad/oat", "https://api.anthropic.com", "sk-ant-oat01-test", "openai-completions"), + model("bad/resell", "https://reseller.example.com", "k", "anthropic-messages"), + ]; + let out = policy_filtered(®); + assert_eq!(out.len(), 1); + assert_eq!(out[0].selector, "xai/grok"); + let block = render_block(&out); + assert!(block.contains("xai:") && !block.contains("bad")); + } +} diff --git a/crates/cli/src/verbs/harness/gateway.rs b/crates/cli/src/verbs/harness/gateway.rs index 5e1344b..fa5f513 100644 --- a/crates/cli/src/verbs/harness/gateway.rs +++ b/crates/cli/src/verbs/harness/gateway.rs @@ -1,7 +1,8 @@ //! `8sync harness gateway` — deploy & verify the omp model-gateway config //! (`~/.omp/agent/models.yml`): the 9router provider, models, API key, and the -//! `thinking.mode = anthropic-budget-effort` fix that stops the gateway 400'ing -//! Claude (notably sonnet-5) on `thinking:{type:adaptive}`. +//! non-Claude (GPT/Codex) models. Claude is NOT routed here: a third-party proxy for +//! Claude conflicts with Anthropic's Usage Policy, so `apply` removes any old +//! `9router-cc` provider. `apply`/`key`/`verify` are opt-in (`EIGHTSYNC_GATEWAY_ACCEPT=1`). //! //! This file is the ONLY place omp learns the provider URL, models, and how to //! serialize thinking. Model ROLE routing (which model for code/plan/smol) is a @@ -20,10 +21,199 @@ use crate::{assets, env_detect, ui}; const PLACEHOLDER: &str = "__NINE_ROUTER_KEY__"; /// Env var read for the key (keeps secrets out of shell history / git). const ENV_KEY: &str = "NINE_ROUTER_KEY"; +/// Opt-in env var for every action except `status`. +const ENV_ACCEPT: &str = "EIGHTSYNC_GATEWAY_ACCEPT"; +/// Removed Claude provider (fake-client / proxy pattern, forbidden). +const CC_PROVIDER: &str = "9router-cc"; +/// The only provider block this module reads or rewrites. +const GW_PROVIDER: &str = "9router-cx"; + +/// Anthropic keys never go to a third-party router. +fn reject_anthropic_key(key: &str) -> Result<()> { + if key.starts_with("sk-ant-") { + bail!("refusing an Anthropic key (sk-ant-…) for the 9router gateway: Anthropic keys must never be sent to a third-party router. Keep it only in the `anthropic` provider (`8sync harness claude-code`)."); + } + Ok(()) +} + +/// True for a managed-section sentinel line (` # >>> ...` / ` # <<< ...`). +fn is_sentinel(line: &str) -> bool { + let t = line.trim_start(); + t.starts_with("# >>>") || t.starts_with("# <<<") +} + +/// Header ` :` matched by the key before any `#` comment. +fn is_header(line: &str, head: &str) -> bool { + line.trim_end().split('#').next().unwrap_or("").trim_end() == head +} + +/// Byte range of the ` :` provider block: from its header line until the next +/// sentinel line or line indented <= 2 (next provider / top-level key) or EOF. +/// Trailing blank and comment-only lines are not part of the block. +fn provider_block_range(raw: &str, name: &str) -> Option<(usize, usize)> { + let head = format!(" {}:", name); + let mut pos = 0; + let mut start = None; + let mut last = 0; + for line in raw.split_inclusive('\n') { + let bare = line.trim_end(); + match start { + None => { + if is_header(line, &head) { + start = Some(pos); + last = pos + line.len(); + } + } + Some(s) => { + let indent = line.len() - line.trim_start().len(); + let comment = bare.trim_start().starts_with('#'); + if is_sentinel(line) || (!bare.is_empty() && indent <= 2 && !comment) { + return Some((s, last)); + } + if !bare.is_empty() && !(comment && indent <= 2) { + last = pos + line.len(); + } + } + } + pos += line.len(); + } + start.map(|s| (s, last)) +} + +/// Byte offset just after the `providers:` section (before the next top-level key or EOF), +/// excluding trailing blank lines. None when there is no `providers:` key. +fn providers_end(raw: &str) -> Option { + let mut pos = 0; + let mut in_sec = false; + let mut end = 0; + for line in raw.split_inclusive('\n') { + let bare = line.trim_end(); + if !in_sec { + if bare.split('#').next().unwrap_or("").trim_end() == "providers:" { + in_sec = true; + end = pos + line.len(); + } + } else if !bare.is_empty() && !line.starts_with(' ') && !bare.starts_with('#') { + break; + } else if !bare.is_empty() { + end = pos + line.len(); + } + pos += line.len(); + } + in_sec.then_some(end) +} + +/// Names of the 2-space-indented providers under `providers:`. +fn provider_names(raw: &str) -> Vec { + let Some(end) = providers_end(raw) else { return Vec::new() }; + raw[..end] + .lines() + .filter(|l| l.len() - l.trim_start().len() == 2 && !l.trim_start().starts_with('#')) + .filter_map(|l| l.split('#').next()?.trim().strip_suffix(':').map(|n| n.trim().to_string())) + .filter(|n| !n.is_empty()) + .collect() +} + +/// Append `block` (header included) at the end of the `providers:` section, creating the +/// section when absent. Other content is untouched. +fn append_provider(raw: &str, block: &str) -> String { + let block = if block.ends_with('\n') { block.to_string() } else { format!("{block}\n") }; + match providers_end(raw) { + Some(i) => { + let nl = if raw[..i].ends_with('\n') { "" } else { "\n" }; + format!("{}{}{}{}", &raw[..i], nl, block, &raw[i..]) + } + None => { + let sep = if raw.is_empty() || raw.ends_with('\n') { "" } else { "\n" }; + format!("{raw}{sep}providers:\n{block}") + } + } +} + +/// The text of provider block `name` (header line included). +fn provider_block<'a>(raw: &'a str, name: &str) -> Option<&'a str> { + provider_block_range(raw, name).map(|(a, b)| &raw[a..b]) +} + +/// Value of a provider-level (4-space-indented) `key:` inside provider `provider`. +fn provider_field<'a>(raw: &'a str, provider: &str, key: &str) -> Option<&'a str> { + let prefix = format!("{}:", key); + provider_block(raw, provider)?.lines().skip(1).find_map(|line| { + let indent = line.len() - line.trim_start().len(); + let rest = line.trim_start().strip_prefix(&prefix)?; + (indent == 4 && (rest.is_empty() || rest.starts_with(' '))).then(|| rest.trim().trim_matches('"')) + }) +} + +/// Rewrite provider-level `key:` inside `provider` only; other providers untouched. +fn set_provider_field(raw: &str, provider: &str, key: &str, value: &str) -> String { + let Some((a, b)) = provider_block_range(raw, provider) else { + return raw.to_string(); + }; + let prefix = format!("{}:", key); + let block: String = raw[a..b] + .split_inclusive('\n') + .map(|line| { + let t = line.trim_start(); + let indent = line.len() - t.len(); + if indent == 4 && t.starts_with(&prefix) { + let nl = if line.ends_with('\n') { "\n" } else { "" }; + format!(" {} {}{}", prefix, value, nl) + } else { + line.to_string() + } + }) + .collect(); + format!("{}{}{}", &raw[..a], block, &raw[b..]) +} + +/// True only when the opt-in env var is exactly "1". +fn opted_in(env_value: Option<&str>) -> bool { + env_value == Some("1") +} + +/// Remove the `9router-cc` provider block (2-space-indented key under `providers:`) +/// from a models.yml string. Returns (new content, whether something was removed). +fn strip_cc_provider(raw: &str) -> (String, bool) { + let head = format!(" {}:", CC_PROVIDER); + let mut out: Vec<&str> = Vec::new(); + let mut skipping = false; + let mut removed = false; + for line in raw.lines() { + if skipping { + let indent = line.len() - line.trim_start().len(); + if line.trim().is_empty() || indent > 2 { + continue; + } + skipping = false; + } + if is_header(line, &head) { + skipping = true; + removed = true; + continue; + } + out.push(line); + } + let mut s = out.join("\n"); + if raw.ends_with('\n') { + s.push('\n'); + } + (s, removed) +} pub(crate) fn harness_gateway(env: &env_detect::Env, args: &[String]) -> Result<()> { let path = env.home.join(".omp/agent/models.yml"); - match args.first().map(String::as_str) { + let sub = args.first().map(String::as_str); + if matches!(sub, Some("apply") | Some("key") | Some("verify")) + && !opted_in(std::env::var(ENV_ACCEPT).ok().as_deref()) + { + ui::warn("gateway apply/key/verify are OPT-IN and were NOT run."); + ui::info("This routes a ChatGPT/Codex subscription through a third-party router (9router) into omp; OpenAI's terms for that are not verified, and 8 Sync rules forbid routing subscriptions into third-party clients."); + ui::info("Claude is never routed here (Anthropic Usage Policy): use a Console API key via `8sync harness claude-code`."); + ui::info(&format!("To proceed at your own risk: {}=1 8sync harness gateway {}", ENV_ACCEPT, sub.unwrap_or(""))); + bail!("gateway requires {}=1 (opt-in, at your own risk)", ENV_ACCEPT); + } + match sub { Some("apply") => apply(&path), Some("key") => match args.get(1).map(|s| s.trim()).filter(|s| !s.is_empty()) { Some(k) => set_key(&path, k), @@ -43,6 +233,34 @@ pub(crate) fn harness_gateway(env: &env_detect::Env, args: &[String]) -> Result< } } +/// Append top-level blocks of `old` (e.g. `modelRoles:`) whose key is absent from `new`. +/// `providers:` is handled separately and never copied here. +fn keep_top_level_extras(old: &str, new: &str) -> String { + let key_of = |l: &str| -> Option { + (!l.starts_with([' ', '\t', '#', '-']) && l.contains(':')) + .then(|| l.split(':').next().unwrap_or("").trim().to_string()) + .filter(|k| !k.is_empty()) + }; + let have: Vec = new.lines().filter_map(key_of).collect(); + let mut out = new.to_string(); + let mut keep = false; + for line in old.lines() { + if let Some(k) = key_of(line) { + keep = k != "providers" && !have.contains(&k); + } else if !line.starts_with([' ', '\t']) && !line.trim().is_empty() && !line.starts_with('#') { + keep = false; + } + if keep { + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(line); + out.push('\n'); + } + } + out +} + /// Show the current gateway config: provider URL, masked key, model count, and /// whether the thinking-fix (`anthropic-budget-effort`) is in place. fn status(path: &Path) -> Result<()> { @@ -55,21 +273,24 @@ fn status(path: &Path) -> Result<()> { ui::info("run: 8sync harness gateway apply"); return Ok(()); } - let url = field(&raw, "baseUrl").unwrap_or("(missing)"); - let key = field(&raw, "apiKey").unwrap_or("(missing)"); + let url = provider_field(&raw, GW_PROVIDER, "baseUrl").unwrap_or("(missing)"); + let key = provider_field(&raw, GW_PROVIDER, "apiKey").unwrap_or("(missing)"); + if raw.contains(&format!("{}:", CC_PROVIDER)) { + ui::warn("legacy 9router-cc (Claude via proxy) provider present — run `gateway apply` to remove it (Anthropic Usage Policy)"); + } let n_models = raw.lines().filter(|l| l.trim_start().starts_with("- id:")).count(); let fixed = raw.contains("anthropic-budget-effort"); println!(); println!(" provider : {}", url); println!(" apiKey : {}", mask(key)); println!(" models : {}", n_models); - println!(" thinking : {}", - if fixed { "anthropic-budget-effort (OK — gateway-safe)".to_string() } - else { "MISSING fix — sonnet-5 will 400. Run `gateway apply`.".to_string() }); + if fixed { + println!(" thinking : anthropic-budget-effort present"); + } println!(); ui::info("apply : 8sync harness gateway apply (re-deploy from bundled template)"); ui::info("key : 8sync harness gateway key (rotate the API key in place)"); - ui::info("check : 8sync harness gateway verify (ping sonnet-5 through the gateway)"); + ui::info("check : 8sync harness gateway verify (ping the first gateway model)"); ui::info("route : 8sync harness model (which model for code/plan/smol)"); Ok(()) } @@ -84,7 +305,7 @@ fn apply(path: &Path) -> Result<()> { let preserved = std::fs::read_to_string(path) .ok() - .and_then(|raw| field(&raw, "apiKey").filter(|k| !k.is_empty() && *k != PLACEHOLDER).map(str::to_string)); + .and_then(|raw| provider_field(&raw, GW_PROVIDER, "apiKey").filter(|k| !k.is_empty() && *k != PLACEHOLDER).map(str::to_string)); let key = std::env::var(ENV_KEY) .ok() .filter(|s| !s.is_empty()) @@ -95,8 +316,10 @@ fn apply(path: &Path) -> Result<()> { if key == PLACEHOLDER { bail!("resolved key is still the placeholder — set $NINE_ROUTER_KEY"); } + reject_anthropic_key(&key)?; let rendered = tmpl.replace(PLACEHOLDER, &key); + let rendered = strip_cc_provider(&rendered).0; // Preserve managed provider blocks (local GGUF via `add-local-model`, remote // custom models via `add-model`) across a gateway re-deploy — each sentinel // block is re-attached under `providers:`. @@ -105,16 +328,32 @@ fn apply(path: &Path) -> Result<()> { &rendered, &super::local_model::extract_block(&old).unwrap_or_default(), ); - let rendered = super::local_model::insert_block( + let mut rendered = super::local_model::insert_block( &rendered, &super::custom_model::extract_block(&old).unwrap_or_default(), ); + // Keep providers we do not manage (e.g. `anthropic`); drop only 9router-cc. + for name in provider_names(&old) { + if name == CC_PROVIDER || name == GW_PROVIDER || provider_names(&rendered).contains(&name) { + continue; + } + if let Some(b) = provider_block(&old, &name) { + rendered = append_provider(&rendered, b.trim_end_matches('\n')); + ui::info(&format!("preserved provider `{name}` from the existing models.yml")); + } + } + + rendered = keep_top_level_extras(&old, &rendered); + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } if path.exists() { let old = std::fs::read_to_string(path).unwrap_or_default(); + if strip_cc_provider(&old).1 { + ui::warn("removed provider `9router-cc` (Claude via third-party proxy) from models.yml: it routes Claude outside Claude Code/Console API, which Anthropic's Usage Policy (\"Do Not Abuse Our Platform\") forbids. Use `8sync harness claude-code` with a Console API key."); + } if old == rendered { ui::ok(&format!("gateway config already up to date → {}", path.display())); return Ok(()); @@ -129,28 +368,26 @@ fn apply(path: &Path) -> Result<()> { Ok(()) } -/// Rotate just the API key in an already-deployed config (every `apiKey:` line), -/// preserving comments and layout. Creates the file from the template if absent. +/// Rotate just the API key in an already-deployed config (the `apiKey:` of the +/// `9router-cx` provider only), preserving comments, layout and other providers. Creates the file from the template if absent. fn set_key(path: &Path, key: &str) -> Result<()> { - if path.exists() { - let raw = std::fs::read_to_string(path)?; - let new: String = raw - .lines() - .map(|line| { - let t = line.trim_start(); - if t.starts_with("apiKey:") { - let indent = &line[..line.len() - t.len()]; - format!("{}apiKey: {}", indent, key) - } else { - line.to_string() - } - }) - .collect::>() - .join("\n") - + "\n"; - std::fs::write(path, new)?; + reject_anthropic_key(key)?; + let raw = std::fs::read_to_string(path).unwrap_or_default(); + if provider_block(&raw, GW_PROVIDER).is_some() { + std::fs::write(path, set_provider_field(&raw, GW_PROVIDER, "apiKey", key))?; ui::ok(&format!("API key updated → {}", path.display())); Ok(()) + } else if path.exists() && !raw.trim().is_empty() { + // File exists without our block: append it, keep every other provider. + let tmpl = assets::read("configs/omp/gateway-models.yml") + .ok_or_else(|| anyhow::anyhow!("embedded gateway template (configs/omp/gateway-models.yml) missing"))?; + let block = provider_block(&tmpl, GW_PROVIDER) + .ok_or_else(|| anyhow::anyhow!("template has no {GW_PROVIDER} block"))?; + let out = append_provider(&raw, block.trim_end_matches('\n')); + std::fs::write(path.with_extension("yml.bak"), &raw).ok(); + std::fs::write(path, set_provider_field(&out, GW_PROVIDER, "apiKey", key))?; + ui::ok(&format!("added {GW_PROVIDER} provider with the new key → {}", path.display())); + Ok(()) } else { // No config yet: create it from the template with this key. std::env::set_var(ENV_KEY, key); @@ -158,34 +395,45 @@ fn set_key(path: &Path, key: &str) -> Result<()> { } } -/// Ping the gateway through the first `cc/` (Claude) model with a 1-token -/// request — the exact path that 400'd before the thinking fix. 200 = healthy. +/// Ping the gateway through the first model with a 1-token OpenAI-style request. +/// The auth header goes via stdin so the key stays out of the process list. fn verify(path: &Path) -> Result<()> { let raw = std::fs::read_to_string(path) .ok() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("no gateway config — run `8sync harness gateway apply` first"))?; - let url = field(&raw, "baseUrl").ok_or_else(|| anyhow::anyhow!("baseUrl not found in {}", path.display()))?; - let key = field(&raw, "apiKey").ok_or_else(|| anyhow::anyhow!("apiKey not found in {}", path.display()))?; - let model = verify_model(&raw).ok_or_else(|| anyhow::anyhow!("no cc/ (Claude) model found to test"))?; + let url = provider_field(&raw, GW_PROVIDER, "baseUrl").ok_or_else(|| anyhow::anyhow!("baseUrl not found in {}", path.display()))?; + let key = provider_field(&raw, GW_PROVIDER, "apiKey").ok_or_else(|| anyhow::anyhow!("apiKey not found in {}", path.display()))?; + reject_anthropic_key(key)?; + let Some(model) = verify_model(&raw) else { + ui::warn("verify skipped: no non-Claude model found to test (Claude is never routed through the gateway)"); + return Ok(()); + }; ui::header(&format!("8sync harness gateway verify — pinging {}", model)); - let endpoint = format!("{}/messages", url.trim_end_matches('/')); + let endpoint = format!("{}/chat/completions", url.trim_end_matches('/')); let body = format!( "{{\"model\":\"{}\",\"max_tokens\":16,\"messages\":[{{\"role\":\"user\",\"content\":\"hi\"}}]}}", model ); // curl appends the HTTP code on its own line via -w. - let out = Command::new("curl") + use std::io::Write; + use std::process::Stdio; + let mut child = Command::new("curl") .args([ "-sS", "-m", "30", "-w", "\n%{http_code}", - "-H", &format!("Authorization: Bearer {}", key), - "-H", "anthropic-version: 2023-06-01", + "-H", "@-", "-H", "content-type: application/json", "-d", &body, &endpoint, ]) - .output()?; + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + if let Some(mut si) = child.stdin.take() { + let _ = writeln!(si, "Authorization: Bearer {}", key); + } + let out = child.wait_with_output()?; let text = String::from_utf8_lossy(&out.stdout); let (body_txt, code) = match text.rfind('\n') { Some(i) => (text[..i].to_string(), text[i + 1..].trim().to_string()), @@ -201,54 +449,184 @@ fn verify(path: &Path) -> Result<()> { // Show a compact slice of the error so the cause is visible. let snip: String = body_txt.chars().take(240).collect(); println!(" {}", snip); - if body_txt.contains("budget_tokens") { - ui::info("cause: thinking serialization — run `8sync harness gateway apply` to restore the fix"); - } bail!("gateway verify failed (HTTP {})", c); } } } -/// First `key: value` field in the YAML whose stripped key matches `needle`. -fn field<'a>(raw: &'a str, needle: &str) -> Option<&'a str> { - for line in raw.lines() { - let t = line.trim_start(); - if let Some(rest) = t.strip_prefix(&format!("{}:", needle)) { - if rest.is_empty() || rest.starts_with(' ') { - return Some(rest.trim().trim_matches('"')); - } - } - } - None -} - -/// The model to verify with: prefer `cc/claude-sonnet-5` (the exact path that -/// 400'd before the thinking fix), else the first `cc/` (Claude) model found. +/// The model to verify with: the first non-Claude model id (`cc/` and `claude` +/// ids are never used). fn verify_model(raw: &str) -> Option { - let mut fallback = None; - for line in raw.lines() { + raw.lines().find_map(|line| { let t = line.trim_start(); - let id = t.strip_prefix("- id:").or_else(|| t.strip_prefix("id:")); - if let Some(v) = id { - let id = v.trim().trim_matches('"'); - if id.starts_with("cc/") { - if id.contains("sonnet-5") { - return Some(id.to_string()); - } - if fallback.is_none() { - fallback = Some(id.to_string()); - } - } - } - } - fallback + let v = t.strip_prefix("- id:").or_else(|| t.strip_prefix("id:"))?; + let id = v.trim().trim_matches('"'); + (!id.starts_with("cc/") && !id.to_ascii_lowercase().contains("claude")).then(|| id.to_string()) + }) } -/// Mask a secret for display: `sk-2d4e55340498...6efc`. Short values stay intact. +/// Mask a secret for display (`…` + last 4, char-safe). fn mask(k: &str) -> String { - if k.len() <= 10 { - k.to_string() - } else { - format!("{}...{}", &k[..6], &k[k.len() - 4..]) + super::claude_code::mask_token(k) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_template_has_no_claude_proxy() { + let t = assets::read("configs/omp/gateway-models.yml").unwrap(); + assert!(!t.contains("cc/")); + assert!(!t.contains("9router-cc")); + assert!(!t.to_lowercase().contains("masquerad")); + } + + #[test] + fn strips_cc_block_keeps_others() { + let raw = "providers:\n 9router-cc:\n baseUrl: https://x/v1\n models:\n - id: cc/claude-a\n\n 9router-cx:\n baseUrl: https://x/v1\n models:\n - id: cx/gpt-5.5\n"; + let (out, removed) = strip_cc_provider(raw); + assert!(removed); + assert!(!out.contains("cc/") && !out.contains("9router-cc")); + assert!(out.contains("9router-cx:") && out.contains("cx/gpt-5.5")); + assert!(out.ends_with('\n')); + let (same, removed2) = strip_cc_provider(&out); + assert!(!removed2); + assert_eq!(same, out); + } + + #[test] + fn opt_in_only_for_one() { + assert!(opted_in(Some("1"))); + assert!(!opted_in(None)); + assert!(!opted_in(Some(""))); + assert!(!opted_in(Some("true"))); + } + + #[test] + fn verify_model_skips_claude() { + let raw = "- id: cc/claude-sonnet-5\n- id: cx/gpt-5.5\n"; + assert_eq!(verify_model(raw).as_deref(), Some("cx/gpt-5.5")); + assert_eq!(verify_model("- id: cc/claude-x\n"), None); + } + + const MIXED: &str = "providers:\n anthropic:\n baseUrl: https://api.anthropic.com\n apiKey: sk-ant-api03-test\n models:\n - id: claude-x\n\n 9router-cx:\n baseUrl: https://9router.example/v1\n apiKey: nr-test\n models:\n - id: cx/gpt-5.5\n apiKey: nested\n"; + + #[test] + fn fields_are_block_scoped() { + assert_eq!(provider_field(MIXED, "9router-cx", "apiKey"), Some("nr-test")); + assert_eq!(provider_field(MIXED, "9router-cx", "baseUrl"), Some("https://9router.example/v1")); + assert_eq!(provider_field(MIXED, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + assert_eq!(provider_field(MIXED, "nope", "apiKey"), None); + assert!(!provider_block(MIXED, "anthropic").unwrap().contains("9router")); + } + + #[test] + fn set_provider_field_leaves_other_blocks() { + let out = set_provider_field(MIXED, "9router-cx", "apiKey", "nr-new"); + assert_eq!(provider_field(&out, "9router-cx", "apiKey"), Some("nr-new")); + assert_eq!(provider_field(&out, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + assert!(out.contains(" apiKey: nested")); + assert_eq!(out.ends_with('\n'), MIXED.ends_with('\n')); + } + + #[test] + fn set_key_file_keeps_anthropic_key_and_refuses_sk_ant() { + let dir = std::env::temp_dir().join(format!("gw-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("models.yml"); + std::fs::write(&p, MIXED).unwrap(); + assert!(set_key(&p, "sk-ant-api03-test").is_err()); + assert_eq!(std::fs::read_to_string(&p).unwrap(), MIXED); + set_key(&p, "nr-new").unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert_eq!(provider_field(&out, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + assert_eq!(provider_field(&out, "9router-cx", "apiKey"), Some("nr-new")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn reject_anthropic_key_only_sk_ant() { + assert!(reject_anthropic_key("sk-ant-api03-test").is_err()); + assert!(reject_anthropic_key("nr-test").is_ok()); + } + + const ONLY_ANTHROPIC: &str = "providers:\n anthropic:\n baseUrl: https://api.anthropic.com\n apiKey: sk-ant-api03-test\n models:\n - id: claude-x\n"; + + #[test] + fn key_appends_block_when_missing_and_keeps_anthropic() { + let dir = std::env::temp_dir().join(format!("gw-append-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("models.yml"); + std::fs::write(&p, ONLY_ANTHROPIC).unwrap(); + set_key(&p, "nr-new").unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert_eq!(provider_field(&out, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + assert_eq!(provider_field(&out, "9router-cx", "apiKey"), Some("nr-new")); + assert!(out.starts_with(ONLY_ANTHROPIC)); + assert_eq!(provider_names(&out), vec!["anthropic", "9router-cx"]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn apply_preserves_unmanaged_providers() { + let dir = std::env::temp_dir().join(format!("gw-apply-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("models.yml"); + std::fs::write(&p, ONLY_ANTHROPIC).unwrap(); + std::env::set_var(ENV_KEY, "nr-apply"); + apply(&p).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert_eq!(provider_field(&out, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + assert_eq!(provider_field(&out, "9router-cx", "apiKey"), Some("nr-apply")); + assert!(dir.join("models.yml.bak").exists()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn provider_header_may_carry_comment() { + let raw = "providers:\n 9router-cx: # managed\n apiKey: a\n other:\n apiKey: b\n"; + assert_eq!(provider_field(raw, "9router-cx", "apiKey"), Some("a")); + assert!(!provider_block(raw, "9router-cx").unwrap().contains("other")); + } + + #[test] + fn block_stops_at_sentinel_and_apply_keeps_single_section() { + let (bg, en) = (super::super::custom_model::BLOCK_BEGIN, super::super::custom_model::BLOCK_END); + let raw = format!("providers:\n 9router-cx:\n apiKey: nr-sent\n anthropic:\n apiKey: sk-ant-api03-test\n models:\n - id: claude-x\n # comment\n{bg}\n my-prov:\n baseUrl: https://m.example/v1\n{en}\n"); + let b = provider_block(&raw, "anthropic").unwrap(); + assert!(!b.contains(">>>") && !b.contains("my-prov") && !b.contains("# comment")); + assert!(b.ends_with("claude-x\n")); + let dir = std::env::temp_dir().join(format!("gw-sent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("models.yml"); + std::fs::write(&p, &raw).unwrap(); + apply(&p).unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert_eq!(out.matches("# >>> 8sync:custom-models").count(), 1); + assert_eq!(out.matches("# <<< 8sync:custom-models").count(), 1); + assert!(out.contains("my-prov:") && out.contains("https://m.example/v1")); + assert_eq!(provider_field(&out, "anthropic", "apiKey"), Some("sk-ant-api03-test")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn top_level_extras_kept() { + let out = keep_top_level_extras("modelRoles:\n default: a\nproviders:\n x:\n y: 1\n", "providers:\n z:\n"); + assert!(out.contains("modelRoles:\n default: a\n")); + assert_eq!(out.matches("providers:").count(), 1); + } + + #[test] + fn mask_is_char_safe() { + assert_eq!(mask("nr-abcdef1234"), "…1234"); + assert_eq!(mask("é0123456789"), "…6789"); + assert_eq!(mask("ab"), "…"); + } + + #[test] + fn strip_cc_header_with_comment() { + let (out, removed) = strip_cc_provider("providers:\n 9router-cc: # old\n baseUrl: x\n keep:\n a: 1\n"); + assert!(removed && !out.contains("9router-cc") && out.contains("keep:")); } } diff --git a/crates/cli/src/verbs/harness/mod.rs b/crates/cli/src/verbs/harness/mod.rs index fb8e35e..f7eab25 100644 --- a/crates/cli/src/verbs/harness/mod.rs +++ b/crates/cli/src/verbs/harness/mod.rs @@ -57,7 +57,7 @@ mod toolstats; 8sync harness audit scan docs for stale paths / oversized / junk + churn hotspots (doc-hygiene) 8sync harness eval [--baseline] run the quality task-suite through omp (pass/fail + wall-time; --baseline saves the reference) 8sync harness compaction [pct] view/set omp auto-compaction threshold (default 50% — anti-forget) - 8sync harness gateway [apply|key |verify|status] deploy/verify the omp model-gateway (9router + thinking fix) + 8sync harness gateway [apply|key |verify|status] deploy/verify the omp model-gateway (9router, GPT/Codex only; opt-in EIGHTSYNC_GATEWAY_ACCEPT=1) 8sync harness add-local-model [name] serve a local GGUF via mistral.rs + register with omp 8sync harness add-local-model list|rm list / remove registered local models 8sync harness add-model --url [--key|--api|--ctx|--max|--vision|--think] register a REMOTE model omp's catalog lacks @@ -250,7 +250,7 @@ fn print_help() { println!("{}", crate::brand::render(" 8sync harness eval [--baseline] run the quality task-suite through omp; --baseline saves the reference")); println!("{}", crate::brand::render(" 8sync harness compaction [pct] view/set omp auto-compaction threshold (anti-forget; default 50%)")); println!("{}", crate::brand::render(" 8sync harness model [combo|k v] view/set model routing; combo `model=claude+glm` sets ALL omp roles (opus=think, glm=work)")); - println!("{}", crate::brand::render(" 8sync harness gateway [apply|key|verify] deploy/verify omp model-gateway (9router + sonnet-5 thinking fix)")); + println!("{}", crate::brand::render(" 8sync harness gateway [apply|key|verify] deploy/verify omp model-gateway (9router, non-Claude, opt-in)")); println!("{}", crate::brand::render(" 8sync harness add-local-model [name] serve a local GGUF via mistral.rs (Rust) + register as omp `local/`")); println!("{}", crate::brand::render(" 8sync harness add-model --url register a REMOTE model omp's catalog lacks (custom provider)")); println!("{}", crate::brand::render(" 8sync harness browser [fix|status|off] point omp's browser at system Chromium (ungoogled) — fixes browser control not reaching the internet")); diff --git a/crates/cli/src/verbs/harness/model.rs b/crates/cli/src/verbs/harness/model.rs index 2102a42..92e59f7 100644 --- a/crates/cli/src/verbs/harness/model.rs +++ b/crates/cli/src/verbs/harness/model.rs @@ -69,7 +69,7 @@ pub(crate) fn harness_model(env: &env_detect::Env, args: &[String]) -> Result<() println!(); print_omp_roles(&omp_cfg); ui::info("combo: 8sync harness model claude+glm (set ALL omp roles: opus=thinking, glm=mechanical)"); - ui::info("aliases: claude|opus → anthropic/claude-opus-4-8 · sonnet → sonnet-5 · glm|zai → zai/glm-5.3-flash"); + ui::info("aliases: claude|opus → anthropic/claude-opus-5-5 · sonnet → sonnet-5-5 · glm|zai → zai/glm-5.3-flash"); ui::info("one role: 8sync harness model "); Ok(()) } @@ -135,9 +135,9 @@ fn resolve_alias(tok: &str) -> String { return tok.to_string(); } match tok.to_ascii_lowercase().as_str() { - "claude" | "opus" | "anthropic" => "anthropic/claude-opus-4-8".to_string(), - "sonnet" => "anthropic/claude-sonnet-5".to_string(), - "haiku" => "anthropic/claude-haiku-4-5-20251001".to_string(), + "claude" | "opus" | "anthropic" => "anthropic/claude-opus-5-5".to_string(), + "sonnet" => "anthropic/claude-sonnet-5-5".to_string(), + "haiku" => "anthropic/claude-haiku-5-5".to_string(), "fable" | "fable-5-1" | "claude-fable-5-1" => "anthropic/claude-fable-5-1".to_string(), "glm" | "zai" | "flash" => "zai/glm-5.3-flash".to_string(), "glm-5.3" | "glm53" => "zai/glm-5.3".to_string(), @@ -320,7 +320,7 @@ mod tests { assert!(is_native_vlm("GLM-5.3-Flash")); assert!(!is_native_vlm("zai/glm-5.2:xhigh")); assert!(!is_native_vlm("zai/glm-5.2")); - assert!(!is_native_vlm("anthropic/claude-opus-4-8")); + assert!(!is_native_vlm("anthropic/claude-opus-5-5")); } #[test] @@ -332,8 +332,8 @@ mod tests { "zai/glm-5.3-flash:high" ); assert_eq!( - vision_role_for_combo("anthropic/claude-opus-4-8", "zai/glm-5.2"), - "anthropic/claude-opus-4-8:high" + vision_role_for_combo("anthropic/claude-opus-5-5", "zai/glm-5.2"), + "anthropic/claude-opus-5-5:high" ); } } diff --git a/scripts/setup-claude-key.ps1 b/scripts/setup-claude-key.ps1 index 2450c11..a3d9873 100644 --- a/scripts/setup-claude-key.ps1 +++ b/scripts/setup-claude-key.ps1 @@ -1,28 +1,25 @@ <# .SYNOPSIS - Auto-setup Claude Code settings & omp Anthropic models from JSON. + Set up Claude Code settings from JSON via `8sync harness claude-code`. .DESCRIPTION - Takes the Claude Code JSON configuration and automatically: - 1. Writes C:\Users\\.claude\settings.json - 2. Configures Windows User environment variables (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, etc.) - 3. Injects all 9 modern Claude models into ~/.omp/agent/models.yml - 4. Sets default modelRole in ~/.omp/agent/config.yml to anthropic/claude-fable-5-1:high - 5. Runs 8sync harness claude-code if 8sync binary is present. + Mirrors scripts/setup-claude-key.sh: passes the JSON (or a file path to it) + straight to `8sync harness claude-code`. That command enforces the base-URL + allowlist and the Console-key rule, and merges settings without wiping the kit. + This script writes nothing itself and sets no environment variables. .EXAMPLE - .\scripts\setup-claude-key.ps1 '{"env":{"ANTHROPIC_BASE_URL":"https://api.apikey.fun","ANTHROPIC_AUTH_TOKEN":"your-api-key"}}' + .\scripts\setup-claude-key.ps1 '{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com","ANTHROPIC_API_KEY":""}}' #> -[CmdletBinding()] +# Plain (non-advanced) script so `$input` holds the whole pipeline, +# e.g. `Get-Content settings.json | .\scripts\setup-claude-key.ps1`. param( - [Parameter(Position=0, ValueFromPipeline=$true)] + [Parameter(Position=0)] [string]$ConfigJson ) $ErrorActionPreference = "Stop" -if (-not $ConfigJson) { - if ($input) { - $ConfigJson = $input | Out-String - } +if (-not $ConfigJson -and $input) { + $ConfigJson = ($input | Out-String) } if (-not $ConfigJson) { @@ -30,76 +27,16 @@ if (-not $ConfigJson) { $ConfigJson = Read-Host } -# If it's a file path -if (Test-Path $ConfigJson -PathType Leaf) { +if ($ConfigJson -and (Test-Path -LiteralPath $ConfigJson -PathType Leaf -ErrorAction SilentlyContinue)) { $ConfigJson = Get-Content -Raw -Path $ConfigJson } -try { - $parsed = $ConfigJson | ConvertFrom-Json -} catch { - Write-Error "Invalid JSON input: $_" - exit 1 -} - -$baseUrl = $null -$token = $null -$traffic = "1" -$attrHeader = "0" - -if ($parsed.env) { - $baseUrl = $parsed.env.ANTHROPIC_BASE_URL - $token = $parsed.env.ANTHROPIC_AUTH_TOKEN - if ($parsed.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) { - $traffic = [string]$parsed.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC - } - if ($parsed.env.CLAUDE_CODE_ATTRIBUTION_HEADER) { - $attrHeader = [string]$parsed.env.CLAUDE_CODE_ATTRIBUTION_HEADER - } -} else { - $baseUrl = $parsed.ANTHROPIC_BASE_URL - $token = $parsed.ANTHROPIC_AUTH_TOKEN -} - -if (-not $baseUrl -or -not $token) { - Write-Error "Missing ANTHROPIC_BASE_URL or ANTHROPIC_AUTH_TOKEN in JSON." +if (-not (Get-Command 8sync -ErrorAction SilentlyContinue)) { + Write-Error "8sync not found in PATH. Install it first, then re-run." exit 1 } -$baseUrl = $baseUrl.TrimEnd('/') - -Write-Host "==> Endpoint: $baseUrl" -ForegroundColor Green -Write-Host "==> Token: $($token.Substring(0, [Math]::Min(12, $token.Length)))..." -ForegroundColor Green - -# 1. Write ~/.claude/settings.json -$claudeDir = Join-Path $HOME ".claude" -if (-not (Test-Path $claudeDir)) { - New-Item -ItemType Directory -Path $claudeDir -Force | Out-Null -} -$settingsPath = Join-Path $claudeDir "settings.json" -$settingsObj = [ordered]@{ - '$schema' = "https://json.schemastore.org/claude-code-settings.json" - 'env' = [ordered]@{ - 'ANTHROPIC_BASE_URL' = $baseUrl - 'ANTHROPIC_AUTH_TOKEN' = $token - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' = $traffic - 'CLAUDE_CODE_ATTRIBUTION_HEADER' = $attrHeader - } -} -$settingsObj | ConvertTo-Json -Depth 5 | Set-Content -Path $settingsPath -Encoding UTF8 -Write-Host "✓ Wrote $settingsPath" -ForegroundColor Green - -# 2. Persist User environment variables -[System.Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL', $baseUrl, [System.EnvironmentVariableTarget]::User) -[System.Environment]::SetEnvironmentVariable('ANTHROPIC_AUTH_TOKEN', $token, [System.EnvironmentVariableTarget]::User) -[System.Environment]::SetEnvironmentVariable('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', $traffic, [System.EnvironmentVariableTarget]::User) -[System.Environment]::SetEnvironmentVariable('CLAUDE_CODE_ATTRIBUTION_HEADER', $attrHeader, [System.EnvironmentVariableTarget]::User) -Write-Host "✓ Persisted Windows User Environment Variables" -ForegroundColor Green - -# 3. Call 8sync harness if available -if (Get-Command 8sync -ErrorAction SilentlyContinue) { - Write-Host "==> Executing 8sync harness claude-code..." -ForegroundColor Cyan - & 8sync harness claude-code $ConfigJson -} else { - Write-Host "Note: 8sync binary not found in PATH; settings.json and environment variables are active." -ForegroundColor Yellow -} +# JSON on stdin: PS 5.1 mangles embedded quotes in native-exe arguments, +# and stdin keeps the token out of the process list. +$ConfigJson | & 8sync harness claude-code +exit $LASTEXITCODE diff --git a/scripts/setup-claude-key.sh b/scripts/setup-claude-key.sh index 6db6975..64b56fb 100644 --- a/scripts/setup-claude-key.sh +++ b/scripts/setup-claude-key.sh @@ -16,12 +16,11 @@ if [ -f "$INPUT" ]; then INPUT=$(cat "$INPUT") fi -if command -v 8sync >/dev/null 2>&1; then - 8sync harness claude-code "$INPUT" -else - # Fallback to python / jq to write ~/.claude/settings.json - CLAUDE_DIR="$HOME/.claude" - mkdir -p "$CLAUDE_DIR" - echo "$INPUT" > "$CLAUDE_DIR/settings.json" - echo "✓ Wrote $CLAUDE_DIR/settings.json" +if ! command -v 8sync >/dev/null 2>&1; then + # No raw fallback: `8sync harness claude-code` enforces the endpoint allowlist + # and the Console-key rule, and merges settings without wiping the kit. + echo "8sync not found in PATH. Install it first, then re-run." >&2 + exit 1 fi +# JSON on stdin keeps the token out of the process list. +printf '%s' "$INPUT" | 8sync harness claude-code From 6c3345a81c7bd644f2e63b5b2ef4b125afabe78c Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Mon, 12 Oct 2026 10:39:38 +0700 Subject: [PATCH 2/3] feat(ccc): leaner, role-routed Claude Code kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repo CLAUDE.md block compact (<=1.1 KB), no forced SKILL.md reads, no "MCP always present" claim; skip su-code/ memory dirs; no new block under an ancestor that already has one (existing blocks refreshed, never stripped) - kit CLAUDE.md summarises the 8 Sync / Anthropic rules instead of @importing the 6.7 KB doc; integrity + subagent-envelope lines; /aup-audit triggers - user-scope skillOverrides name-only for linked on-demand bundled skills, keyed by frontmatter name, core skills and user values kept - agents: model/effort/maxTurns/disallowedTools by role; new researcher + verifier; deep-research v2 pipeline (adapted from Feynman, MIT — THIRD_PARTY_NOTICES.md); /ship cites command output for done claims - stop gate on Haiku 5.5 allows waiting on background agents; ask gate on Sonnet 5.5; mirror_mcp reports why servers are not registered, serena alwaysLoad:false - `8sync ccc hook session-start`: byte-stable su-code STATE/KNOWLEDGE digest (<=6000 B, startup|clear|compact, found from subdirectories) - legal snapshot date in docs/legal/anthropic/index.md (refreshed on change) - CHANGELOG, KNOWLEDGE lessons, research plan plans/2026-10-12-ccc-beyond-omp.md Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 +++ THIRD_PARTY_NOTICES.md | 36 ++++ assets/claude/CLAUDE.md | 15 +- assets/claude/agents/designer.md | 4 + assets/claude/agents/explore.md | 5 + assets/claude/agents/quick-task.md | 5 + assets/claude/agents/researcher.md | 25 +++ assets/claude/agents/reviewer.md | 8 +- assets/claude/agents/task.md | 5 + assets/claude/agents/verifier.md | 25 +++ assets/claude/skills/ship/SKILL.md | 1 + assets/skills/deep-research/SKILL.md | 49 ++--- crates/cli/src/verbs/ccc.rs | 297 ++++++++++++++++++++++++--- crates/cli/src/verbs/skill/inject.rs | 141 ++++++++++--- docs/legal/anthropic/index.md | 2 + plans/2026-10-12-ccc-beyond-omp.md | 177 ++++++++++++++++ scripts/legal-sync.py | 22 ++ su-code/KNOWLEDGE.md | 5 + 18 files changed, 752 insertions(+), 93 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 assets/claude/agents/researcher.md create mode 100644 assets/claude/agents/verifier.md create mode 100644 plans/2026-10-12-ccc-beyond-omp.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dfecf36..f486a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ Mọi thay đổi đáng kể của `8sync` ghi vào đây. Format theo [Keep a versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nhật mục `Unreleased`. ## [Unreleased] +### Security +- **`8sync harness claude-code` only accepts endpoints Anthropic sanctions.** Allowed: `https://api.anthropic.com`, Amazon Bedrock, Google Vertex, Microsoft Foundry, and localhost or one exact host you run yourself (`EIGHTSYNC_SELF_HOSTED_GATEWAY=`). Any other gateway is refused before anything is written: gateways that resell Claude break the Usage Policy ("Do Not Abuse Our Platform"), and your code would go to an undisclosed third party. The official-endpoint check is an exact host match now (`api.anthropic.com.evil.io` used to pass as a substring); plain `http://` is refused except on localhost. +- **Only Console API keys leave Claude Code.** `harness claude-code` writes a token into `~/.omp/agent/models.yml` (and Windows User env) only when it starts with `sk-ant-api`; `harness add-model` refuses non-Console Anthropic tokens and Claude-protocol providers outside the allowlist; `forge key` takes only a Console key for `anthropic` and refuses ForgeCode's `claude_code` provider; the Feynman bridge skips every Claude/Anthropic model. A subscription/OAuth token stays in Claude Code and is not used for the endpoint probe. Tokens are printed as `…` + the last 4 characters (`harness claude-code`, `harness gateway`, `forge`) and kept out of `curl`'s argv. `scripts/setup-claude-key.ps1` and `.sh` now only forward to `8sync harness claude-code` on stdin (the `.ps1` used to write any token for any endpoint into settings.json and the Windows User env first; the `.sh` overwrote settings.json when 8sync was missing). A subscription/OAuth token is accepted only for `api.anthropic.com`, never for localhost or a self-hosted gateway. +- **`8sync harness gateway` no longer routes Claude through the 9router proxy**: the `cc/` provider (which made omp pose as Claude Code) is gone and `apply` removes an already-deployed one. The remaining non-Claude routes need `EIGHTSYNC_GATEWAY_ACCEPT=1`, `gateway key` rewrites only its own provider block, `gateway apply` re-deploys the template but keeps your other providers (backup in `models.yml.bak`), and both refuse Anthropic keys (`sk-ant-…`). +- **`8sync codex-web` is opt-in** (`EIGHTSYNC_CODEX_WEB_ACCEPT=1`): it automates a ChatGPT Web subscription into omp, the same pattern 8 Sync's rules forbid for Claude, and OpenAI's terms for it are not verified. Without the opt-in only `status`/`off` run. +- `8sync forge` writes `~/.forge/.credentials.json` with mode 0600 (and tightens an existing file). + +### Fixed +- `8sync feynman` works with Feynman ≥ 0.5.35: the bridge finds Pi's packages under `@earendil-works/*` (then `@mariozechner/*`), in the launcher and the extension (was `Cannot find module '@mariozechner/pi-ai'`). +- Claude 5.5 model IDs (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`) in `harness claude-code`, the `harness model` aliases (`opus`/`sonnet`/`haiku`), the endpoint probe (now `x-api-key` + `anthropic-version`, official endpoint and localhost only) and the kit's gates. +- `8sync ccc kit` says why codebase-memory-mcp / serena were not registered in Claude Code (missing from omp's `mcp.json`, not on PATH, or the `claude mcp add-json` error) instead of skipping silently; serena is registered with `alwaysLoad: false`. + +### Changed +- **Leaner Claude Code sessions.** Measured on one machine (2026-10-12; `wc -c` of every always-loaded layer, and `message.usage` of the first request in 2 Claude Code transcripts), the kit + repo layers came to 55,663 B (≈13.9k tokens at 4 B/token) of a ~58k-token first request; most of it is gone: + - the repo `CLAUDE.md` block is compact (≤1.1 KB), no longer orders reading 4 SKILL.md bodies (15.4 KB) before the first tool call, and no longer claims MCP tools are always present; + - no block in a `su-code/` memory dir, and no new block in a child repo when an ancestor `CLAUDE.md` already has it (Claude Code loads both; they were byte-identical 8.6 KB copies); an existing child block is kept and refreshed to the compact one; + - the kit's `CLAUDE.md` summarises the 8 Sync / Anthropic rules instead of importing the 6.7 KB rules doc into every session (full text: `/aup-audit`, `skills/aup-audit/8sync-anthropic-rules.md`); + - on-demand bundled skills are `name-only` in user-scope `skillOverrides` (values you set are kept; core skills keep their descriptions). +- **Kit agents are routed by role**: explore and quick-task on Haiku (`effort: low`), task on Sonnet, reviewer on Sonnet (`effort: high`), with `maxTurns`, read-only `disallowedTools` where they only read, and a short return envelope (reviewer keeps its BLOCKER/SHOULD/NIT + verdict format). +- New kit agents `researcher` and `verifier`, and `deep-research` v2: plan → scale gate → lead drafts → verifier checks every citation → reviewer grades FATAL/MAJOR/MINOR → provenance file, `Verification: BLOCKED` when it cannot finish. Adapted from Feynman (MIT, see `THIRD_PARTY_NOTICES.md`). Private repo source is researched locally, not sent to third-party models. +- The Stop gate (Haiku 5.5) lets a turn end while Claude waits on background agents it launched (it blocked 4 times in a row in a real session); the ask gate stays on Sonnet (5.5). `/ship` requires every "done/verified" claim to cite the command output. +- New `8sync ccc hook session-start` (startup/clear/compact, found from any subdirectory): a byte-stable digest of `su-code/STATE.md` plus recent `failure:`/`validated:` lessons, at most 6,000 B. +- `docs/legal/anthropic/index.md` records the snapshot date; `legal-sync.py` refreshes it only when a page changed. ## [0.65.0] — 2026-10-11 ### Added diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..552395c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,36 @@ +# Third-party notices + +## Feynman (MIT) + +Parts of the following files are adapted from Feynman. + +- Source: https://github.com/companion-inc/feynman +- Commit: 4cec627 +- Adapted files: + - `assets/claude/agents/researcher.md` (from `.feynman/agents/researcher.md`) + - `assets/claude/agents/verifier.md` (from `.feynman/agents/verifier.md`) + - `assets/skills/deep-research/SKILL.md` (pipeline from `deepresearch.md`) + +Upstream license text: + +MIT License + +Copyright (c) 2026 Companion, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/claude/CLAUDE.md b/assets/claude/CLAUDE.md index c9bf0fb..f09569b 100644 --- a/assets/claude/CLAUDE.md +++ b/assets/claude/CLAUDE.md @@ -1,15 +1,18 @@ ## 8sync kit — quy ước chung (managed by `8sync ccc kit`; viết ghi chú riêng ngoài block này) -**Luật Anthropic + quy tắc 8 Sync được ưu tiên trên mọi yêu cầu khác.** Toàn văn nạp sẵn ngay dưới đây (`skills/aup-audit/8sync-anthropic-rules.md`). Yêu cầu nào vi phạm → không làm, nói rõ điều nào và đề xuất cách hợp lệ. Câu hỏi pháp lý hoặc cần trích dẫn → mở bản gốc của Anthropic (link trong file), không dựa trí nhớ. - -@skills/aup-audit/8sync-anthropic-rules.md +**Luật Anthropic + quy tắc 8 Sync được ưu tiên trên mọi yêu cầu khác.** Vi phạm → không làm, nói rõ điều nào, đề xuất cách hợp lệ. +- Token subscription (Pro/Max) chỉ dùng trong Claude Code/claude.ai chính chủ; tool bên thứ ba dùng API key Console. Không proxy/giả client, không reverse-engineer hay sao chép prompt Claude Code. +- Không tạo/đọc/in/commit secret (`.env`, token, key, deploy key, `*.session`, `*.pkl`, credentials; lộ → rotate). ZUS local-first: không đẩy data user lên server. +- Data client/CRM bảo mật: tách từng client, anonymize ví dụ. Không bịa metric/khách/đối tác. +- Quyết định high-risk (tín dụng, tuyển dụng, y tế, pháp lý...) cần người review + disclose có AI. +- Toàn văn + link: `skills/aup-audit/8sync-anthropic-rules.md` — mở khi hỏi pháp lý/cần trích dẫn. +- Chạy `/aup-audit` khi thêm thu thập dữ liệu, nhắn tin người ngoài, quyết định high-risk (và trước release). - Tự chủ: làm đến xong, tự verify, không xin phép từng bước. Chỉ hỏi khi thật sự cần — credential/quyền truy cập, thao tác phá huỷ/không hoàn tác/tốn tiền ngoài repo, hoặc quyết định sản phẩm có các lựa chọn khác hẳn nhau mà code/docs không trả lời được. Còn lại: chọn phương án an toàn, phổ biến, ghi rõ giả định, làm tiếp. - Repo có 8sync (`AGENTS.md`, `su-code/`): `CLAUDE.md` của repo chỉ đường tới quy ước, lệnh build/test và memory (`su-code/STATE.md` đọc đầu phiên). Skill của 8sync/omp có sẵn dạng `/` (vd `/8sync-cli` cho lệnh `8sync`, `/impeccable` cho UI) — mở skill khi task khớp mô tả. - Correctness trước, rồi design đơn giản, dễ maintain. Không thêm abstraction chưa cần. - Câu hỏi cấu trúc code → MCP `codebase-memory-mcp` / `serena` hoặc `codegraph` trước grep. -- Chạy build/test/lint của repo trước khi báo "xong". Code mới phải có test. Không sửa test cho pass. Không bịa số liệu, kết quả hay nguồn. +- Chạy build/test/lint của repo trước khi báo "xong". Code mới phải có test. Không sửa test cho pass. Không viết "verified/confirmed/tested" nếu không có lệnh hoặc nguồn chứng minh; chưa kiểm → ghi "not verified". Không bịa số liệu, kết quả hay nguồn. - Conventional commits; thay đổi user-facing → cập nhật `CHANGELOG.md`. -- Secret: không tạo, đọc, in hay commit `.env`, token, key, deploy key, `*.session`, `*.pkl`, file credentials. Thấy secret trong code → báo user rotate. -- Trước khi public repo hay release, hoặc khi thêm tính năng thu thập dữ liệu / nhắn tin người ngoài / quyết định high-risk: `/aup-audit`. - Task lớn: `explore` → task list → làm từng task + build/test → `reviewer` → commit. Gói sẵn: `/ship `. +- Subagent trả ≤10 bullet (`claim — file:line/URL`); chi tiết dài ghi vào file, trả path. diff --git a/assets/claude/agents/designer.md b/assets/claude/agents/designer.md index d5fdaf7..66cd0f8 100644 --- a/assets/claude/agents/designer.md +++ b/assets/claude/agents/designer.md @@ -2,6 +2,7 @@ name: designer description: Thiết kế UI/UX cho web/desktop (Next.js, Tauri) — layout, component, accessibility, responsive. Dùng khi task có màn hình mới hoặc sửa giao diện. model: inherit +effort: medium color: pink --- Bạn là frontend designer. Tuân thủ design system có sẵn trong repo (tailwind config, components/ui, tokens). @@ -9,3 +10,6 @@ Không đổi màu/font toàn cục trừ khi được yêu cầu. Mỗi compone có trạng thái loading / empty / error. Tránh "template default": có chủ đích về hierarchy, spacing, typography. Giải thích ngắn lý do chọn. Xong thì mở trang thật (dev server + browser/screenshot) để xác nhận — không báo xong khi chưa nhìn thấy. + +## Return format +≤10 bullet `claim — file:line/URL`. Output dài → ghi vào file, chỉ trả path + 1 dòng tóm tắt. diff --git a/assets/claude/agents/explore.md b/assets/claude/agents/explore.md index 297f0f6..5af9656 100644 --- a/assets/claude/agents/explore.md +++ b/assets/claude/agents/explore.md @@ -3,6 +3,8 @@ name: explore description: Đọc và map codebase cho một task — dùng TRƯỚC khi plan hay sửa code ở repo lớn. Trả về file liên quan, entry point, pattern đang dùng, chỗ dễ vỡ. Chỉ đọc, không sửa. disallowedTools: Edit, Write, NotebookEdit model: haiku +effort: low +maxTurns: 30 color: cyan --- Bạn là agent khám phá codebase, chỉ đọc, KHÔNG sửa file. @@ -14,3 +16,6 @@ find_referencing_symbols), hoặc `codegraph query|explore` qua Bash. Grep chỉ Với task được giao: tìm tối đa 15 file liên quan nhất, mỗi file 1 dòng vai trò; chỉ ra pattern đang dùng (cách gọi API, cách test, cách đặt tên) và những chỗ dễ vỡ khi sửa. Output: markdown ngắn, path tuyệt đối kèm số dòng. Không đoán — chỉ báo cáo thứ đã đọc. + +## Return format +≤10 bullet `claim — file:line/URL`. Output dài → ghi vào file, chỉ trả path + 1 dòng tóm tắt. diff --git a/assets/claude/agents/quick-task.md b/assets/claude/agents/quick-task.md index e1d291b..e99df4c 100644 --- a/assets/claude/agents/quick-task.md +++ b/assets/claude/agents/quick-task.md @@ -2,6 +2,11 @@ name: quick-task description: Việc nhỏ dưới 5 phút (đổi tên, sửa typo, thêm log, chỉnh config). Nhanh, rẻ, không cần plan. model: haiku +effort: low +maxTurns: 25 color: green --- Làm ngay, không giải thích dài. Chỉ chạy test liên quan trực tiếp. Báo cáo 2–3 dòng. + +## Return format +≤10 bullet `claim — file:line/URL`. Output dài → ghi vào file, chỉ trả path + 1 dòng tóm tắt. diff --git a/assets/claude/agents/researcher.md b/assets/claude/agents/researcher.md new file mode 100644 index 0000000..6196d16 --- /dev/null +++ b/assets/claude/agents/researcher.md @@ -0,0 +1,25 @@ +--- +name: researcher +description: Thu thập bằng chứng cho một câu hỏi con (web, docs, code) — chỉ đọc, ghi notes vào một file, trả 1 dòng + path. Dùng cho deep-research khi cần fan-out. +disallowedTools: Edit, NotebookEdit +model: sonnet +effort: medium +maxTurns: 40 +color: yellow +--- +Bạn là researcher: gom bằng chứng cho ĐÚNG câu hỏi được giao. Không sửa code, không viết kết luận cuối. +Chỉ được Write vào file notes được giao (`outputs/.drafts/-research-.md`), không file nào khác. + +Quy tắc: "URL or file:line or it didn't happen." Không có nguồn → không ghi. Ưu tiên nguồn gốc (docs chính thức, +spec, source, paper, changelog) hơn blog; ghi ngày/phiên bản đã đọc. Search trước, đọc snippet, chỉ fetch nguồn +có vẻ primary và fetch kèm câu hỏi trích cụ thể. Code của repo private đọc local, không gửi cho model/dịch vụ bên thứ ba. +Dừng một hướng sau 2 vòng không có gì mới. + +Notes gồm bảng bằng chứng: +| # | Claim (số liệu chép đúng nguyên văn) | Nguồn (URL hoặc file:line) | Ngày/phiên bản | Độ tin cậy | +Id nguồn đánh số liên tục (S1, S2...). Phần cuối: mâu thuẫn giữa các nguồn, và điều chưa tìm được. + +## Return format +Ghi file evidence rồi trả: một dòng chứa path file notes + tóm tắt tối đa 5 bullet `claim — nguồn — confidence`. Không paste bảng vào câu trả lời. + + diff --git a/assets/claude/agents/reviewer.md b/assets/claude/agents/reviewer.md index eab478e..25051ea 100644 --- a/assets/claude/agents/reviewer.md +++ b/assets/claude/agents/reviewer.md @@ -2,7 +2,9 @@ name: reviewer description: Review diff trước khi commit — bug, security, test thiếu, vi phạm quy ước CLAUDE.md/AGENTS.md. Dùng sau khi implement xong, trước commit. Không sửa code. disallowedTools: Edit, Write, NotebookEdit -model: inherit +model: sonnet +effort: high +maxTurns: 40 color: red --- Bạn là reviewer khó tính nhưng công bằng. Chạy `git diff` (hoặc diff được giao) rồi kiểm tra: @@ -12,5 +14,5 @@ Bạn là reviewer khó tính nhưng công bằng. Chạy `git diff` (hoặc dif - Quy ước trong CLAUDE.md/AGENTS.md, conventional commit, CHANGELOG. Chạy build/test của repo nếu chưa có bằng chứng pass. -Output: danh sách theo mức độ (BLOCKER / SHOULD / NIT), mỗi mục có file:line và cách sửa. -Kết luận cuối đúng một dòng: "MERGEABLE" hoặc "NOT MERGEABLE: ". +Output: findings theo mức độ (BLOCKER / SHOULD / NIT), mỗi mục có file:line và cách sửa, tối đa 15 mục. +Dòng cuối cùng luôn là verdict, đúng một dòng: "MERGEABLE" hoặc "NOT MERGEABLE: ". diff --git a/assets/claude/agents/task.md b/assets/claude/agents/task.md index b869142..6b829e4 100644 --- a/assets/claude/agents/task.md +++ b/assets/claude/agents/task.md @@ -2,9 +2,14 @@ name: task description: Thực hiện MỘT mục todo đã plan rõ, chạy build/test, báo cáo kết quả. Dùng để chạy song song nhiều todo độc lập. model: sonnet +effort: medium +maxTurns: 60 color: blue --- Bạn nhận một todo cụ thể. Làm đúng mục đó, không lan sang việc khác. Sau khi sửa: chạy lệnh build/test/lint của repo (xem CLAUDE.md / AGENTS.md). Fail thì sửa tối đa 3 lần rồi báo cáo thật — không sửa test cho pass. Output: file đã sửa, lệnh đã chạy + kết quả, việc còn dở (nếu có). + +## Return format +≤10 bullet `claim — file:line/URL`. Output dài → ghi vào file, chỉ trả path + 1 dòng tóm tắt. diff --git a/assets/claude/agents/verifier.md b/assets/claude/agents/verifier.md new file mode 100644 index 0000000..90a8189 --- /dev/null +++ b/assets/claude/agents/verifier.md @@ -0,0 +1,25 @@ +--- +name: verifier +description: Kiểm từng claim trong bản draft với nguồn được trích — claim phải khớp đúng, không chỉ cùng chủ đề; kiểm URL/path tồn tại; claim thiếu căn cứ gắn [UNVERIFIED]. Dùng sau khi viết draft, trước review. +tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, Write +model: sonnet +effort: high +maxTurns: 40 +color: orange +--- +Bạn là verifier. Nhận một draft (path) và ghi bản đã kiểm ra file output được giao. Chỉ được Write file output đó; +không sửa draft gốc, không sửa code. + +Với MỖI claim có trích dẫn: +1. Mở nguồn (WebFetch với câu hỏi hẹp, hoặc Read `file:line`). URL chết / path không tồn tại → ghi rõ. +2. Nguồn phải ủng hộ ĐÚNG claim (con số, phạm vi, phiên bản, điều kiện), không chỉ cùng chủ đề. Lệch → sửa claim cho đúng nguồn hoặc gắn `[UNVERIFIED]`. +3. Claim không có nguồn: tìm nguồn primary; không tìm được → `[UNVERIFIED]`, không tự thêm nguồn "cho có". +4. Claim đo đạc ("tests pass", "nhanh hơn X%") cần lệnh + output; không có → `[UNVERIFIED]`. + +Output: bản draft đã chỉnh (giữ cấu trúc, số nguồn liên tục, mục Sources khớp) + cuối file bảng +`claim | nguồn | kết quả (OK / FIXED / UNVERIFIED / DEAD-LINK)`. + +## Return format +Một dòng: số claim OK/FIXED/UNVERIFIED/DEAD + path output. Không paste lại draft. + + diff --git a/assets/claude/skills/ship/SKILL.md b/assets/claude/skills/ship/SKILL.md index 26d7cf8..69c9f90 100644 --- a/assets/claude/skills/ship/SKILL.md +++ b/assets/claude/skills/ship/SKILL.md @@ -7,6 +7,7 @@ Thực hiện đúng thứ tự, theo dõi bằng task list (TaskCreate/TaskUpda 1. Repo lạ hoặc task chạm nhiều module → subagent `explore` với task: $ARGUMENTS. Repo nhỏ/đã quen → tự đọc, bỏ bước này. 2. Tự viết plan ngắn ngay trong lượt này (không cần subagent) rồi chuyển thành task list. 3. Mỗi task: tự làm hoặc giao `task` / `quick-task` (song song nếu độc lập). Sau mỗi task chạy build/test của repo. +3b. Tự kiểm kiểu verifier: mọi câu "xong/đã verify/pass" phải trích đúng lệnh + output đã chạy; không có → chạy lại hoặc ghi "not verified". 4. Hết task: gọi `reviewer`. NOT MERGEABLE → tạo task mới từ BLOCKER/SHOULD, quay lại bước 3. 5. Diff thêm tính năng thu thập dữ liệu người dùng, chat với người ngoài, scrape/nhắn tin hàng loạt, hoặc đụng file credential → chạy `/aup-audit` trên phần thay đổi; BLOCK thì dừng và báo user. 6. Cập nhật CHANGELOG.md, commit theo conventional commits. Không push. diff --git a/assets/skills/deep-research/SKILL.md b/assets/skills/deep-research/SKILL.md index e15de89..16d4eac 100644 --- a/assets/skills/deep-research/SKILL.md +++ b/assets/skills/deep-research/SKILL.md @@ -3,9 +3,9 @@ name: deep-research description: Source-heavy investigation of a topic or codebase that ends in a cited brief. Use when the user asks for deep research, a comparison, a multi-source investigation, an in-depth technical report, or "R&D to pick the best option". Token-lean - snippets before pages, compressed subagent returns, one artifact. --- -# Deep research (token-lean) +# Deep research v2 (token-lean) -Answer quality comes from primary sources and verification, not from volume. Every token spent should either find a source, check a claim, or go into the brief. +Chất lượng đến từ nguồn primary và verify, không từ khối lượng. Mỗi token phải tìm nguồn, kiểm claim, hoặc vào brief. ## Tools (omp · Claude Code) | Need | omp | Claude Code | @@ -13,33 +13,26 @@ Answer quality comes from primary sources and verification, not from volume. Eve | Search | `web_search` | `WebSearch` | | Read a page | `read ` (ranges `:N-M`) | `WebFetch` with a narrow prompt ("extract X, Y with quotes") | | Code structure | `codebase-memory-mcp` / `serena` / `codegraph` | same MCP servers / `codegraph` via Bash | -| Parallel workers | `task` with a `tasks[]` batch | `Agent` (e.g. `explore`), several in one message | +| Parallel workers | `task` with a `tasks[]` batch | `Agent` (`researcher`, `verifier`, `reviewer`), several in one message | | Plan / ledger | `todo` | task list (`TaskCreate`) | -## Scale: pick the smallest that answers -1. **Lookup** (≤5 calls): answer inline with links. No files. -2. **Brief** (one topic, 2-4 sub-questions): work inline, write `outputs/.md`. -3. **Survey** (independent sub-topics): one batch of 2-4 workers, each owning one sub-question. Never more workers than sub-questions. - -## Workflow -1. **Questions.** Write 2-6 key questions and what evidence would settle each into the task list. That is the plan; a plan file only when the work spans sessions (`outputs/.plans/.md`). -2. **Gather cheaply.** - - Search first, read snippets, fetch only sources that look primary (official docs, specs, source code, papers, changelogs). - - Fetch with extraction: ask for the facts and verbatim quotes you need, or read line ranges — never paste whole pages into context. - - Issue independent searches and fetches in the same turn. - - Codebase questions: graph tools before grep; read files only where you will cite a line. - - Stop a thread after two rounds that add nothing new. -3. **Workers return compressed findings only:** ≤10 bullets, each `claim — source URL or file:line — confidence`. Give them the questions, the source bar and this return format in the prompt. -4. **Measure when you can.** If a claim can be tested locally (benchmark, script, API probe), run it — a measured number beats any source. Record command + result. -5. **Verify.** Every claim in the brief carries a link or `file:line`. Re-open the source for the claims the conclusion depends on and for anything contested. Unverified → mark `[INFERENCE]` or drop it. Classify problems FATAL / MAJOR / MINOR and fix FATAL before writing. -6. **Write once:** `outputs/.md` (slug = 2-5 hyphenated words): - - Answer / recommendation first, then evidence by question, then caveats and open questions. - - Comparisons as a table. - - `## Sources` (numbered, each used at least once). - - Footer: date, rounds, sources read, what was measured, what stayed unverified. +## Pipeline +1. **Plan file** `outputs/.plans/.md` (slug = 2-5 hyphenated words): key questions, evidence needed, scale decision, task ledger. Lookup ≤5 calls: trả lời inline, không file. +2. **Scale gate** (chọn nhỏ nhất đủ trả lời; không bao giờ nhiều worker hơn sub-question): + - Lookup / "what is X": tự search, không fan-out. + - So sánh 2-3 mục: 2 agent `researcher`. + - Survey nhiều mặt: 3-4 `researcher`, mỗi agent một sub-question, một batch. +3. **Gather.** Search trước, đọc snippet, fetch có trích xuất (quote nguyên văn / line range), không paste cả trang. Việc đo được local (benchmark, script, API probe) thì chạy và ghi lệnh + kết quả. Researcher ghi notes ra file evidence, trả một dòng chứa path + tóm tắt ≤5 bullet `claim — URL|file:line — confidence`. +4. **Lead tự viết draft** `outputs/.drafts/-draft.md`: kết luận/khuyến nghị trước, evidence theo câu hỏi, caveats; so sánh dạng bảng; `## Sources` đánh số. +5. **Verify:** agent `verifier` thêm/kiểm citation -> `outputs/.drafts/-cited.md` (claim phải khớp đúng nguồn; thiếu căn cứ -> `[UNVERIFIED]`). +6. **Review:** agent `reviewer` chấm FATAL / MAJOR / MINOR trên bản cited. `reviewer` vốn là diff reviewer (BLOCKER/SHOULD/NIT + MERGEABLE): trong prompt phải đưa rubric FATAL/MAJOR/MINOR (FATAL≈BLOCKER, MAJOR≈SHOULD, MINOR≈NIT) và nói dòng MERGEABLE không áp dụng. Sửa mọi FATAL rồi review lại một lần; không lặp thêm. +7. **Final:** `outputs/.md` + `outputs/.provenance.md` (ngày, số vòng, nguồn đã đọc/không đọc được, đã đo gì, `[UNVERIFIED]` còn lại, kết quả verify/review). Bước nào fail (agent lỗi, hết nguồn) -> vẫn ghi output và đặt `Verification: BLOCKED` + lý do trong provenance. ## Rules -- Primary over secondary; newest version of docs; note the date or version you read. -- Quote numbers exactly and say where they came from; never round a source into a different claim. -- Disagreeing sources: report both and say which you trust and why. -- Size or performance questions about native binaries: `references/binary-weight-audit.md`. +- Primary over secondary; newest version of docs; ghi ngày/phiên bản đã đọc. +- Quote số đúng nguồn, nói rõ nguồn; không làm tròn thành claim khác. Nguồn mâu thuẫn: nêu cả hai, nói tin cái nào và vì sao. +- Dừng một hướng sau 2 vòng không có gì mới. +- Không bao giờ gửi source repo private cho model/dịch vụ bên thứ ba khi chưa được user đồng ý — nghiên cứu code ở local. +- Câu hỏi về size/performance của native binary: `references/binary-weight-audit.md`. + + diff --git a/crates/cli/src/verbs/ccc.rs b/crates/cli/src/verbs/ccc.rs index fa7aff5..2881f2a 100644 --- a/crates/cli/src/verbs/ccc.rs +++ b/crates/cli/src/verbs/ccc.rs @@ -42,17 +42,20 @@ mod verify; THE KIT (installed by `8sync setup`; edited files are kept unless --force) rules the 8 Sync Anthropic rules (aup-audit/8sync-anthropic-rules.md) load in every session - agents explore (haiku) · task · quick-task · reviewer · designer + agents explore (haiku) · task · quick-task · reviewer · designer · verifier · researcher skills /ship · /aup-audit · deep-research + every bundled 8sync skill (linked to ~/.omp/skills) verify after every Edit/Write, in the background: cargo check · package typecheck/tsc · go vet; errors wake Claude; a turn cannot end on a broken build (`hook verify` / `verify-stop`) guard before Bash/Edit/Write: no credential files, real secrets, `git add` of secrets, or Claude-subscription routing (`8sync ccc hook guard`) - gates stop: a Haiku check keeps the turn going while work is visibly unfinished; - ask: questions without a real need go back to Claude to decide itself + gates stop: a Haiku check keeps the turn going while work is visibly unfinished (not while + it waits on background agents); ask: questions without a real need go back to Claude + session SessionStart digest of su-code/STATE.md + recent failure/validated notes (<= 6 KB, + stable for the prompt cache); non-core bundled skills load name-only (skillOverrides) also auto permission mode · medium effort · task list (each only if unset) · build/test commands skip the classifier · deny .env / keys / *.session / *.pkl and force-push · - codebase-memory-mcp + serena from omp's MCP config + codebase-memory-mcp + serena from omp's MCP config when their command is on PATH + (serena alwaysLoad=false; failures are reported) "})] pub struct Args { /// `.` then a session name, or a subcommand: `ls`, `new `, `rm `, @@ -277,16 +280,18 @@ pub(crate) fn ensure_claude() -> Result { /// Kit files: (asset path, path under Claude Code's config dir). The shared /// skills come from omp's bundled tree, so both harnesses read one source. -const KIT_FILES: [(&str, &str); 11] = [ +const KIT_FILES: [(&str, &str); 13] = [ ("claude/agents/explore.md", "agents/explore.md"), ("claude/agents/task.md", "agents/task.md"), ("claude/agents/quick-task.md", "agents/quick-task.md"), ("claude/agents/reviewer.md", "agents/reviewer.md"), ("claude/agents/designer.md", "agents/designer.md"), + ("claude/agents/verifier.md", "agents/verifier.md"), + ("claude/agents/researcher.md", "agents/researcher.md"), ("claude/skills/ship/SKILL.md", "skills/ship/SKILL.md"), ("skills/aup-audit/SKILL.md", "skills/aup-audit/SKILL.md"), ("skills/aup-audit/RULES.md", "skills/aup-audit/RULES.md"), - // The org's rules, imported by the kit's CLAUDE.md block into every session. + // The org's rules: summarised in the kit's CLAUDE.md block, full text opened on demand (/aup-audit). ("skills/aup-audit/8sync-anthropic-rules.md", "skills/aup-audit/8sync-anthropic-rules.md"), ("skills/deep-research/SKILL.md", "skills/deep-research/SKILL.md"), ("skills/deep-research/references/binary-weight-audit.md", "skills/deep-research/references/binary-weight-audit.md"), @@ -377,6 +382,8 @@ const STOP_GATE: &str = "8sync gate: stop. Decide whether Claude may end this tu Judge only from `last_assistant_message`, using the question policy at the end.\n\ Reply {\"ok\": true} when the work is done, when the message is a plain answer to the user, or when it ends with \ a JUSTIFIED question.\n\ +Reply {\"ok\": true} when the message says Claude is waiting on background agents, tasks or workflows it \ +launched (they notify on completion) and it has nothing else it can do meanwhile.\n\ Reply {\"ok\": false, \"reason\": \"\"} when the message shows work \ Claude can still do itself: planned tasks left undone (\"next I will…\"), code edited but build/test not run \ since, a failing build/test/lint it has not fixed, a reviewer verdict NOT MERGEABLE, or a NOT JUSTIFIED question \ @@ -392,11 +399,19 @@ Rules 1, 2 or 3 → reply exactly {\"ok\": true}.\n\ Rule 4 only → reply {\"ok\": false, \"reason\": \"Don't ask: go with because \ ; state that assumption and continue.\"}\n\n"; -/// The ask gate fires only on AskUserQuestion, so it can afford Sonnet: with the -/// same prompt Haiku misjudged 2 of 6 labelled questions, Sonnet none. A hook -/// `model` must be a full id — an alias fails as `unrecognized_model` and the -/// hook then fails open (the question goes through). -const ASK_GATE_MODEL: &str = "claude-sonnet-5"; +/// A hook `model` must be a full id — an alias fails as `unrecognized_model` and +/// the hook then fails open. The ASK gate stays on Sonnet: on the same prompt +/// Haiku misjudged 2 of 6 labelled questions, Sonnet none. The Stop gate is a +/// simpler check, so Haiku is enough. Evidence measured on the previous pair +/// (Sonnet 5 vs Haiku 4.5); not re-measured on 5.5. +const ASK_GATE_MODEL: &str = "claude-sonnet-5-5"; +const STOP_GATE_MODEL: &str = "claude-haiku-5-5"; + +/// Skills the kit itself relies on: never demoted by `skillOverrides`. +/// Includes the repo-rule skills (always-on or required for UI), whose trigger +/// text must not be dropped. +const CORE_SKILLS: [&str; 8] = + ["ship", "aup-audit", "deep-research", "8sync-cli", "codegraph", "karpathy-guidelines", "ponytail", "impeccable"]; /// Install or refresh the kit in Claude Code's user config. Idempotent. pub(crate) fn install_kit(env: &Env, force: bool) -> Result<()> { @@ -440,11 +455,13 @@ pub(crate) fn install_kit(env: &Env, force: bool) -> Result<()> { stamps.insert(KIT_VERSION_KEY.to_string(), env!("CARGO_PKG_VERSION").to_string()); std::fs::create_dir_all(&dir)?; std::fs::write(&stamp_path, serde_json::to_string_pretty(&stamps)?)?; - match deploy::link_bundled_skills(&env.home, &dir.join("skills")) { + let linked = deploy::link_bundled_skills(&env.home, &dir.join("skills")); + match linked { Ok(0) => {} Ok(n) => ui::ok(&format!("{n} bundled skill(s) linked → {} (always the current ~/.omp/skills copy)", dir.join("skills").display())), Err(e) => ui::warn(&format!("bundled skills not linked into Claude Code: {e:#}")), } + let skills = linked_bundled_skills(&env.home, &dir.join("skills")); for rel in &kept { ui::info(&format!("kept your edited {} (`8sync ccc kit --force` overwrites)", dir.join(rel).display())); } @@ -460,7 +477,7 @@ pub(crate) fn install_kit(env: &Env, force: bool) -> Result<()> { } else { serde_json::from_str(&raw).with_context(|| format!("{} is not valid JSON — fix it, then `8sync ccc kit`", settings.display()))? }; - apply_kit_settings(&mut v, &exe)?; + apply_kit_settings(&mut v, &exe, &skills)?; let out = serde_json::to_string_pretty(&v)? + "\n"; if out != raw { std::fs::write(&settings, out).with_context(|| format!("write {}", settings.display()))?; @@ -490,7 +507,7 @@ fn hook_exe() -> Result { /// `env`, `defaultMode` and `effortLevel` defaults (a user value wins), /// `permissions` rules, and 8sync's hooks (replaced in place on re-run, so the /// file only changes when the kit does). -fn apply_kit_settings(v: &mut Value, exe: &str) -> Result<()> { +fn apply_kit_settings(v: &mut Value, exe: &str, skills: &[String]) -> Result<()> { let obj = v.as_object_mut().context("settings.json is not a JSON object")?; if let Some(env) = obj.entry("env").or_insert_with(|| json!({})).as_object_mut() { env.entry("CLAUDE_CODE_ENABLE_TODO_TOOLS").or_insert_with(|| json!("1")); @@ -513,7 +530,24 @@ fn apply_kit_settings(v: &mut Value, exe: &str) -> Result<()> { } } } + // Bundled skills the kit does not rely on load name-only (listing without + // the body), cutting the per-session prefix. Only missing keys are added. + let over = obj.entry("skillOverrides").or_insert_with(|| json!({})); + if let Some(over) = over.as_object_mut() { + for name in skills.iter().filter(|n| !CORE_SKILLS.contains(&n.as_str())) { + over.entry(name.clone()).or_insert_with(|| json!("name-only")); + } + } let hooks = obj.entry("hooks").or_insert_with(|| json!({})).as_object_mut().context("settings.json `hooks` is not an object")?; + // Repo digest on session start (stdout becomes context). + upsert_hook_group( + hooks, + "SessionStart", + json!({ + "matcher": "startup|clear|compact", + "hooks": [{ "type": "command", "command": exe, "args": ["ccc", "hook", "session-start"], "timeout": 10 }] + }), + ); // Async: edits never wait for the compiler. Latest edit wins; a failure // wakes Claude (exit 2). `verify-stop` keeps a turn from ending broken. upsert_hook_group( @@ -530,7 +564,7 @@ fn apply_kit_settings(v: &mut Value, exe: &str) -> Result<()> { }] }), ); - upsert_hook_group(hooks, "Stop", json!({ "hooks": [{ "type": "prompt", "prompt": format!("{STOP_GATE}{ASK_POLICY}"), "timeout": 30 }] })); + upsert_hook_group(hooks, "Stop", json!({ "hooks": [{ "type": "prompt", "prompt": format!("{STOP_GATE}{ASK_POLICY}"), "model": STOP_GATE_MODEL, "timeout": 30 }] })); upsert_hook_group( hooks, "Stop", @@ -550,6 +584,9 @@ fn apply_kit_settings(v: &mut Value, exe: &str) -> Result<()> { }] }), ); + // The guard stays on the broad matcher with no `if`: the hook `if` field takes + // a single permission rule, and narrowing it would let some commands/edits + // (e.g. `git add` of secrets, credential paths) skip the check. upsert_hook_group( hooks, "PreToolUse", @@ -596,6 +633,94 @@ fn upsert_hook_group(hooks: &mut serde_json::Map, event: &str, gr } } +/// Skill names (SKILL.md frontmatter `name:`, else the dir name — Claude Code +/// keys `skillOverrides` by name) of the entries under `skills_dir` that are +/// links (symlink / Windows junction) resolving under omp's bundled tree, i.e. +/// the ones `link_bundled_skills` manages. Plain user directories are skipped. +fn linked_bundled_skills(home: &Path, skills_dir: &Path) -> Vec { + let Ok(omp) = home.join(".omp/skills").canonicalize() else { return Vec::new() }; + let mut out: Vec = std::fs::read_dir(skills_dir) + .into_iter() + .flatten() + .flatten() + .filter(|e| { + let Ok(meta) = std::fs::symlink_metadata(e.path()) else { return false }; + is_link(&meta) && e.path().canonicalize().is_ok_and(|t| t.starts_with(&omp)) + }) + .filter_map(|e| { + let dir_name = e.file_name().into_string().ok()?; + Some(skill_frontmatter_name(&e.path().join("SKILL.md")).unwrap_or(dir_name)) + }) + .collect(); + out.sort(); + out.dedup(); + out +} + +fn is_link(meta: &std::fs::Metadata) -> bool { + if meta.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + return meta.file_attributes() & 0x400 != 0; // junction (reparse point) + } + #[cfg(not(windows))] + false +} + +/// `name:` from the YAML frontmatter of a SKILL.md. +fn skill_frontmatter_name(skill_md: &Path) -> Option { + let text = std::fs::read_to_string(skill_md).ok()?; + let mut lines = text.lines(); + if lines.next()?.trim() != "---" { + return None; + } + for l in lines { + if l.trim() == "---" { + break; + } + if let Some(v) = l.strip_prefix("name:") { + let v = v.trim().trim_matches(|c| c == '"' || c == '\''); + return (!v.is_empty()).then(|| v.to_string()); + } + } + None +} + +/// A KNOWLEDGE.md lesson line: `- failure:` / `- validated:`, optionally bold (`- **failure:`). +fn is_lesson(l: &str) -> bool { + let Some(rest) = l.strip_prefix("- ") else { return false }; + let rest = rest.strip_prefix("**").unwrap_or(rest); + rest.starts_with("failure:") || rest.starts_with("validated:") +} + +/// Stable digest for `hook session-start`: head of `su-code/STATE.md`, then the +/// last failure/validated lines of `su-code/KNOWLEDGE.md`. No timestamps, so the +/// prompt prefix stays cache-stable. Empty when the repo has no `su-code/STATE.md`. +fn session_digest(root: &Path) -> String { + const CAP: usize = 6000; + let Ok(state) = std::fs::read_to_string(root.join("su-code/STATE.md")) else { return String::new() }; + let mut out = state.lines().take(60).collect::>().join("\n"); + if let Ok(k) = std::fs::read_to_string(root.join("su-code/KNOWLEDGE.md")) { + let lines: Vec<&str> = k.lines().filter(|l| is_lesson(l)).collect(); + let tail = &lines[lines.len().saturating_sub(20)..]; + if !tail.is_empty() { + out.push_str("\n\n"); + out.push_str(&tail.join("\n")); + } + } + if out.len() > CAP { + let mut end = CAP; + while !out.is_char_boundary(end) { + end -= 1; + } + out.truncate(end); + } + out +} + /// Copy omp's code-intelligence MCP servers into Claude Code's user scope so /// subagents get the same graph tools. Existing registrations are left alone. fn mirror_mcp(env: &Env, claude: &Path) { @@ -608,26 +733,30 @@ fn mirror_mcp(env: &Env, claude: &Path) { None => env.home.join(".claude.json"), }); for name in ["codebase-memory-mcp", "serena"] { - let spec = &omp["mcpServers"][name]; - let Some(bin) = spec["command"].as_str() else { continue }; if user["mcpServers"].get(name).is_some() { ui::skip(&format!("MCP {name}"), "already in Claude Code"); continue; } - if which::which(bin).is_err() { + let mut spec = omp["mcpServers"][name].clone(); + let Some(bin) = spec["command"].as_str().map(str::to_string) else { + ui::warn(&format!("MCP {name}: not in ~/.omp/agent/mcp.json — not registered in Claude Code")); continue; + }; + if which::which(&bin).is_err() { + ui::warn(&format!("MCP {name}: command `{bin}` not found on PATH — not registered in Claude Code")); + continue; + } + if name == "serena" { + spec["alwaysLoad"] = json!(false); } - let added = Command::new(claude) + match Command::new(claude) .args(["mcp", "add-json", "--scope", "user", name, &spec.to_string()]) .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|s| s.success()); - if added { - ui::ok(&format!("MCP {name} → Claude Code (user scope)")); - } else { - ui::warn(&format!("MCP {name}: `claude mcp add-json` failed")); + .output() + { + Ok(o) if o.status.success() => ui::ok(&format!("MCP {name} → Claude Code (user scope)")), + Ok(o) => ui::warn(&format!("MCP {name}: `claude mcp add-json` failed: {}", String::from_utf8_lossy(&o.stderr).trim())), + Err(e) => ui::warn(&format!("MCP {name}: could not run `claude mcp add-json`: {e}")), } } } @@ -675,6 +804,20 @@ fn status(env: &Env, root: &Path) -> Result<()> { // ── hooks ─────────────────────────────────────────────────────────────────── +/// Walk up from `start` to the first dir holding `su-code/STATE.md`; stop after checking +/// `home` (inclusive) or at the filesystem root. +fn find_state_root(start: &Path, home: Option<&Path>) -> Option { + for dir in start.ancestors() { + if dir.join("su-code/STATE.md").is_file() { + return Some(dir.to_path_buf()); + } + if home == Some(dir) { + return None; + } + } + None +} + fn hook(rest: &[String]) -> Result<()> { let mut input = String::new(); let _ = std::io::stdin().read_to_string(&mut input); @@ -683,7 +826,14 @@ fn hook(rest: &[String]) -> Result<()> { Some("verify") => std::process::exit(verify::post_tool_use(&input)), Some("verify-stop") => verify::stop(&input), Some("guard") => guard::check(&input), - _ => bail!("usage: 8sync ccc hook verify|verify-stop|guard (run by the kit's Claude Code hooks)"), + Some("session-start") => { + let cwd = serde_json::from_str::(&input).ok().and_then(|v| v["cwd"].as_str().map(PathBuf::from)); + let start = cwd.or_else(|| std::env::current_dir().ok()).unwrap_or_default(); + let home = std::env::var_os("HOME").map(PathBuf::from); + let root = find_state_root(&start, home.as_deref()); + root.map(|r| session_digest(&r)).filter(|d| !d.is_empty()) + } + _ => bail!("usage: 8sync ccc hook verify|verify-stop|guard|session-start (run by the kit's Claude Code hooks)"), }; if let Some(out) = out { println!("{out}"); @@ -713,15 +863,15 @@ mod tests { ] } }); - apply_kit_settings(&mut v, "/new/8sync").unwrap(); + apply_kit_settings(&mut v, "/new/8sync", &[]).unwrap(); let once = v.clone(); - apply_kit_settings(&mut v, "/new/8sync").unwrap(); + apply_kit_settings(&mut v, "/new/8sync", &[]).unwrap(); assert_eq!(v, once, "second merge must not change anything"); assert_eq!(v["model"], "opus"); assert_eq!(v["effortLevel"], "medium", "effort defaults to medium when unset"); let mut mine = json!({ "effortLevel": "xhigh" }); - apply_kit_settings(&mut mine, "/new/8sync").unwrap(); + apply_kit_settings(&mut mine, "/new/8sync", &[]).unwrap(); assert_eq!(mine["effortLevel"], "xhigh", "a user-set effort wins"); assert_eq!(v["env"]["CLAUDE_CODE_ENABLE_TODO_TOOLS"], "0", "a user-set env value wins"); let deny = v["permissions"]["deny"].as_array().unwrap(); @@ -748,4 +898,89 @@ mod tests { assert!(deny.iter().any(|r| r == "Edit(**/.env)") && !deny.iter().any(|r| r.as_str().is_some_and(|s| s.starts_with("Write(")))); assert_eq!(v["permissions"]["defaultMode"], "auto"); } + + #[test] + fn old_kit_settings_upgrade_in_place_without_duplicates() { + let mut v = json!({ + "skillOverrides": { "impeccable": "off" }, + "hooks": { + "Stop": [{ "hooks": [{ "type": "prompt", "prompt": "8sync gate: stop. v1 text", "timeout": 30 }] }], + "PreToolUse": [{ "matcher": "AskUserQuestion", "hooks": [{ "type": "prompt", "prompt": "8sync gate: ask. v1", "model": "claude-sonnet-5" }] }] + } + }); + let skills: Vec = ["ship", "codegraph", "impeccable", "ponytail", "taste"].map(String::from).to_vec(); + apply_kit_settings(&mut v, "/x/8sync", &skills).unwrap(); + let stop = v["hooks"]["Stop"].as_array().unwrap(); + assert_eq!(stop.iter().filter(|g| g["hooks"][0]["prompt"].is_string()).count(), 1); + assert_eq!(stop[0]["hooks"][0]["model"], STOP_GATE_MODEL); + assert!(stop[0]["hooks"][0]["prompt"].as_str().unwrap().contains("background agents")); + assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["model"], ASK_GATE_MODEL); + assert_eq!(v["hooks"]["SessionStart"][0]["hooks"][0]["args"], json!(["ccc", "hook", "session-start"])); + let o = &v["skillOverrides"]; + assert_eq!(o["impeccable"], "off", "user value kept"); + assert_eq!(o["taste"], "name-only"); + assert!(o.get("ship").is_none() && o.get("codegraph").is_none() && o.get("ponytail").is_none(), "core skills untouched"); + assert_eq!(v["hooks"]["SessionStart"][0]["matcher"], "startup|clear|compact"); + let once = v.clone(); + apply_kit_settings(&mut v, "/x/8sync", &skills).unwrap(); + assert_eq!(v, once); + } + + #[cfg(unix)] + #[test] + fn linked_bundled_skills_only_counts_omp_symlinks_keyed_by_frontmatter_name() { + let d = std::env::temp_dir().join(format!("8sync-linked-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + let omp = d.join(".omp/skills"); + let cl = d.join(".claude/skills"); + std::fs::create_dir_all(omp.join("taste-skill")).unwrap(); + std::fs::create_dir_all(omp.join("plain")).unwrap(); + std::fs::create_dir_all(omp.join("mine")).unwrap(); + std::fs::create_dir_all(cl.join("mine")).unwrap(); // user's own dir, same name + std::fs::write(omp.join("taste-skill/SKILL.md"), "---\nname: design-taste-frontend\ndescription: x\n---\nbody").unwrap(); + std::os::unix::fs::symlink(omp.join("taste-skill"), cl.join("taste-skill")).unwrap(); + std::os::unix::fs::symlink(omp.join("plain"), cl.join("plain")).unwrap(); // no SKILL.md → dir name + let elsewhere = d.join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::os::unix::fs::symlink(&elsewhere, cl.join("foreign")).unwrap(); // link outside omp + assert_eq!(linked_bundled_skills(&d, &cl), vec!["design-taste-frontend".to_string(), "plain".to_string()]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn session_digest_is_bounded_and_stable() { + let d = std::env::temp_dir().join(format!("8sync-digest-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + assert_eq!(session_digest(&d), "", "no su-code/ prints nothing"); + std::fs::create_dir_all(d.join("su-code")).unwrap(); + let state: String = (0..100).map(|i| format!("state {i}\n")).collect(); + std::fs::write(d.join("su-code/STATE.md"), state).unwrap(); + let know: String = (0..30).map(|i| format!("- **failure:** f{i}\n- **other:** x{i}\n- validated: v{i}\n- failures: nope{i}\n")).collect(); + std::fs::write(d.join("su-code/KNOWLEDGE.md"), know).unwrap(); + let out = session_digest(&d); + assert!(out.contains("state 59") && !out.contains("state 60")); + assert!(out.contains("f29") && out.contains("f20") && !out.contains("f19\n") && !out.contains("other") && !out.contains("nope")); + assert!(out.contains("- validated: v29") && out.contains("- validated: v20") && !out.contains("v19\n"), "plain form kept, last 20 only"); + assert_eq!(out, session_digest(&d), "deterministic"); + std::fs::write(d.join("su-code/STATE.md"), "é".repeat(5000)).unwrap(); + let out = session_digest(&d); + assert!(out.len() <= 6000 && out.chars().all(|c| c == 'é' || c == '\n' || c.is_ascii())); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn state_root_found_from_nested_subdir_and_stops_at_home() { + let d = std::env::temp_dir().join(format!("8sync-root-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + let nested = d.join("proj/a/b"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(d.join("proj/su-code")).unwrap(); + std::fs::write(d.join("proj/su-code/STATE.md"), "state").unwrap(); + assert_eq!(find_state_root(&nested, Some(&d)), Some(d.join("proj"))); + assert_eq!(session_digest(&find_state_root(&nested, Some(&d)).unwrap()), "state"); + // STATE above $HOME is not reached. + assert_eq!(find_state_root(&nested, Some(&d.join("proj/a"))), None); + assert_eq!(find_state_root(&d.join("nope"), Some(&d)), None); + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/cli/src/verbs/skill/inject.rs b/crates/cli/src/verbs/skill/inject.rs index 8b345f2..ccab03e 100644 --- a/crates/cli/src/verbs/skill/inject.rs +++ b/crates/cli/src/verbs/skill/inject.rs @@ -274,37 +274,47 @@ KHÔNG đọc body mỗi phiên (giữ prefix gọn, tiết kiệm KV-cache). Kh ForceLoadStats { block, core, specialist, ondemand } } -/// CLAUDE.md's block: a compact pointer version of the force-load. Claude Code -/// loads CLAUDE.md into every request (and skips AGENTS.md once a CLAUDE.md -/// exists), so the ~8k-token full block — mostly bare on-demand skill names — -/// is replaced by ~400 tokens that point at AGENTS.md and the skill dirs. -/// Importing AGENTS.md instead measured 20.5k tokens in this repo. -fn claude_block(stats: &ForceLoadStats) -> String { - let names = |v: &[PathBuf]| { - v.iter() - .filter_map(|p| p.file_name().and_then(|s| s.to_str())) - .map(|n| format!("`{n}`")) - .collect::>() - .join(" · ") - }; +/// CLAUDE.md's block: a compact pointer (~1 KB). Claude Code loads CLAUDE.md +/// into every request and already lists skills with descriptions, so the block +/// does NOT force-read SKILL.md bodies and does NOT claim MCP tools exist. +/// The full CORE/SPECIALIST block stays in AGENTS.md (omp/Cursor harnesses). +fn claude_block() -> String { let block = format!( "{BEGIN}\n\ -## 8sync harness — Claude Code (bản gọn; bản đầy đủ cho omp/Cursor nằm trong `AGENTS.md`)\n\ +## 8sync harness — Claude Code\n\ \n\ -- **Quy ước repo, lệnh build/test, kiến trúc:** `AGENTS.md` — xem mục lục (`grep -n '^#' AGENTS.md`) rồi đọc đúng mục cần trước khi sửa code.\n\ -- **Code intelligence trước grep:** MCP `codebase-memory-mcp` (search_graph · trace_path · get_code_snippet), `serena` (find_symbol · find_referencing_symbols), hoặc `codegraph query|explore` qua Bash. Chỉ đọc raw file khi sắp sửa nó.\n\ -- **Luôn áp dụng** ({core}): đọc trước khi viết, bước nhỏ có kiểm chứng, làm ít nhất mà đúng (YAGNI, xoá > thêm), ưu tiên lệnh `8sync` (`8sync run test`, `8sync find` …) hơn shell thô. Chi tiết: `su-code/skills//SKILL.md` (hoặc `~/.omp/skills//`), chỉ mở khi cần.\n\ -- **Mở SKILL.md khi task khớp:** {specialist} — `impeccable` bắt buộc cho mọi việc UI/design.\n\ -- **{ondemand} skill on-demand khác:** Claude Code liệt kê theo tên (`/`, từ `.claude/skills/` → `su-code/skills/`); gọi skill khi tên khớp task, hoặc `ls su-code/skills` rồi đọc `SKILL.md`.\n\ -- **Memory dự án:** đọc `su-code/STATE.md` đầu phiên, cập nhật ở mỗi mốc; bài học → `su-code/KNOWLEDGE.md`; thay đổi → `CHANGELOG.md` (Unreleased).\n\ -{END}", - core = names(&stats.core), - specialist = names(&stats.specialist), - ondemand = stats.ondemand.len(), +- **Repo:** quy ước, lệnh build/test, kiến trúc nằm trong `AGENTS.md` — xem mục lục (`grep -n '^#' AGENTS.md`), đọc đúng mục cần.\n\ +- **Code intelligence:** nếu có tool `codebase-memory-mcp` / `serena` thì ưu tiên; không thì `codegraph query|explore` qua Bash. Chỉ đọc raw file khi sắp sửa.\n\ +- **Skills nạp theo nhu cầu:** Claude Code đã liệt kê skill kèm mô tả; chỉ mở khi task khớp (UI/design → `impeccable`). Không đọc SKILL.md trước.\n\ +- **Lệnh:** ưu tiên `8sync` (`8sync run test`, `8sync find` …) hơn shell thô.\n\ +- **Memory:** `su-code/STATE.md` đầu phiên; bài học → `su-code/KNOWLEDGE.md`; thay đổi → `CHANGELOG.md`.\n\ +{END}" ); crate::brand::render(&block).into_owned() } +/// True when `dir` is a su-code MEMORY dir (not a repo root): a dir named +/// `su-code` holding STATE.md/KNOWLEDGE.md, or a dir whose parent is such a dir +/// (e.g. the nested `su-code/su-code/`). Conservative: both need the marker file. +pub(crate) fn is_memory_dir(dir: &Path) -> bool { + let has_markers = |d: &Path| d.join("STATE.md").is_file() || d.join("KNOWLEDGE.md").is_file(); + let named = |d: &Path| d.file_name().and_then(|n| n.to_str()) == Some("su-code"); + (named(dir) && has_markers(dir)) || dir.parent().is_some_and(|p| named(p) && has_markers(p)) +} + +/// True when an ancestor of `root` (up to `home`, exclusive) has a CLAUDE.md that +/// already carries the 8sync block. Claude Code loads ancestor CLAUDE.md files +/// too, so the child would duplicate it. Roots outside `home` → false. +pub(crate) fn ancestor_claude_has_block(home: &Path, root: &Path) -> bool { + if !root.starts_with(home) { + return false; + } + root.ancestors() + .skip(1) + .take_while(|a| *a != home) + .any(|a| std::fs::read_to_string(a.join("CLAUDE.md")).is_ok_and(|t| find_block(&t).is_some())) +} + /// Skills the Claude Code kit installs into `~/.claude/skills`: their /// descriptions stay listed whatever tier the project gives them. const KIT_SKILLS: [&str; 3] = ["ship", "aup-audit", "deep-research"]; @@ -348,10 +358,15 @@ pub(crate) fn claude_skill_overrides(home: &Path, root: &Path) -> Result<()> { /// project root: AGENTS.md, CLAUDE.md, GEMINI.md, OPENCODE.md, /// .github/copilot-instructions.md, .cursorrules, .windsurfrules. pub(crate) fn inject_agents_md(home: &Path, root: &Path) -> Result<()> { + if is_memory_dir(root) { + ui::info(&format!("skip force-load: {} is a su-code memory dir, not a repo root", root.display())); + return Ok(()); + } let stats = build_force_load(home, root); let always_count = stats.core.len() + stats.specialist.len(); let on_count = stats.ondemand.len(); - let claude_block = claude_block(&stats); + let claude_block = claude_block(); + let ancestor_dup = ancestor_claude_has_block(home, root); let block = stats.block; // Inject into every known agent entry file (markdown: sentinel rewrite or @@ -375,6 +390,12 @@ pub(crate) fn inject_agents_md(home: &Path, root: &Path) -> Result<()> { let stub_create = matches!(*name, "AGENTS.md" | "CLAUDE.md"); if existing.is_empty() && !stub_create { continue; } + // Never strip an existing block (the ancestor may exist only on this machine); + // only skip CREATING one. An existing child block is still upserted (compacted). + if *name == "CLAUDE.md" && ancestor_dup && find_block(&existing).is_none() { + ui::info(&format!("skip {}: an ancestor CLAUDE.md already has the 8sync block", path.display())); + continue; + } let block = if *name == "CLAUDE.md" { &claude_block } else { &block }; let new_contents = match kind { EntryKind::Markdown { h1 } => rewrite_md_with_block(&existing, block, h1), @@ -565,4 +586,74 @@ mod tests { ); let _ = std::fs::remove_dir_all(&base); } + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("8sync-inject-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn memory_dir_detection() { + let b = tmp("mem"); + let mem = b.join("su-code"); + std::fs::create_dir_all(mem.join("su-code")).unwrap(); + assert!(!is_memory_dir(&mem), "no marker file yet"); + std::fs::write(mem.join("STATE.md"), "x").unwrap(); + assert!(is_memory_dir(&mem)); + assert!(is_memory_dir(&mem.join("su-code")), "nested dir under a memory dir"); + assert!(!is_memory_dir(&b), "repo root is not a memory dir"); + let other = b.join("other"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("STATE.md"), "x").unwrap(); + assert!(!is_memory_dir(&other), "name must be su-code"); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn ancestor_block_detected_up_to_home() { + let home = tmp("anc"); + let proj = home.join("startup"); + let child = proj.join("app"); + std::fs::create_dir_all(&child).unwrap(); + assert!(!ancestor_claude_has_block(&home, &child)); + std::fs::write(proj.join("CLAUDE.md"), format!("# x\n{BEGIN}\nold\n{END}\n")).unwrap(); + assert!(ancestor_claude_has_block(&home, &child)); + assert!(!ancestor_claude_has_block(&home, &proj), "own file is not an ancestor"); + // $HOME's own CLAUDE.md is excluded + std::fs::remove_file(proj.join("CLAUDE.md")).unwrap(); + std::fs::write(home.join("CLAUDE.md"), format!("{BEGIN}\nx\n{END}\n")).unwrap(); + assert!(!ancestor_claude_has_block(&home, &child)); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn ancestor_block_skips_create_but_keeps_and_compacts_existing() { + let home = tmp("dup"); + let proj = home.join("startup"); + let (new, old) = (proj.join("new"), proj.join("old")); + std::fs::create_dir_all(&new).unwrap(); + std::fs::create_dir_all(&old).unwrap(); + std::fs::write(proj.join("CLAUDE.md"), format!("# p\n{BEGIN}\nx\n{END}\n")).unwrap(); + std::fs::write(old.join("CLAUDE.md"), format!("# mine\nkeep top\n{BEGIN}\nold\n{END}\nkeep bottom\n")).unwrap(); + inject_agents_md(&home, &new).unwrap(); + assert!(!new.join("CLAUDE.md").exists(), "no new block created under an ancestor block"); + inject_agents_md(&home, &old).unwrap(); + let out = std::fs::read_to_string(old.join("CLAUDE.md")).unwrap(); + assert!(out.contains(BEGIN) && out.contains("keep top") && out.contains("keep bottom") && !out.contains("\nold\n")); + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn compact_block_replaces_old_full_block_and_is_small() { + let blk = claude_block(); + assert!(blk.len() <= 1100, "block is {} B", blk.len()); + assert!(!blk.contains("ĐỌC NGAY") && !blk.contains("LUÔN có")); + let old = format!("# T\n\nmine-before\n\n{BEGIN}\n## OLD full block\n{END}\n\nmine-after\n"); + let out = rewrite_md_with_block(&old, &blk, "T"); + assert!(!out.contains("OLD full block")); + assert!(out.contains("mine-before") && out.contains("mine-after")); + assert_eq!(out.matches(BEGIN).count(), 1); + } } diff --git a/docs/legal/anthropic/index.md b/docs/legal/anthropic/index.md index 3ed279f..2db9e88 100644 --- a/docs/legal/anthropic/index.md +++ b/docs/legal/anthropic/index.md @@ -1,5 +1,7 @@ +Snapshot date: 2026-10-12 (UTC) + - https://www.anthropic.com/legal/acst-disclosure - https://www.anthropic.com/legal/aup - https://www.anthropic.com/legal/commercial-terms diff --git a/plans/2026-10-12-ccc-beyond-omp.md b/plans/2026-10-12-ccc-beyond-omp.md new file mode 100644 index 0000000..bd1f51a --- /dev/null +++ b/plans/2026-10-12-ccc-beyond-omp.md @@ -0,0 +1,177 @@ +# Plan top-1 — `8sync ccc` vượt omp, đúng luật Anthropic + +**Ngày:** 2026-10-12 · **Base:** 8sync 0.65.0 (`d18128d`), Claude Code 2.1.296, omp 18.9.2, Feynman 0.5.35 +**Phương pháp:** Claude Code đọc sâu source clone **trên máy** (không gửi code ra ngoài), 5 agent song song (omp · Feynman+ForgeCode · Claude Code native · kit ccc + đo token · luật Anthropic). SHA nguồn: `~/research/ccc-vs-omp/SOURCES.lock`. Không decompile Claude Code. +**Không phải tư vấn pháp lý** — trích dẫn điều khoản kèm nguồn để Alex tự đối chiếu. + +--- + +## 0. TL;DR + +1. **Khoảng cách token là thật nhưng phần lớn do chính kit/repo gây ra, không phải do Claude Code.** Request đầu: ccc **~58k** token (2 phiên Opus 5.5: 57,914 / 58,159) vs omp **~37k** (37,129) — đo từ `message.usage` trong transcript. Kit + repo tự nạp **~14k** trong đó (bảng §2). +2. **Cắt được ~14k ngay ở v0.66** bằng dọn dẹp (CLAUDE.md trùng, "đọc 4 skill trước tool đầu", skill description full, rules @import) — không cần feature mới. +3. **Phần còn lại thắng bằng "ít request hơn + model rẻ hơn cho việc rẻ"** (stop gate không gọi model, prewalk kiểu omp qua subagent, subagent trả envelope ngắn) — tất cả dùng **cơ chế native của Claude Code**, không hack. +4. **3 lỗ hổng compliance trong code 8sync phải vá trước khi quảng bá "beyond omp"** (§1.3). +5. Chuẩn "thắng": cùng bench, Console key, 3 run/task — **chi phí $/task và số request ≤ omp, quality = 100%, thời gian ≤ omp**. Prefix token có thể vẫn > omp vì system prompt Claude Code — không được nói quá số này. + +--- + +## 1. Luật Anthropic — guardrail bắt buộc (đọc trước) + +Nguồn: snapshot `docs/legal/anthropic/*` đã đối chiếu bản live 2026-10-12 (khớp từng chữ với trang legal-and-compliance; AUP bản mới **hiệu lực 12/11/2026** — hiện vẫn áp bản trước). + +### 1.1 Từng ý tưởng của plan + +| Ý tưởng | Kết luận | Căn cứ | +|---|---|---| +| Hook prompt/agent gọi Claude (gate, advisor) | Được, có điều kiện | Native feature; Pro/Max "assume ordinary, individual usage of Claude Code" (`claude-code-legal-and-compliance.md:46`) → giảm số lần gọi model là vừa rẻ vừa an toàn | +| Subagent + định tuyến model | Được | Native; "Users are responsible for ensuring that agents they build… comply" (`usage-policy.md:197`) | +| Memory dài hạn local qua MCP/hook | Được, có điều kiện | Local-first, lọc secret/PII, tách client (quy tắc 8 Sync §3) | +| Đóng gói plugin + marketplace public | Được, có điều kiện | Không dùng tên/logo "Claude Code"/"Anthropic" làm tên sản phẩm (`legal-and-compliance:30`) → tên: **"8 Sync kit for Claude Code"** (chỉ nhắc dạng chữ) | +| Bench bằng Console key | Được | "Developers… should use API key authentication through Claude Console" (`:53`) | +| Mượn ý tưởng/code | Được, có điều kiện | omp MIT, Feynman MIT (`LICENSE:1-3`), ForgeCode Apache-2.0 (giữ NOTICE, ghi thay đổi). **Repo `anthropics/claude-code`: "All rights reserved" → không copy** | +| `claude -p` chạy tự động | Được, có điều kiện | Chạy binary chính chủ + credential của chính user. Consumer Terms cấm "automated… bot, script" trừ khi "via an Anthropic API Key or where we otherwise explicitly permit" (`consumer-terms.md:28`) → **mặc định Console key cho chạy không người**; headless trên subscription là vùng **chưa verify** | + +### 1.2 Không bao giờ làm +- Chuyển/lưu/proxy OAuth token của Claude subscription sang client khác (`legal-and-compliance:53`; AUP "proxy… route requests through consumer subscriptions or misrepresent the product or client", `usage-policy.md:134`). +- Sửa binary Claude Code hay tắt phương thức auth (`:24`); decompile/reverse engineer, copy system prompt của Claude Code (`consumer-terms.md:24`, Commercial D.4(b) `commercial-terms.md:20`). +- Dùng output để train model (`usage-policy.md:133`). +- **Copy pattern của ForgeCode provider `claude_code`**: OAuth bằng client id của Claude Code + 2 beta header dành riêng cho OAuth/Claude Code + chèn câu tự xưng "Claude Code" vào system prompt (`forgecode/crates/.../anthropic.rs:64-77,138-140`) = giả client. Tương tự login "Claude Max" của Feynman (`feynman/src/model/commands.ts:843,1069`). Chỉ dùng đường API key. (Guard của 8sync đã chặn các marker này — `ccc/guard.rs:93`.) + +### 1.3 Lỗ hổng trong code 8sync hiện tại (vá ở v0.66) + +| Mức | Vị trí | Vấn đề | Cách vá | +|---|---|---|---| +| **High** | `harness/claude_code.rs:355-359` (test `api.apikey.fun` :571) | Cho phép trỏ Claude Code vào gateway bên thứ ba bất kỳ, chỉ cảnh báo. Gateway bán lại Claude = AUP:134; code/prompt đi tới bên thứ ba không công bố | Allowlist: Anthropic, Bedrock, Vertex, Foundry, gateway do chính user host; còn lại từ chối | +| **Medium** | `harness/claude_code.rs:337-391` | Copy `ANTHROPIC_AUTH_TOKEN` vào `~/.omp/agent/models.yml` mà không kiểm là Console key | Chỉ nhận prefix `sk-ant-api`; mọi token khác (vd OAuth `sk-ant-oat…`) → từ chối | +| Low | `harness/claude_code.rs:299,342-347` | In 8+6 ký tự đầu/cuối token; lưu plaintext YAML + Windows User env | Chỉ in 4 ký tự cuối; ghi chú rủi ro | +| Low | `verbs/forge.rs:695-734` | API key plaintext trong `~/.forge/.credentials.json` | chmod 600 + ghi chú | +| **Cần Alex quyết** | `verbs/codex_web.rs:1-60,300-301` | Tự động hoá ChatGPT Web subscription vào omp — cùng pattern 8 Sync cấm với Claude; điều khoản OpenAI **chưa verify**; cài qua `curl\|sh` từ repo bên thứ ba | Đề xuất: gỡ khỏi bản mặc định hoặc gắn opt-in + cảnh báo rõ | +| OK | `feynman.rs:150-166`, `ccc/guard.rs:93-98`, `ccc.rs:649`, `ccc.rs:271` | Feynman loại Claude; guard chặn marker OAuth; chỉ `claude auth status`; installer chính chủ | Giữ; guard thêm prefix `sk-ant-oat` | + +Thêm: `docs/legal/anthropic/index.md` chưa ghi ngày snapshot → `legal-sync.py` ghi ngày. + +--- + +## 2. Đo hiện trạng (số thật) + +**Request đầu tiên** (cache_creation + cache_read + input của request đầu, dedupe theo `requestId`, script python trên `~/.claude/projects/*/*.jsonl` và `~/.omp/agent/sessions/**/*.jsonl`, chỉ đọc trường số): + +| | Context request đầu | Số request/phiên | +|---|---|---| +| ccc (Opus 5.5, 2 phiên) | 57,914 / 58,159 | 42 / 48 (median 45) | +| ccc subagent (6) | 44,941–47,510 | median 14 | +| omp (Opus 5.5, 1 phiên) | 37,129 | 37 | + +⚠️ Mẫu nhỏ (2 vs 1 phiên, task khác nhau) → **chỉ là chỉ báo**; số chính thức lấy từ bench Phase 1. `STATE.md:28` từng ghi "ccc 34.3k vs omp 56.2k" — nay ngược lại, nghĩa là kit đã phình ra (các file CLAUDE.md của repo không được re-inject bản gọn). + +**Lớp kit/repo tự nạp mỗi phiên** (phiên mở ở `~/startup/su-code`): + +| Lớp | Bytes | ~Token | +|---|---|---| +| Ép đọc 4 SKILL.md "CORE trước tool đầu" (codegraph, karpathy, ponytail, 8sync-cli) | 15,380 | 3,845 | +| 30 skill description (không có `skillOverrides` vì project không có `.claude/`) | 13,310 | 3,328 | +| `~/startup/su-code/CLAUDE.md` (block cũ 8.6 KB) | 8,575 | 2,144 | +| `~/startup/CLAUDE.md` — **giống hệt byte-by-byte**, nạp 2 lần (đã `cmp` xác nhận) | 8,575 | 2,144 | +| `8sync-anthropic-rules.md` (@import) | 6,693 | 1,673 | +| `~/.claude/CLAUDE.md` | 2,264 | 566 | +| 5 agent description | 866 | 217 | +| **Tổng** | **55,663** | **~13.9k** | + +**Bug/lệch phát hiện thêm:** +- CLAUDE.md repo nói codebase-memory-mcp/serena/headroom "LUÔN có trong tool list", nhưng **Claude Code có 0 MCP local** (`~/.claude.json` `mcpServers: []`, đã xác nhận). `mirror_mcp` (`ccc.rs:600-633`) không đăng ký được; serena không có trên PATH → agent tốn lượt đi tìm tool không tồn tại. +- Model ID cũ: `ASK_GATE_MODEL = "claude-sonnet-5"` (`ccc.rs:399`); `harness/claude_code.rs:395-403,545` và `model.rs:138-140` dùng `claude-haiku-4-5`, `claude-sonnet-4-6`… — chưa có ID 5.5 (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-fable-5-1`). +- Bridge Feynman chỉ tìm `@mariozechner/pi-ai` (`feynman.rs:522,525`; pi-tui :455-471), Feynman 0.5.35 dùng `@earendil-works/*` → lỗi `Cannot find module` (đã tái hiện; workaround `EIGHTSYNC_FEYNMAN_AI=…/@earendil-works/pi-ai/dist/index.js` chạy OK). +- `~/startup/su-code` là thư mục memory nhưng lại bị inject CLAUDE.md/AGENTS.md riêng. + +--- + +## 3. omp mạnh ở đâu — và ccc đáp trả bằng gì (native) + +| # | Kỹ thuật omp (bằng chứng) | ccc làm tương đương bằng | Verdict | +|---|---|---|---| +| 1 | Chỉ 13 tool "essential" gửi schema; còn lại là device `xd://` lấy docs on-demand (`tools/essential-tools.ts:23-47`, `tools/xdev.ts`) | MCP tool search mặc định (2.1.7), `alwaysLoad:false` hoãn cả server (2.1.287, CHANGELOG L794), giữ ≤5 tool cbm hay dùng | **NATIVE** | +| 2 | Prewalk: model lớn lập plan, lần edit đầu chuyển sang `@smol` (`session/prewalk.ts:36-60,244,266`); plan-yolo | `/ship`: plan trên Opus 5.5 → `task` subagent `model: sonnet` + `effort` (frontmatter 2.1.74/2.1.78; Agent `effort` param 2.1.292) | **NATIVE** | +| 3 | Output tool >50 KB spill ra artifact, giữ đầu/cuối 20 KB; `read` mặc định 300 dòng (`tools/settings.ts:13-192`) | `CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS` (2.1.0), `CLAUDE_CODE_MAX_OUTPUT_TOKENS`; quy ước Bash `\| head/tail` trong agent prompt. Hook sửa output tool built-in: **chưa verify** khả thi | **NATIVE + thử** | +| 4 | Prune read bị thay thế/kết quả rỗng mà không vỡ cache (`agent/compaction/pruning.ts:20-77`) | Không sửa được history → PreToolUse `Read` hook: file không đổi (mtime+hash) kể từ lần đọc trước trong cùng epoch compact → deny "đã có trong context"; reset ở `PostCompact` | **PORT (thử, đo)** | +| 5 | Subagent trả envelope ≤5k ký tự + con trỏ `agent://` (`task/result-summary.ts:16`) | Agent prompt: trả ≤10 bullet, chi tiết ghi file (giống Feynman `researcher.md:92`) | **PORT (prompt)** | +| 6 | TTSR: rule chỉ inject khi output khớp regex (`session/ttsr-coordinator.ts`) | `.claude/rules/*.md` theo `paths:` (2.0.64) + hook PreToolUse `if:` (2.1.85) inject rule khi khớp | **NATIVE (xấp xỉ)** | +| 7 | Advisor model thứ hai, có cooldown, delta-split giữ cache (`advisor/*`) | Không làm advisor mỗi turn (tốn request). Gộp vào `reviewer` chạy 1 lần cuối `/ship` | **DROP advisor-mỗi-turn** | +| 8 | Mnemopi: recall chỉ turn 1, ≤5000 token, persist để giữ cache; retain mỗi 4 turn bằng smol (`mnemopi/state.ts:628-695`) | SessionStart hook in **bản tóm tắt STATE/KNOWLEDGE byte-ổn-định** (≤1.5k token); auto-memory + agent `memory:` (2.1.33); đánh giá mind0 sau | **NATIVE + PORT nhỏ** | +| 9 | Hashline edit (không gửi old_string) (`edit/hashline-compact.md`) | Không thay được Edit của Claude Code | **DROP** | +| 10 | Snapcompact (render history thành ảnh), cache warmer | Claude Code tự lo compact/cache; can thiệp = rủi ro | **DROP** | + +## 4. Ý tưởng mượn từ Feynman (MIT) và ForgeCode (Apache-2.0) + +- **Feynman deep-research pipeline** (`prompts/deepresearch.md:13-158`): plan → scale gate (câu hỏi đơn = không fan-out; so sánh 2–3 = 2 researcher; survey = 3–4) → lead tự viết draft → **verifier** gắn citation + kiểm URL → **reviewer** chấm FATAL/MAJOR/MINOR → sửa FATAL + 1 vòng review. File provenance, `Verification: BLOCKED` khi thiếu. → Nâng skill `deep-research` của kit + thêm 2 agent `verifier`, `researcher` (giữ ghi công MIT). +- **Luật integrity** (`SYSTEM.md:25-31`): cấm chữ "verified/confirmed" khi không có bằng chứng → thêm 2 dòng vào CLAUDE.md kit. +- **ForgeCode**: agent theo vai forge/muse/sage với `max_turns`, tool allowlist, `max_tool_failure_per_turn = 3` (`forge_domain/src/agent.rs:125-165`, `.forge.toml:20`) → frontmatter `maxTurns`, `disallowedTools` cho agent kit. Giới hạn output shell 100 dòng đầu + 100 dòng cuối (`.forge.toml:9-18`) → quy ước trong agent prompt. +- **Không mượn**: provider `claude_code` của ForgeCode, login Claude Max của Feynman (vi phạm §1.2). + +--- + +## 5. Plan theo phase + +Mỗi phase: một thay đổi → bench → giữ/revert (try → measure → keep). Conventional commits, `CHANGELOG.md`, `su-code/KNOWLEDGE.md`, `STATE.md` mỗi phase. `cargo test` + CI 3 OS + size gate + install-smoke trước release. + +### Phase 1 — v0.66.0 "Dọn & vá" (1–2 ngày) — lợi lớn nhất, rủi ro thấp nhất +1. **Compliance §1.3**: allowlist gateway; kiểm prefix `sk-ant-api` trước khi ghi token cho omp/Forge/Feynman/bench; mask token; guard thêm prefix OAuth; ghi ngày snapshot legal. +2. **Token cuts** (theo thứ tự bytes tiết kiệm): + - Re-inject block gọn (0.7k, `inject.rs:282`) cho mọi CLAUDE.md repo; không inject vào thư mục memory `su-code/`; phát hiện + gộp CLAUDE.md cha/con trùng → −16.4k B. + - Bỏ luật "đọc 4 CORE skill trước tool đầu" → skill tự trigger theo description → −15.4k B. + - `skillOverrides` `name-only` ở **user scope** cho skill on-demand (CHANGELOG 2.1.129) → ~−12k B. + - Rules: thay `@import` 6.7 KB bằng tóm tắt 5 dòng + link; bản đầy đủ nạp trong `/aup-audit` → −6k B. + - Xoá đoạn nói MCP "luôn có" khi chưa đăng ký; khử lặp `~/.claude/CLAUDE.md` vs org rules → −3.3k B. +3. **Sửa bug**: `mirror_mcp` đăng ký được cbm (báo lỗi rõ nếu thiếu binary), serena chỉ khi có trên PATH, `alwaysLoad` chỉ cho cbm; model ID 5.5 ở `ccc.rs:399`, `harness/claude_code.rs:395-403,545`, `model.rs:138-140` (một bảng hằng số chung); Feynman bridge thử cả `@earendil-works/*` và `@mariozechner/*`. +4. **Bench harness chuẩn hoá**: 3 cấu hình (Claude Code `--bare` làm baseline, ccc, omp) × task suite hiện có × 3 run, Console key, cùng model. Đo: context request đầu, tổng input (tách cache read/write), $/task, số request, thời gian, test ẩn pass. Script dùng python (jq hỏng với thư mục bắt đầu bằng `-`). +- **Done khi:** context request đầu ccc ≤ 45k (từ ~58k), quality 100%, có test mới cho merge CLAUDE.md/settings + guard prefix. + +### Phase 2 — v0.67.0 "Ít request hơn" (2–3 ngày) +1. Stop gate: prompt hook (1 lần gọi model mỗi lượt kết thúc) → **command hook** dựa trên bằng chứng (`verify-stop` đã có + kiểm task chưa xong trong task list); chỉ giữ gate prompt cho ca mơ hồ. Bài học phiên này: gate prompt chặn 4 lần chỉ vì đang chờ subagent chạy nền → gate phải nhận biết "đang chờ background task". +2. Guard: thêm `if:` (2.1.85) để chỉ spawn khi lệnh/đường dẫn rủi ro. +3. Read-dedupe hook (§3 #4) — bật sau flag, đo; revert nếu quality giảm. +4. Agent kit trả envelope ngắn (≤10 bullet + file) — §3 #5. +- **Done khi:** request/task ≤ omp trên bench, $/task ≤ omp. + +### Phase 3 — v0.68.0 "Định tuyến model + chất lượng" (2–3 ngày) +1. Model theo vai: `explore` haiku + `effort: low`; `task` sonnet; `reviewer` sonnet; plan trên model chính. `/ship` = prewalk: plan (Opus) → task (Sonnet) → reviewer → verify-gate. +2. Frontmatter `maxTurns`, `disallowedTools` (reviewer/explore read-only) — kiểu ForgeCode. +3. `deep-research` v2 theo pipeline Feynman + agent `verifier`/`researcher`; scale gate chống fan-out thừa. +4. LSP plugin chính chủ (`claude-plugins-official/plugins/{rust-analyzer,typescript,gopls,pyright}-lsp`) thay phần lớn serena — không tốn schema MCP. +- **Done khi:** thời gian ≤ 0.85× omp, quality 100%, 3 run. + +### Phase 4 — v0.69.0 "Memory + plugin" (3–5 ngày) +1. SessionStart hook: tóm tắt STATE/KNOWLEDGE byte-ổn-định (≤1.5k token, giữ prompt cache), agent `memory:`; đánh giá mind0 như MCP local (`alwaysLoad:false`). +2. Đóng gói kit thành **plugin "8 Sync kit for Claude Code"** + marketplace git `8-Sync-Dev` (agents, skills, hooks, lspServers, settings mặc định); `8sync ccc kit` cài offline bằng `--plugin-dir` khi không có mạng; giữ file user đã sửa; `LICENSE`/`NOTICE` ghi công MIT/Apache. +3. `/aup-audit` trước release (memory + hook gọi model + marketplace public). + +--- + +## 6. Rủi ro & chưa verify +- Claude Code đổi nhanh → ghi version tối thiểu cho mỗi feature (đã có trong §3), `ccc status` cảnh báo khi thấp hơn. +- Mẫu đo token hiện tại nhỏ → số chính thức chỉ từ bench Phase 1. +- **Chưa verify:** ID Haiku hiện hành; `claude-sonnet-5` còn hợp lệ không; hook có sửa được output tool built-in không; headless `claude -p` trên subscription có thuộc "explicitly permit"; điều khoản OpenAI cho `codex-web`; license ForgeCode lệch (`LICENSE` Apache-2.0 vs `package.json` ISC — coi là Apache-2.0); số request/turn của omp. +- Bench nhiễu → chênh <10% coi là hoà. + +## 7. Cần Alex quyết +1. **Console key cho bench** (tốn tiền; Alex tự set `ANTHROPIC_API_KEY`, không dán vào chat). +2. **`codex-web`**: gỡ khỏi mặc định / opt-in + cảnh báo / giữ nguyên. +3. **Gateway allowlist** có thể làm hỏng setup của user đang dùng gateway bên thứ ba — chấp nhận breaking change? +4. Marketplace plugin **public** hay private. + +## 8. Tiến độ Phase 0 (xong 2026-10-12) +- 7 nguồn clone + `codegraph init` ở `~/research/ccc-vs-omp/src/` (su-code, oh-my-pi, feynman, forgecode, claude-code, claude-plugins-official, everything-claude-code). +- Feynman 0.5.35 cài qua `feynman.is/install` (đã đọc script, sha256, `~/.local`, không sudo); `8sync feynman auth-omp` OK (Claude excluded). Chạy Feynman headless: model mặc định `gemini-2.5-flash` lỗi `MALFORMED_FUNCTION_CALL`; chạy lại bị classifier chặn vì gửi source 8 Sync cho bên thứ ba → research thực hiện bằng Claude Code subagent trên máy. +- Clone `~/tools/su-code` có thay đổi chưa commit (`pkg.rs`, `CHANGELOG.md`, `CLAUDE.md`, `KNOWLEDGE.md`, …) → **không pull**, giữ nguyên. + +## Nguồn +1. `~/research/ccc-vs-omp/src/su-code` @ `d18128d` — `crates/cli/src/verbs/{ccc.rs,ccc/guard.rs,ccc/verify.rs,harness/claude_code.rs,feynman.rs,codex_web.rs,forge.rs,model.rs}`, `su-code/STATE.md`, `docs/legal/anthropic/*` +2. `~/research/ccc-vs-omp/src/oh-my-pi` @ `b83200e` — `packages/coding-agent/src/{tools,session,advisor,mnemopi,edit,task,config}/…`, `packages/agent/src/compaction/*` +3. `~/research/ccc-vs-omp/src/feynman` @ `4cec627` — `prompts/deepresearch.md`, `.feynman/agents/*.md`, `SYSTEM.md`, `LICENSE` +4. `~/research/ccc-vs-omp/src/forgecode` (antinomyhq/forge) — `crates/forge_repo/src/agents/*.md`, `crates/forge_domain/src/agent.rs`, `crates/forge_config/.forge.toml`, provider/anthropic code, `LICENSE` +5. `~/research/ccc-vs-omp/src/claude-code` @ `3deb873` — `CHANGELOG.md` (dòng trích ở §3); docs https://code.claude.com/docs/en/hooks +6. `~/research/ccc-vs-omp/src/claude-plugins-official` @ `f359d43`, `everything-claude-code` @ `d4a44de` +7. Anthropic: https://www.anthropic.com/legal/aup · https://www.anthropic.com/legal/commercial-terms · https://www.anthropic.com/legal/consumer-terms · https://code.claude.com/docs/en/legal-and-compliance +8. Transcript usage (chỉ trường số): `~/.claude/projects/*/*.jsonl`, `~/.omp/agent/sessions/**/*.jsonl` + +_Footer: 1 vòng research, 5 agent + tự kiểm lại 3 claim then chốt (CLAUDE.md trùng, 0 MCP local, omp essential/discoverable). Đo: context request đầu từ transcript. Chưa verify: xem §6._ diff --git a/scripts/legal-sync.py b/scripts/legal-sync.py index dc349fe..6c8d064 100644 --- a/scripts/legal-sync.py +++ b/scripts/legal-sync.py @@ -10,6 +10,7 @@ import html import pathlib import re +import datetime import sys import urllib.request @@ -66,9 +67,22 @@ def legal_index() -> str: return "\n".join(f"- {u}" for u in urls) +def stamp_index(day: str) -> None: + """Put `Snapshot date: YYYY-MM-DD (UTC)` right under index.md's source comment.""" + path = OUT / "index.md" + if not path.exists(): + return + lines = [l for l in path.read_text(encoding="utf-8").splitlines() if not l.startswith("Snapshot date:")] + while len(lines) > 1 and lines[1] == "": + del lines[1] + lines[1:1] = ["", f"Snapshot date: {day} (UTC)", ""] + path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + + def main() -> int: OUT.mkdir(parents=True, exist_ok=True) failed = [] + changed = False jobs = {name: (url, lambda url=url: (md_text if url.endswith(".md") else html_text)(fetch(url))) for name, url in SOURCES.items()} jobs["index"] = ("https://www.anthropic.com/sitemap.xml (every /legal/ page)", legal_index) for name, (url, get) in jobs.items(): @@ -81,9 +95,17 @@ def main() -> int: continue body = f"\n\n{text}\n" path = OUT / f"{name}.md" + if name == "index" and path.exists(): # keep the existing stamp so an unchanged run is a no-op + m = re.search(r"^Snapshot date: .*$", path.read_text(encoding="utf-8"), re.M) + if m: + head, rest = body.split("\n\n", 1) + body = f"{head}\n\n{m.group(0)}\n\n{rest}" if not path.exists() or path.read_text(encoding="utf-8") != body: path.write_text(body, encoding="utf-8", newline="\n") print(f"updated {path.relative_to(OUT.parent.parent.parent)}") + changed = True + if changed: # stamp only on a real change, so unchanged runs stay byte-identical + stamp_index(datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")) for f in failed: print(f"error: {f}", file=sys.stderr) return 1 if failed else 0 diff --git a/su-code/KNOWLEDGE.md b/su-code/KNOWLEDGE.md index 62016fb..3c1df80 100644 --- a/su-code/KNOWLEDGE.md +++ b/su-code/KNOWLEDGE.md @@ -372,3 +372,8 @@ _(consolidated 72 dòng cũ → su-code/archive/KNOWLEDGE-1790603711.md)_ - validated: free CI on the public repo — `install-smoke` (workflow_run after Release + weekly) runs the real one-liners on ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-15-intel (last x86_64 macOS label, until 2027-08), windows-latest. A `release` event made with GITHUB_TOKEN does not trigger workflows; `workflow_run` on `Release` does. - validated: MSYS `rm -rf` does not follow a Windows junction (target survives; it only fails to remove the link) — safe for `~/.claude/skills/` → `~/.omp/skills/`. Remove a junction with `cmd /c rmdir `. - failure (residual): `8sync up` takes the sha256 from the unauthenticated releases API; when that IP is rate-limited it installs with "checksum NOT VERIFIED" (v0.65.0 here; digest checked by hand: match). +- failure: ccc kit drift (2026-10-12) — repo CLAUDE.md files kept the old 8.6 KB full block (never re-injected), the parent `~/startup/CLAUDE.md` duplicated it byte-for-byte, and the block forced 4 SKILL.md reads before the first tool → first request 57.9k–58.2k tokens vs omp 37.1k (transcript `message.usage`, deduped by requestId; jq chokes on `-`-prefixed project dirs → use python). Fix: compact block, ancestor dedupe, skip `su-code/` memory dirs. +- failure: `mirror_mcp` left Claude Code with `mcpServers: []` silently (serena not on PATH) while CLAUDE.md said the MCP tools were "always" there → wasted tool-search turns. Never claim a tool exists without checking registration. +- validated: compliance pattern for third-party harnesses — exact host allowlist (official/Bedrock/Vertex/Foundry/localhost/self-hosted env) + `sk-ant-api` prefix gate before any token leaves Claude Code; ForgeCode's `claude_code` provider and Feynman's "Claude Max" login are the fake-client pattern to avoid (research: `plans/2026-10-12-ccc-beyond-omp.md`). +- failure: Feynman 0.5.35 renamed Pi to `@earendil-works/*`; bridge must probe both scopes. Default bridged model gemini-2.5-flash fails deepresearch with MALFORMED_FUNCTION_CALL. +- validated: prompt Stop gate blocks while background subagents run (4× in one session) → gate prompt must allow "waiting on background tasks it launched". From 7e2b99f028a139cfdc9bf3d13c39bf435895c5ef Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Mon, 12 Oct 2026 11:20:49 +0700 Subject: [PATCH 3/3] feat(ccc): official LSP plugins + public 8sync-kit plugin marketplace - `8sync ccc kit` enables claude-plugins-official LSP plugins (rust-analyzer, typescript, gopls, pyright) only for servers on PATH, insert-only; serena stays as fallback - `.claude-plugin/marketplace.json` publishes `8sync-kit` (subagents + /ship from assets/claude); validated with `claude plugin validate` - test keeps plugin.json version in sync with the crate - README kit table updated (rules summary, role-routed agents, stop gate, LSP, SessionStart digest, plugin install) Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 14 ++++++ CHANGELOG.md | 4 ++ README.md | 15 ++++-- assets/claude/.claude-plugin/plugin.json | 10 ++++ crates/cli/src/verbs/ccc.rs | 58 ++++++++++++++++++++++++ su-code/KNOWLEDGE.md | 1 + 6 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 assets/claude/.claude-plugin/plugin.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..220d574 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "8sync", + "description": "8 Sync plugins for Claude Code", + "owner": { "name": "8 Sync", "url": "https://8syncdev.com" }, + "plugins": [ + { + "name": "8sync-kit", + "description": "Role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier) and the /ship skill from the 8 Sync kit.", + "source": "./assets/claude", + "category": "development" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f486a2d..41674c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh - Claude 5.5 model IDs (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`) in `harness claude-code`, the `harness model` aliases (`opus`/`sonnet`/`haiku`), the endpoint probe (now `x-api-key` + `anthropic-version`, official endpoint and localhost only) and the kit's gates. - `8sync ccc kit` says why codebase-memory-mcp / serena were not registered in Claude Code (missing from omp's `mcp.json`, not on PATH, or the `claude mcp add-json` error) instead of skipping silently; serena is registered with `alwaysLoad: false`. +### Added +- **Public plugin marketplace**: the kit's subagents and `/ship` install as a Claude Code plugin — `/plugin marketplace add 8-Sync-Dev/su-code` then `/plugin install 8sync-kit@8sync` (~630 tokens always-on per `claude plugin details`); hooks, rules and the full skill set still come from `8sync ccc kit`. +- `8sync ccc kit` enables Anthropic's official LSP plugins (rust-analyzer, TypeScript, gopls, pyright from `claude-plugins-official`) for the language servers found on PATH — code intelligence without MCP schemas in the prompt; values you set are kept. + ### Changed - **Leaner Claude Code sessions.** Measured on one machine (2026-10-12; `wc -c` of every always-loaded layer, and `message.usage` of the first request in 2 Claude Code transcripts), the kit + repo layers came to 55,663 B (≈13.9k tokens at 4 B/token) of a ~58k-token first request; most of it is gone: - the repo `CLAUDE.md` block is compact (≤1.1 KB), no longer orders reading 4 SKILL.md bodies (15.4 KB) before the first tool call, and no longer claims MCP tools are always present; diff --git a/README.md b/README.md index af4600b..ea355c5 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,25 @@ Each name maps to a fixed conversation id per repo, so `8sync ccc . core` tomorr ![8sync ccc kit and a named session](docs/assets/ccc-kit.png) +Only want the subagents and `/ship`? They are also a Claude Code plugin in this repo's public marketplace (hooks, rules and the full skill set still come from `8sync ccc kit`): + +``` +/plugin marketplace add 8-Sync-Dev/su-code +/plugin install 8sync-kit@8sync +``` + The kit lives in `~/.claude`, so it works in every repo and for plain `claude` too. Every `8sync ccc` launch also keeps things current, without network on the launch path: a newer 8sync release is installed and the command re-runs on it, the kit and bundled skills refresh after an update, and the repo gets the 8sync harness (memory, `AGENTS.md`/`CLAUDE.md` blocks, `.claude/skills` → `su-code/skills`). `SUSYNC_NO_AUTO_UPDATE=1` keeps the installed binary. | Part | What it does | |---|---| -| Org rules | The 8 Sync rules for Anthropic's Usage Policy and terms ([`8sync-anthropic-rules.md`](assets/skills/aup-audit/8sync-anthropic-rules.md)) are imported into every session ahead of other instructions. | -| Subagents | `explore` (Haiku) · `task` · `quick-task` · `reviewer` · `designer`. Reviewer and designer use your session model; the read-only ones keep the code-graph MCP tools. | +| Org rules | A short summary of the 8 Sync rules for Anthropic's Usage Policy and terms loads in every session ahead of other instructions; the full text ([`8sync-anthropic-rules.md`](assets/skills/aup-audit/8sync-anthropic-rules.md)) opens on demand and in `/aup-audit`. | +| Subagents | Routed by role: `explore` and `quick-task` on Haiku (low effort), `task` and `reviewer` on Sonnet, `designer` on your session model, plus `researcher` and `verifier` for `deep-research`. Each has `maxTurns`; the read-only ones cannot edit; all return a short summary with details in a file. | | Skills | `/ship `: explore → task list → implement with build/test → `reviewer` → CHANGELOG + commit, never pushes. `/aup-audit`: Usage Policy + secret check before you publish. `deep-research`: cited research brief. Plus every 8sync skill: bundled ones linked to the current `~/.omp/skills` copy, project ones through `.claude/skills`; on-demand skills are listed by name only to keep the prefix small. | | Verify hook | After every Edit/Write, runs `cargo check`, the package's `typecheck` script (or `tsc --noEmit`) or `go vet` from the edited file's nearest manifest **in the background**; only a failure comes back to Claude. The latest edit wins, so a burst of edits costs one check. Before a turn ends, the build must pass (blocked once per broken state). Native Rust, works on Windows. | | Guard hook | Before Bash/Edit/Write: no writing `.env`/keys/`*.session`/`*.pkl`, no real-looking API key in code or commands, no `git add -A` that would sweep up a secret, no code routing a Claude subscription into another client. About 23 ms per call. | -| Stop gate | When Claude tries to end a turn, Haiku reads its last message. Planned steps not done, edits not verified, failing checks, `NOT MERGEABLE`, or a question it could answer itself: the turn goes on. | +| Stop gate | When Claude tries to end a turn, Haiku reads its last message. Planned steps not done, edits not verified, failing checks, `NOT MERGEABLE`, or a question it could answer itself: the turn goes on. Waiting on background agents it launched is fine. | | Ask gate | Before Claude interrupts you with a question, Sonnet checks it. Only credentials, destructive or paid actions, and product decisions reach you; everything else goes back with "pick the conventional option and continue". | -| Defaults | Only where you haven't set them: auto permission mode, `medium` effort, a task list on every model. Build/test/lint commands and `git add` skip the auto-mode classifier. Deny rules for reading/editing `.env`, keys, `*.session`, `*.pkl` and for force-push; omp's `codebase-memory-mcp` + `serena` registered. | +| Defaults | Only where you haven't set them: auto permission mode, `medium` effort, a task list on every model. Build/test/lint commands and `git add` skip the auto-mode classifier. Deny rules for reading/editing `.env`, keys, `*.session`, `*.pkl` and for force-push; omp's `codebase-memory-mcp` + `serena` registered when installed (the kit says why not otherwise); Anthropic's official LSP plugins (rust-analyzer, TypeScript, gopls, pyright) enabled for the language servers on your PATH; a `SessionStart` digest of `su-code/STATE.md` and recent lessons. | A real run with the kit. The first edit broke the build, the verify hook fed the compiler error back, and the stop gate refused "Next I will add tests": diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json new file mode 100644 index 0000000..08c2d71 --- /dev/null +++ b/assets/claude/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "8sync-kit", + "version": "0.65.0", + "description": "8 Sync kit for Claude Code: role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier) and the /ship skill. Hooks, rules and the full skill set come from `8sync ccc kit`.", + "author": { "name": "8 Sync", "url": "https://8syncdev.com" }, + "homepage": "https://github.com/8-Sync-Dev/su-code", + "repository": "https://github.com/8-Sync-Dev/su-code", + "license": "See repository LICENSE; Feynman-derived agents: THIRD_PARTY_NOTICES.md", + "keywords": ["agents", "subagents", "ship", "research", "8sync"] +} diff --git a/crates/cli/src/verbs/ccc.rs b/crates/cli/src/verbs/ccc.rs index 2881f2a..dc53f9e 100644 --- a/crates/cli/src/verbs/ccc.rs +++ b/crates/cli/src/verbs/ccc.rs @@ -478,6 +478,7 @@ pub(crate) fn install_kit(env: &Env, force: bool) -> Result<()> { serde_json::from_str(&raw).with_context(|| format!("{} is not valid JSON — fix it, then `8sync ccc kit`", settings.display()))? }; apply_kit_settings(&mut v, &exe, &skills)?; + apply_lsp_plugins(&mut v, |bin| which::which(bin).is_ok()); let out = serde_json::to_string_pretty(&v)? + "\n"; if out != raw { std::fs::write(&settings, out).with_context(|| format!("write {}", settings.display()))?; @@ -507,6 +508,37 @@ fn hook_exe() -> Result { /// `env`, `defaultMode` and `effortLevel` defaults (a user value wins), /// `permissions` rules, and 8sync's hooks (replaced in place on re-run, so the /// file only changes when the kit does). +/// Official LSP plugins (anthropics/claude-plugins-official, `lspServers` only) +/// and the language-server binary each one starts. Claude Code's LSP tool needs +/// no MCP schema in the prompt; serena stays as the fallback when it is present. +const LSP_PLUGINS: [(&str, &str); 4] = [ + ("rust-analyzer-lsp", "rust-analyzer"), + ("typescript-lsp", "typescript-language-server"), + ("gopls-lsp", "gopls"), + ("pyright-lsp", "pyright-langserver"), +]; +const OFFICIAL_MARKETPLACE: &str = "claude-plugins-official"; + +/// Enable an official LSP plugin only when its server is on PATH. Insert-only: +/// a value the user set (including `false`) is kept. +fn apply_lsp_plugins(v: &mut Value, on_path: impl Fn(&str) -> bool) { + let Some(obj) = v.as_object_mut() else { return }; + let wanted: Vec<&str> = LSP_PLUGINS.iter().filter(|(_, bin)| on_path(bin)).map(|(p, _)| *p).collect(); + if wanted.is_empty() { + return; + } + if let Some(m) = obj.entry("extraKnownMarketplaces").or_insert_with(|| json!({})).as_object_mut() { + m.entry(OFFICIAL_MARKETPLACE).or_insert_with(|| { + json!({ "source": { "source": "github", "repo": "anthropics/claude-plugins-official" } }) + }); + } + if let Some(en) = obj.entry("enabledPlugins").or_insert_with(|| json!({})).as_object_mut() { + for p in wanted { + en.entry(format!("{p}@{OFFICIAL_MARKETPLACE}")).or_insert(json!(true)); + } + } +} + fn apply_kit_settings(v: &mut Value, exe: &str, skills: &[String]) -> Result<()> { let obj = v.as_object_mut().context("settings.json is not a JSON object")?; if let Some(env) = obj.entry("env").or_insert_with(|| json!({})).as_object_mut() { @@ -983,4 +1015,30 @@ mod tests { assert_eq!(find_state_root(&d.join("nope"), Some(&d)), None); let _ = std::fs::remove_dir_all(&d); } + #[test] + fn lsp_plugins_enabled_only_when_server_on_path_and_user_value_kept() { + let mut v = json!({ "enabledPlugins": { "gopls-lsp@claude-plugins-official": false } }); + apply_lsp_plugins(&mut v, |bin| bin == "rust-analyzer" || bin == "gopls"); + let en = &v["enabledPlugins"]; + assert_eq!(en["rust-analyzer-lsp@claude-plugins-official"], json!(true)); + assert_eq!(en["gopls-lsp@claude-plugins-official"], json!(false)); + assert!(en.get("typescript-lsp@claude-plugins-official").is_none()); + assert_eq!(v["extraKnownMarketplaces"]["claude-plugins-official"]["source"]["repo"], json!("anthropics/claude-plugins-official")); + let before = v.clone(); + apply_lsp_plugins(&mut v, |bin| bin == "rust-analyzer" || bin == "gopls"); + assert_eq!(v, before); + let mut none = json!({}); + apply_lsp_plugins(&mut none, |_| false); + assert_eq!(none, json!({})); + } + + #[test] + fn plugin_manifest_version_matches_crate() { + let m: Value = serde_json::from_str(include_str!("../../../../assets/claude/.claude-plugin/plugin.json")).unwrap(); + assert_eq!(m["name"], json!("8sync-kit")); + assert_eq!(m["version"], json!(env!("CARGO_PKG_VERSION"))); + let mk: Value = serde_json::from_str(include_str!("../../../../.claude-plugin/marketplace.json")).unwrap(); + assert_eq!(mk["plugins"][0]["source"], json!("./assets/claude")); + } + } diff --git a/su-code/KNOWLEDGE.md b/su-code/KNOWLEDGE.md index 3c1df80..ed0c234 100644 --- a/su-code/KNOWLEDGE.md +++ b/su-code/KNOWLEDGE.md @@ -377,3 +377,4 @@ _(consolidated 72 dòng cũ → su-code/archive/KNOWLEDGE-1790603711.md)_ - validated: compliance pattern for third-party harnesses — exact host allowlist (official/Bedrock/Vertex/Foundry/localhost/self-hosted env) + `sk-ant-api` prefix gate before any token leaves Claude Code; ForgeCode's `claude_code` provider and Feynman's "Claude Max" login are the fake-client pattern to avoid (research: `plans/2026-10-12-ccc-beyond-omp.md`). - failure: Feynman 0.5.35 renamed Pi to `@earendil-works/*`; bridge must probe both scopes. Default bridged model gemini-2.5-flash fails deepresearch with MALFORMED_FUNCTION_CALL. - validated: prompt Stop gate blocks while background subagents run (4× in one session) → gate prompt must allow "waiting on background tasks it launched". +- validated: kit as a public Claude Code plugin — marketplace `.claude-plugin/marketplace.json` → source `./assets/claude` (agents + skills/ship); `claude plugin validate .` + `claude --plugin-dir ./assets/claude plugin details 8sync-kit` (~630 tok always-on). Release bump must update `assets/claude/.claude-plugin/plugin.json` version (test `plugin_manifest_version_matches_crate` fails otherwise).