From c5f253b1a55b4dfc7e9b5e34d4ab72af397a826d Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 4 Nov 2026 10:41:33 +0000 Subject: [PATCH 1/2] feat(claude): launch account-bound sessions through optional cswap (#229) --- README.md | 1 + crates/ccteam-cli/src/main.rs | 10 + .../ccteam-cli/tests/cswap_accounts_test.rs | 336 ++++++++++ crates/ccteam-cli/tests/status_view_test.rs | 1 + crates/ccteam-core/src/config.rs | 7 + crates/ccteam-core/src/identity.rs | 23 + .../tests/claude_bg_rmux_adapter_test.rs | 1 + .../codex_app_server_progress_bridge_test.rs | 1 + .../ccteam-core/tests/harness_trait_test.rs | 4 + crates/ccteam-harness/src/adapter.rs | 8 + .../src/execution/claude_accounts.rs | 602 ++++++++++++++++++ .../src/execution/claude_stream_json/mod.rs | 91 ++- .../execution/claude_stream_json/transport.rs | 7 +- .../claude_stream_json/turn_gating.rs | 10 +- .../src/execution/experience.rs | 2 + crates/ccteam-harness/src/execution/mod.rs | 1 + .../src/execution/session_meta.rs | 7 + .../src/execution/transcript_tail.rs | 16 + .../src/execution/vendor_title.rs | 7 + .../tests/claude_agent_smoke_test.rs | 3 + .../tests/claude_stream_json_remote_test.rs | 1 + .../tests/claude_stream_json_test.rs | 4 + .../tests/claude_tui_env_test.rs | 1 + .../tests/claude_tui_reattach_test.rs | 1 + .../tests/claude_tui_resume_test.rs | 2 + .../ccteam-harness/tests/claude_tui_test.rs | 5 + .../tests/codex_app_server_test.rs | 19 + crates/ccteam-harness/tests/dsh_acp_test.rs | 2 + crates/ccteam-harness/tests/grok_acp_test.rs | 5 + crates/ccteam-harness/tests/kimi_acp_test.rs | 4 + .../ccteam-harness/tests/opencode_acp_test.rs | 4 + crates/ccteam-harness/tests/pi_bridge_test.rs | 1 + crates/ccteam-harness/tests/pi_rpc_test.rs | 1 + .../ccteam-harness/tests/session_meta_test.rs | 1 + crates/ccteam-im/src/external_nodes.rs | 1 + crates/ccteam-im/src/gateway.rs | 374 ++++++++++- crates/ccteam-im/src/mcp/dispatch.rs | 7 + crates/ccteam-im/src/mcp/protocol.rs | 8 +- crates/ccteam-web/src/routes/agents.rs | 3 + .../ccteam-web/src/routes/claude_accounts.rs | 53 ++ crates/ccteam-web/src/routes/mod.rs | 1 + crates/ccteam-web/src/routes/openapi.rs | 1 + crates/ccteam-web/src/routes/sessions_api.rs | 32 +- crates/ccteam-web/src/routes/status.rs | 1 + crates/ccteam-web/src/state.rs | 4 + crates/ccteam-web/tests/agents_test.rs | 1 + .../ccteam-web/tests/claude_accounts_test.rs | 114 ++++ crates/ccteam-web/tests/status_test.rs | 1 + .../web/src/components/ChatComposer.tsx | 4 + .../components/CswapAccountPicker.test.tsx | 39 ++ .../web/src/components/CswapAccountPicker.tsx | 51 ++ crates/ccteam-web/web/src/index.css | 5 + crates/ccteam-web/web/src/lib/sessionsApi.ts | 18 + crates/ccteam-web/web/src/pages/HomeView.tsx | 6 + .../ccteam-web/web/src/pages/SessionView.tsx | 6 + crates/ccteam-web/web/tests/v032-spa.spec.ts | 35 + docs/orchestration-cn.md | 2 + docs/orchestration.md | 2 + docs/usage-cn.md | 4 + docs/usage.md | 4 + 60 files changed, 1945 insertions(+), 21 deletions(-) create mode 100644 crates/ccteam-cli/tests/cswap_accounts_test.rs create mode 100644 crates/ccteam-harness/src/execution/claude_accounts.rs create mode 100644 crates/ccteam-web/src/routes/claude_accounts.rs create mode 100644 crates/ccteam-web/tests/claude_accounts_test.rs create mode 100644 crates/ccteam-web/web/src/components/CswapAccountPicker.test.tsx create mode 100644 crates/ccteam-web/web/src/components/CswapAccountPicker.tsx diff --git a/README.md b/README.md index 7610ccd40..2896c6d91 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ The installer runs the daemon; `ccteam status` reprints your link (`http:// - six formation playbooks (commander & crews, driver & advisor, cross review, bake-off, research triangulation, cost pyramid) that prefill the launcher with a vendor lineup +- optional Claude subscription selection through a preinstalled [cswap](https://github.com/realiti4/claude-swap): open the new-session model menu's advanced options and choose an authorized account; default login works without cswap, while isolated profiles retain their account across resumes - a chat per session with a compact title/model header and unified Files / Context toolbar, including a clock on the composer to queue delayed user turns above the input; a turn's folded activity row opens into its steps, and each step into its full thinking text or tool input and output - a context sidebar per session: session status, harness/model/host, delegation parent, cumulative cost and last activity, followed by the harness's own context accounting — detailed categories for Claude; window totals and last-request input/cache/output/reasoning usage for Codex — refreshed when a turn ends, with dated snapshots after release - a Team page: the live delegation topology — vendor, the model and reasoning effort each session is actually running, cost, every row a real link so a parent and its delegate open side by side — plus a division-of-labor charter (the per-project `routing.md` agents read via `status`) edited in place, and a Runs tab listing every ccteam Flow run with the sessions it hired diff --git a/crates/ccteam-cli/src/main.rs b/crates/ccteam-cli/src/main.rs index c8c69c17b..ef3a294b3 100644 --- a/crates/ccteam-cli/src/main.rs +++ b/crates/ccteam-cli/src/main.rs @@ -693,6 +693,16 @@ enum HookCommand { } fn main() -> Result<()> { + // cswap's PATH entry must remain a transparent stdio exec, including for + // auth probes. Intercept before clap, logging, runtime and global setup. + let relay_args: Vec = std::env::args_os().collect(); + if relay_args.get(1).is_some_and(|arg| arg == "internal") + && relay_args + .get(2) + .is_some_and(|arg| arg == "claude-account-launch") + { + return ccteam_harness::execution::claude_accounts::run_relay(&relay_args[3..]); + } // V0.8 W2a — rmux SDK daemon spawn protocol. BEFORE clap parses // argv, intercept the `--__internal-daemon ` form emitted // by `rmux_sdk::Rmux::connect_or_start` so the same ccteam binary diff --git a/crates/ccteam-cli/tests/cswap_accounts_test.rs b/crates/ccteam-cli/tests/cswap_accounts_test.rs new file mode 100644 index 000000000..9d8de138d --- /dev/null +++ b/crates/ccteam-cli/tests/cswap_accounts_test.rs @@ -0,0 +1,336 @@ +//! Real adapter + real ccteam relay, with deterministic offline vendor/CLI +//! peers. All environment mutation is in this isolated integration process. +#![cfg(unix)] + +use ccteam_harness::execution::claude_accounts::{account_id, ClaudeAccountBinding}; +use ccteam_harness::{ + AgentSpecBrief, ClaudeStreamJsonAdapter, HarnessAdapter, PermissionMode, SpawnCtx, ThreadEvent, + TurnInput, +}; +use futures::StreamExt; +use std::{os::unix::fs::PermissionsExt, path::Path, time::Duration}; + +const FAKE_CLAUDE: &str = r#"#!/usr/bin/python3 +import json, os, sys +from pathlib import Path +profile = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(Path.home() / '.claude'))) +email = os.environ.get('CSWAP_FAKE_DEFAULT_EMAIL', 'two@example.test') if 'CLAUDE_CONFIG_DIR' not in os.environ else 'two@example.test' +if sys.argv[1:3] == ['auth', 'status']: + print(json.dumps({'loggedIn':True,'authMethod':'claude.ai','email':email,'orgId':''})) + sys.exit(0) +args = sys.argv[1:] +assert '--no-chrome' in args and '--input-format' in args +log = {'argv':args,'profile':str(profile),'pid':os.getpid(),'isatty':[sys.stdin.isatty(),sys.stdout.isatty(),sys.stderr.isatty()], 'api_key':os.environ.get('ANTHROPIC_API_KEY'), 'path':os.environ['PATH']} +Path(os.environ['CSWAP_FAKE_LOG']).write_text(json.dumps(log)) +def emit(obj): + print(json.dumps(obj), flush=True) +for line in sys.stdin: + request=json.loads(line) + if request['type']=='control_request': + emit({'type':'control_response','response':{'subtype':'success','request_id':request['request_id'],'response':{'commands':[],'models':[{'value':'fake-model'}]}}}) + elif request['type']=='user': + emit({'type':'assistant','message':{'id':'msg-test','role':'assistant','model':'fake-model','content':[{'type':'text','text':'account reply'}]}}) + emit({'type':'result','subtype':'success','is_error':False,'result':'account reply','usage':{'input_tokens':1,'output_tokens':1}}) +"#; + +const FAKE_CSWAP: &str = r#"#!/usr/bin/python3 +import json, os, shutil, subprocess, sys +from pathlib import Path +log=Path(os.environ['CSWAP_FAKE_CALLS']) +with log.open('a') as output: + output.write(json.dumps(sys.argv[1:])+'\n') +if sys.argv[1:]==['list','--json']: + print(json.dumps({'schemaVersion':1,'accounts':[{'number':2,'email':os.environ.get('CSWAP_FAKE_ACCOUNT_EMAIL','two@example.test'),'organizationUuid':'','alias':'work','active':os.environ.get('CSWAP_FAKE_ACTIVE')=='1'}]})) + sys.exit(0) +if sys.argv[1:]==['run','--help']: + print('--no-share-history --require-session') + sys.exit(0) +assert sys.argv[1:3]==['run','2'] +assert '--no-share-history' in sys.argv +active=os.environ.get('CSWAP_FAKE_ACTIVE')=='1' +if active and '--require-session' in sys.argv: + sys.exit(4) +env=dict(os.environ) +if not active: + profile=Path.home()/'cswap-profiles'/'two' + profile.mkdir(parents=True,exist_ok=True) + env['CLAUDE_CONFIG_DIR']=str(profile) + for key in ['ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN','CLAUDE_CODE_OAUTH_TOKEN']: + env.pop(key,None) + subprocess.run([shutil.which('claude'),'auth','status','--json'],env=env,check=True,capture_output=True) +print('Launching account two@example.test', flush=True) +args=sys.argv[sys.argv.index('--')+1:] +os.execvpe('claude',['claude',*args],env) +"#; + +fn script(path: &Path, body: &str) { + std::fs::write(path, body).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +fn ctx(root: &Path, sid: &str, binding: Option) -> SpawnCtx { + SpawnCtx { + claude_account: binding, + generation: 1, + slug: "probe".into(), + sid: sid.into(), + owner: "user:web-api".into(), + cwd: root.into(), + project_dir: root.into(), + extra_args: vec![], + model_id: None, + effort: None, + mode: None, + permission_mode: PermissionMode::Skip, + secret: String::new(), + remote: None, + } +} + +fn log(root: &Path) -> serde_json::Value { + serde_json::from_slice(&std::fs::read(root.join("argv.json")).unwrap()).unwrap() +} + +#[tokio::test] +async fn account_launches_preserve_protocol_profiles_and_default_optional_behavior() { + let real_cswap = std::env::var_os("CCTEAM_TEST_REAL_CSWAP_BIN"); + let real_claude = std::env::var_os("CCTEAM_TEST_REAL_CLAUDE_BIN"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let home = root.join("home"); + std::fs::create_dir_all(home.join(".claude")).unwrap(); + std::env::set_var("HOME", &home); + std::env::set_var("CCTEAM_HOME", home.join(".ccteam")); + std::env::set_var("CLAUDE_CONFIG_HOME", home.join(".claude")); + std::env::set_var("XDG_DATA_HOME", root.join("data")); + std::env::set_var("PATH", "/usr/bin:/bin"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + for key in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_OAUTH_TOKEN", + ] { + std::env::remove_var(key); + } + std::env::set_var("CCTEAM_CSWAP_RELAY_BIN", env!("CARGO_BIN_EXE_ccteam")); + std::env::set_var("CCTEAM_STREAM_JSON_INIT_TIMEOUT_MS", "5000"); + std::env::set_var("CSWAP_FAKE_LOG", root.join("argv.json")); + std::env::set_var("CSWAP_FAKE_CALLS", root.join("cswap.jsonl")); + let claude = root.join("real-claude"); + let cswap = root.join("cswap"); + script(&claude, FAKE_CLAUDE); + script(&cswap, FAKE_CSWAP); + std::env::set_var("CCTEAM_CLAUDE_BIN", &claude); + std::env::set_var("CCTEAM_CSWAP_BIN", root.join("missing-cswap")); + let spec = AgentSpecBrief { + role: String::new(), + }; + + // No cswap is installed: the ordinary adapter still starts and resumes. + let adapter = ClaudeStreamJsonAdapter::new(); + let normal = adapter + .start_thread(&spec, &ctx(root, "s1", None)) + .await + .unwrap(); + assert!(!root.join("cswap.jsonl").exists()); + assert_eq!( + log(root)["profile"], + home.join(".claude").to_string_lossy().as_ref() + ); + adapter.close_thread(&normal).await.unwrap(); + + std::env::set_var("CCTEAM_CSWAP_BIN", &cswap); + std::env::set_var("ANTHROPIC_API_KEY", "sandbox-ambient-key-must-not-win"); + let requested = ClaudeAccountBinding::requested(account_id("two@example.test", "")).unwrap(); + let selected = adapter + .start_thread(&spec, &ctx(root, "s2", Some(requested.clone()))) + .await + .unwrap(); + let binding: ClaudeAccountBinding = + serde_json::from_value(selected.raw_extras["claude_account"].clone()).unwrap(); + assert!(!binding.default_login); + assert_eq!(binding.config_dir, Some(home.join("cswap-profiles/two"))); + assert_eq!( + log(root)["isatty"], + serde_json::json!([false, false, false]) + ); + assert!(log(root)["api_key"].is_null()); + assert_eq!(log(root)["path"], "/usr/bin:/bin"); + let body: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join(".ccteam/chat/s2/body.json")).unwrap()) + .unwrap(); + assert_eq!(log(root)["pid"], body["pid"]); + for _ in 0..2 { + let mut events = adapter.events(&selected); + adapter + .submit_turn(&selected, TurnInput::UserText("hello".into())) + .await + .unwrap(); + let completed = tokio::time::timeout(Duration::from_secs(3), async { + while let Some(event) = events.next().await { + if matches!(event, ThreadEvent::TurnCompleted { .. }) { + return true; + } + } + false + }) + .await + .unwrap(); + assert!( + completed, + "each request must complete on the same stream-json process" + ); + assert!(adapter.thread_is_live(&selected)); + } + adapter.close_thread(&selected).await.unwrap(); + + let transcript = ccteam_harness::execution::transcript_tail::anthropic_project_dir_in( + &binding.config_dir.as_ref().unwrap().join("projects"), + root, + ) + .join(format!("{}.jsonl", selected.identity)); + std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); + std::fs::write(&transcript, "{}\n").unwrap(); + let resumed = adapter + .start_thread(&spec, &ctx(root, "s2", Some(binding.clone()))) + .await + .unwrap(); + assert!(log(root)["argv"] + .as_array() + .unwrap() + .iter() + .any(|arg| arg == "--resume")); + assert_eq!( + log(root)["profile"], + binding + .config_dir + .as_ref() + .unwrap() + .to_string_lossy() + .as_ref() + ); + adapter.close_thread(&resumed).await.unwrap(); + + // A movable slot is not an account identity. Replacing slot 2 fails closed. + std::env::set_var("CSWAP_FAKE_ACCOUNT_EMAIL", "replacement@example.test"); + assert!(adapter + .start_thread(&spec, &ctx(root, "s2", Some(binding.clone()))) + .await + .is_err()); + std::env::remove_var("CSWAP_FAKE_ACCOUNT_EMAIL"); + + // An already isolated sid cannot move into the default profile on resume. + std::env::set_var("CSWAP_FAKE_ACTIVE", "1"); + assert!(adapter + .start_thread(&spec, &ctx(root, "s2", Some(binding))) + .await + .is_err()); + assert!(transcript.exists()); + + // A NEW selection may fall back to the active/default login (owner choice). + let fallback = adapter + .start_thread(&spec, &ctx(root, "s3", Some(requested.clone()))) + .await + .unwrap(); + let default_binding: ClaudeAccountBinding = + serde_json::from_value(fallback.raw_extras["claude_account"].clone()).unwrap(); + assert!(default_binding.default_login); + assert_eq!(default_binding.config_dir, Some(home.join(".claude"))); + assert!(log(root)["api_key"].is_null()); + adapter.close_thread(&fallback).await.unwrap(); + + // Auto has changed the default identity and cswap has been uninstalled: + // the fallback sid must still resume in the original default profile. + std::env::set_var("CSWAP_FAKE_DEFAULT_EMAIL", "auto-switched@example.test"); + std::env::set_var("CCTEAM_CSWAP_BIN", root.join("missing-cswap")); + let followed = adapter + .start_thread(&spec, &ctx(root, "s3", Some(default_binding))) + .await + .unwrap(); + assert_eq!( + log(root)["profile"], + home.join(".claude").to_string_lossy().as_ref() + ); + adapter.close_thread(&followed).await.unwrap(); + assert!(adapter + .start_thread(&spec, &ctx(root, "s4", Some(requested))) + .await + .is_err()); + + // Optional compatibility probe: a fresh sandbox store with a fake token; + // no real account or credentials are read or copied. + if let Some(real_cswap) = real_cswap { + std::env::remove_var("CSWAP_FAKE_ACTIVE"); + std::env::remove_var("ANTHROPIC_API_KEY"); + let guard = root.join("netguard"); + std::fs::create_dir_all(&guard).unwrap(); + std::fs::write(guard.join("sitecustomize.py"), "import socket\ndef deny(*args, **kwargs):\n raise RuntimeError('network disabled for isolated cswap probe')\nsocket.socket.connect=deny\nsocket.create_connection=deny\n").unwrap(); + std::env::set_var("PYTHONPATH", &guard); + let result = std::process::Command::new(&real_cswap) + .args([ + "add-token", + "sk-ant-oat01-SANDBOX-NOT-REAL", + "--email", + "two@example.test", + "--slot", + "2", + ]) + .output() + .unwrap(); + assert!( + result.status.success(), + "sandbox setup failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + std::env::set_var("CCTEAM_CSWAP_BIN", real_cswap); + let selection = + ClaudeAccountBinding::requested(account_id("two@example.test", "")).unwrap(); + let real_launcher = adapter + .start_thread(&spec, &ctx(root, "s5", Some(selection.clone()))) + .await + .unwrap(); + assert_eq!( + real_launcher.raw_extras["claude_account"]["default_login"], + false + ); + assert_eq!( + log(root)["isatty"], + serde_json::json!([false, false, false]) + ); + adapter.close_thread(&real_launcher).await.unwrap(); + if let Some(real_claude) = real_claude { + // Real binaries, initialize only: no user turn or model inference. + std::env::set_var("CCTEAM_CLAUDE_BIN", real_claude); + std::env::set_var("CCTEAM_STREAM_JSON_INIT_TIMEOUT_MS", "30000"); + std::env::set_var("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); + std::env::set_var("DISABLE_TELEMETRY", "1"); + let real = adapter + .start_thread(&spec, &ctx(root, "s6", Some(selection))) + .await + .unwrap(); + assert!(adapter.thread_is_live(&real)); + assert_eq!(real.raw_extras["claude_account"]["default_login"], false); + let profile = std::path::PathBuf::from( + real.raw_extras["claude_account"]["config_dir"] + .as_str() + .unwrap(), + ); + let body: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join(".ccteam/chat/s6/body.json")).unwrap(), + ) + .unwrap(); + let pid = body["pid"].as_u64().unwrap(); + let pid_record = profile.join("sessions").join(format!("{pid}.json")); + eprintln!("real cswap + Claude stream-json initialize passed; native session PID record exists: {}", pid_record.exists()); + assert!( + pid_record.exists(), + "cswap must be able to discover and protect the headless Claude process" + ); + adapter.close_thread(&real).await.unwrap(); + } + } +} diff --git a/crates/ccteam-cli/tests/status_view_test.rs b/crates/ccteam-cli/tests/status_view_test.rs index 2e15550d6..a96b7f4a8 100644 --- a/crates/ccteam-cli/tests/status_view_test.rs +++ b/crates/ccteam-cli/tests/status_view_test.rs @@ -78,6 +78,7 @@ fn ephemeral_home(slug: &str) -> (tempfile::TempDir, PathBuf, PathBuf, String) { mode: ccteam_harness::PermissionMode| { let now = "2026-01-01T00:00:00Z".to_string(); let meta = ccteam_harness::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-core/src/config.rs b/crates/ccteam-core/src/config.rs index ebeadc962..861e85da6 100644 --- a/crates/ccteam-core/src/config.rs +++ b/crates/ccteam-core/src/config.rs @@ -45,6 +45,11 @@ pub const DAEMON_WORKERS_ENV: &str = "CCTEAM_DAEMON_WORKERS"; /// on every collection guarantees an older config.yaml still parses. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CcteamConfig { + /// Explicit cswap subscription grants, keyed by canonical owner tag. + /// Operators use their local accounts; tenants need a named identity + /// reference from account discovery. No credentials are stored here. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub cswap_account_grants: BTreeMap>, /// Canonical base for `ccteam init --in `. When absent, /// `CcteamPaths::from_env` falls back to `$HOME/projects`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -274,6 +279,7 @@ pub fn default_claude_jobs_retention_days() -> u32 { impl Default for CcteamConfig { fn default() -> Self { Self { + cswap_account_grants: BTreeMap::new(), projects_root: None, default_project: None, projects: Vec::new(), @@ -492,6 +498,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let entry = sample_entry("foo", &PathBuf::from("/home/rob/code/foo")); let cfg = CcteamConfig { + cswap_account_grants: Default::default(), projects_root: Some(PathBuf::from("/work/repos")), default_project: Some("foo".to_string()), projects: vec![entry.clone()], diff --git a/crates/ccteam-core/src/identity.rs b/crates/ccteam-core/src/identity.rs index 16a56dcdc..84d0e10fd 100644 --- a/crates/ccteam-core/src/identity.rs +++ b/crates/ccteam-core/src/identity.rs @@ -11,6 +11,12 @@ pub const ADMIN_WEB_ID: &str = "web-api"; /// outside it (`telegram:`, …) are IM-owned; `None` is unowned. pub const WEB_OWNER_PREFIX: &str = "user:"; +/// Project access alone never grants the right to spend another owner's +/// subscription. Account grants name stable identities, not movable slots. +pub fn can_use_cswap_account(is_operator: bool, granted: &[String], account: &str) -> bool { + is_operator || granted.iter().any(|id| id == account) +} + /// Return the persisted owner tag for resources created by a web identity. pub fn owner_tag(user_id: &str, is_admin: bool) -> String { if is_admin { @@ -109,6 +115,23 @@ pub fn operator_im_pool_sees(viewer_identity: &str, named_operator: bool, owner: mod tests { use super::*; + #[test] + fn subscription_use_requires_operator_or_an_explicit_identity_grant() { + assert!(can_use_cswap_account(true, &[], "account-a")); + assert!(!can_use_cswap_account(false, &[], "account-a")); + assert!(can_use_cswap_account( + false, + &["account-a".into()], + "account-a" + )); + assert!(!can_use_cswap_account( + false, + &["account-a".into()], + "account-b" + )); + assert!(!can_use_cswap_account(false, &["*".into()], "account-a")); + } + #[test] fn owner_and_web_chat_tags_are_stable() { assert_eq!(owner_tag("admin", true), "user:web-api"); diff --git a/crates/ccteam-core/tests/claude_bg_rmux_adapter_test.rs b/crates/ccteam-core/tests/claude_bg_rmux_adapter_test.rs index 2b2e8c227..4e4895555 100644 --- a/crates/ccteam-core/tests/claude_bg_rmux_adapter_test.rs +++ b/crates/ccteam-core/tests/claude_bg_rmux_adapter_test.rs @@ -107,6 +107,7 @@ async fn via_mux_bg_spawn_routes_through_rmux_daemon() { role: "tester".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "rmuxbg".into(), diff --git a/crates/ccteam-core/tests/codex_app_server_progress_bridge_test.rs b/crates/ccteam-core/tests/codex_app_server_progress_bridge_test.rs index 3882689c3..9c20baf7e 100644 --- a/crates/ccteam-core/tests/codex_app_server_progress_bridge_test.rs +++ b/crates/ccteam-core/tests/codex_app_server_progress_bridge_test.rs @@ -269,6 +269,7 @@ async fn end_to_end_turn_completed_writes_progress_jsonl_with_vendor_codex() { role: "codex-bot".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo-codex".into(), diff --git a/crates/ccteam-core/tests/harness_trait_test.rs b/crates/ccteam-core/tests/harness_trait_test.rs index 3d04d889e..400d15298 100644 --- a/crates/ccteam-core/tests/harness_trait_test.rs +++ b/crates/ccteam-core/tests/harness_trait_test.rs @@ -121,6 +121,7 @@ async fn claude_bg_start_thread_parses_backgrounded_marker() { role: "tester".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -158,6 +159,7 @@ async fn claude_bg_start_thread_rejects_empty_role() { role: String::new(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -281,6 +283,7 @@ async fn claude_bg_via_mux_spawns_ephemeral_session_and_close_reaps_it() { role: "tester".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "muxbg".into(), @@ -468,6 +471,7 @@ mod codex_tmux { role: String::new(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, slug: "codextest".into(), sid: "codex-1".into(), diff --git a/crates/ccteam-harness/src/adapter.rs b/crates/ccteam-harness/src/adapter.rs index c834b73c7..8076d2c1b 100644 --- a/crates/ccteam-harness/src/adapter.rs +++ b/crates/ccteam-harness/src/adapter.rs @@ -415,6 +415,9 @@ pub enum DetachOutcome { /// finished a turn (or several) while no daemon was reading it. #[derive(Debug, Clone)] pub struct UnobservedTurnCtx { + /// Explicit profile binding from the session ledger; never infer it from + /// an ambient home or a possibly unreadable metadata file during reads. + pub claude_account: Option, pub sid: String, pub slug: String, /// The session's working directory (the vendor transcript lives keyed by @@ -1164,6 +1167,8 @@ pub enum DirectiveOutcome { /// `thread/name/set`) answers [`TitleSync::Deferred`] when `thread` is `None`. #[derive(Debug, Clone)] pub struct SessionTitleTarget { + /// The session's ledger-owned profile, including stopped sessions. + pub claude_account: Option, /// ccteam session id (`s{n}`). pub sid: String, /// The vendor's own session id as recorded in `meta.json` (Claude session @@ -1838,6 +1843,9 @@ pub struct ThreadErrorEvent { /// per-model cost accuracy). #[derive(Debug, Clone, Default)] pub struct SpawnCtx { + /// Optional Claude subscription binding. Only local stream-json supports + /// cswap; default spawns never probe or invoke it (issue #229). + pub claude_account: Option, pub slug: String, pub sid: String, /// Which THREAD of this sid is starting. A sid outlives any one thread — diff --git a/crates/ccteam-harness/src/execution/claude_accounts.rs b/crates/ccteam-harness/src/execution/claude_accounts.rs new file mode 100644 index 000000000..b25048c5e --- /dev/null +++ b/crates/ccteam-harness/src/execution/claude_accounts.rs @@ -0,0 +1,602 @@ +//! Optional cswap launcher integration. Claude/cswap own credentials; this +//! module handles only account references, argv, and profile metadata (#229). + +use std::{ + collections::BTreeMap, + ffi::OsString, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::{fs_atomic::atomic_write_durable, transcript_tail::anthropic_project_dir_in}; + +const RELAY_ENV: &str = "CCTEAM_CSWAP_LAUNCH"; +const REAL_BIN_ENV: &str = "CCTEAM_CSWAP_CLAUDE"; +const ORIGINAL_PATH_ENV: &str = "CCTEAM_CSWAP_PATH"; +const MAX_DISCOVERY_BYTES: usize = 1024 * 1024; + +/// Safe to return to an authorized frontend. No credentials or profile paths. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CswapAccount { + pub id: String, + pub slot: String, + pub label: String, + pub active: bool, +} + +/// Private session metadata. `config_dir` is the literal path exported by +/// cswap (its spelling also selects the macOS Keychain service). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaudeAccountBinding { + pub id: String, + #[serde(default)] + pub slot: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub config_dir: Option, + #[serde(default)] + pub directory_identity: Option, + #[serde(default)] + pub default_login: bool, +} + +impl ClaudeAccountBinding { + pub fn requested(id: String) -> Result { + if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("invalid cswap account reference; choose an account from the account list"); + } + Ok(Self { + id, + slot: String::new(), + label: String::new(), + config_dir: None, + directory_identity: None, + default_login: false, + }) + } + + pub fn display(&self) -> CswapAccount { + CswapAccount { + id: self.id.clone(), + slot: self.slot.clone(), + label: self.label.clone(), + active: self.default_login, + } + } +} + +pub fn account_id(email: &str, organization: &str) -> String { + let identity = serde_json::to_vec(&(email, organization)).expect("string pair serializes"); + format!("{:x}", Sha256::digest(identity)) +} + +pub fn parse_accounts(bytes: &[u8]) -> Result> { + if bytes.len() > MAX_DISCOVERY_BYTES { + bail!("cswap account list is too large"); + } + let value: serde_json::Value = + serde_json::from_slice(bytes).context("cswap did not return a JSON account list")?; + if value["schemaVersion"].as_u64() != Some(1) { + bail!("unsupported cswap account-list schema"); + } + let rows = value["accounts"] + .as_array() + .context("cswap account list is missing accounts")?; + let mut accounts = BTreeMap::new(); + for row in rows { + // API-key accounts have no subscription quota and run does not support them. + if row["usageStatus"].as_str() == Some("api_key") { + continue; + } + let email = row["email"] + .as_str() + .filter(|s| !s.is_empty()) + .context("cswap account has no identity")?; + let org = row["organizationUuid"].as_str().unwrap_or(""); + let slot = row["number"] + .as_u64() + .filter(|n| *n > 0) + .context("cswap account has no slot")? + .to_string(); + let alias = row["alias"].as_str().filter(|s| !s.trim().is_empty()); + let masked = email + .split_once('@') + .map(|(name, domain)| format!("{}***@{domain}", name.chars().next().unwrap_or('*'))) + .unwrap_or_else(|| "***".into()); + let label = alias + .map(|alias| format!("{alias} ({masked})")) + .unwrap_or(masked); + let id = account_id(email, org); + if accounts + .insert( + id.clone(), + CswapAccount { + id, + slot, + label, + active: row["active"].as_bool().unwrap_or(false), + }, + ) + .is_some() + { + bail!("cswap contains duplicate account identities; repair its account list first"); + } + } + Ok(accounts.into_values().collect()) +} + +pub fn cswap_bin() -> OsString { + std::env::var_os("CCTEAM_CSWAP_BIN").unwrap_or_else(|| "cswap".into()) +} + +/// Called only by optional discovery or an explicitly selected account. Never +/// part of daemon readiness or a normal/default Claude spawn. +pub async fn discover_accounts() -> Result> { + let help = cswap_output(&["run", "--help"], Duration::from_secs(5)).await?; + let help = String::from_utf8_lossy(&help); + if !help.contains("--no-share-history") || !help.contains("--require-session") { + bail!("this cswap version lacks account-isolation options; upgrade cswap or use default login"); + } + parse_accounts(&cswap_output(&["list", "--json"], Duration::from_secs(20)).await?) +} + +async fn cswap_output(args: &[&str], timeout: Duration) -> Result> { + let mut command = tokio::process::Command::new(cswap_bin()); + command + .args(args) + .env_remove("CLAUDE_CONFIG_DIR") + .kill_on_drop(true); + let output = tokio::time::timeout(timeout, command.output()) + .await + .context("cswap account discovery timed out; default login remains available")? + .context("cswap is not installed or cannot run; install cswap to select an account")?; + if !output.status.success() { + bail!("cswap account discovery failed; check cswap list in the daemon user's environment"); + } + Ok(output.stdout) +} + +/// Discovery is explicit and isolated from readiness/status. Coalesce open +/// picker requests and briefly cache failures as well as successful results. +#[derive(Default)] +pub struct CswapAccountService { + cache: tokio::sync::Mutex>, +} + +struct CachedAccounts { + captured: std::time::Instant, + result: std::result::Result, String>, +} + +impl CswapAccountService { + pub async fn accounts(&self) -> Result> { + let mut cache = self.cache.lock().await; + if let Some(cached) = &*cache { + let ttl = if cached.result.is_ok() { 30 } else { 5 }; + if cached.captured.elapsed() < Duration::from_secs(ttl) { + return cached.result.clone().map_err(anyhow::Error::msg); + } + } + let result = discover_accounts() + .await + .map_err(|error| format!("{error:#}")); + *cache = Some(CachedAccounts { + captured: std::time::Instant::now(), + result: result.clone(), + }); + result.map_err(anyhow::Error::msg) + } +} + +/// The relay performs the final profile check and fresh/resume decision just +/// before exec. This avoids a second preparatory run and a credential race. +#[derive(Debug, Serialize, Deserialize)] +struct LaunchRequest { + binding: ClaudeAccountBinding, + uuid: String, + cwd: PathBuf, +} + +pub struct CswapLaunch { + pub argv: Vec, + pub env: Vec<(String, String)>, + directory: PathBuf, +} + +impl Drop for CswapLaunch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +impl CswapLaunch { + pub async fn prepare( + binding: &ClaudeAccountBinding, + argv: &[String], + env: &[(String, String)], + cwd: &Path, + chat_dir: &Path, + uuid: &str, + ) -> Result { + #[cfg(not(unix))] + bail!("cswap accounts currently require Linux, WSL or macOS"); + let mut binding = binding.clone(); + if !binding.default_login { + let account = discover_accounts() + .await? + .into_iter() + .find(|a| a.id == binding.id) + .context( + "selected cswap account no longer exists; choose an account for a new session", + )?; + binding.slot = account.slot; + binding.label = account.label; + } + let mut nonce = [0u8; 16]; + getrandom::getrandom(&mut nonce) + .map_err(|error| anyhow::anyhow!("cswap launch nonce: {error}"))?; + let nonce = format!("{:x}", Sha256::digest(nonce)); + let directory = chat_dir.join(format!("cswap-launch-{nonce}")); + let bin_dir = directory.join("bin"); + std::fs::create_dir_all(&bin_dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?; + } + let mut launch = Self { + argv: Vec::new(), + env: env.to_vec(), + directory, + }; + let request = LaunchRequest { + binding: binding.clone(), + uuid: uuid.to_string(), + cwd: cwd.to_path_buf(), + }; + atomic_write_durable( + &launch.directory.join("request.json"), + &serde_json::to_vec(&request)?, + )?; + let relay = std::env::var_os("CCTEAM_CSWAP_RELAY_BIN") + .map(PathBuf::from) + .map(Ok) + .unwrap_or_else(std::env::current_exe)?; + let quoted = relay.to_string_lossy().replace('\'', "'\\''"); + let script = format!("#!/bin/sh\nexec '{quoted}' internal claude-account-launch \"$@\"\n"); + let entry = bin_dir.join("claude"); + std::fs::write(&entry, script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o700))?; + } + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir]; + paths.extend(std::env::split_paths(&original_path)); + launch.env.extend([ + ( + "PATH".into(), + std::env::join_paths(paths)?.to_string_lossy().into_owned(), + ), + ( + ORIGINAL_PATH_ENV.into(), + original_path.to_string_lossy().into_owned(), + ), + ( + REAL_BIN_ENV.into(), + argv.first().context("missing Claude binary")?.clone(), + ), + ( + RELAY_ENV.into(), + launch.directory.to_string_lossy().into_owned(), + ), + ]); + // A prior default fallback is a normal default-profile session forever; + // re-running its old slot after auto switched would move its history. + if binding.default_login { + launch.argv = vec![entry.to_string_lossy().into_owned()]; + } else { + launch.argv = vec![ + cswap_bin().to_string_lossy().into_owned(), + "run".into(), + binding.slot, + "--no-share-history".into(), + ]; + if binding.config_dir.is_some() { + launch.argv.push("--require-session".into()); + } + launch.argv.push("--".into()); + } + launch.argv.extend_from_slice(&argv[1..]); + Ok(launch) + } + + pub fn binding(&self) -> Result { + serde_json::from_slice(&std::fs::read(self.directory.join("binding.json"))?) + .context("cswap launch did not confirm its profile") + } +} + +/// Hidden CLI entry: do not initialize daemon state or consume stdin. cswap +/// may call `claude auth status` before it execs the actual stream-json child. +pub fn run_relay(args: &[OsString]) -> Result<()> { + let directory = + PathBuf::from(std::env::var_os(RELAY_ENV).context("cswap relay is not configured")?); + let request: LaunchRequest = + serde_json::from_slice(&std::fs::read(directory.join("request.json"))?)?; + let real_bin = std::env::var_os(REAL_BIN_ENV).context("cswap relay has no Claude binary")?; + let mut command = std::process::Command::new(&real_bin); + command.env( + "PATH", + std::env::var_os(ORIGINAL_PATH_ENV).unwrap_or_default(), + ); + command + .env_remove(RELAY_ENV) + .env_remove(REAL_BIN_ENV) + .env_remove(ORIGINAL_PATH_ENV); + let is_stream_json = args.iter().any(|arg| arg == "--input-format"); + if !is_stream_json { + return exec(command, args); + } + let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".claude"))) + .context("Claude profile cannot be resolved")?; + let default_login = std::env::var_os("CLAUDE_CONFIG_DIR").is_none(); + verify_subscription_settings(&config_dir, &request.cwd)?; + let mut binding = request.binding; + #[cfg(unix)] + let directory_identity = { + use std::os::unix::fs::MetadataExt; + let metadata = + std::fs::metadata(&config_dir).context("selected Claude profile is unavailable")?; + format!("{}:{}", metadata.dev(), metadata.ino()) + }; + #[cfg(not(unix))] + let directory_identity = config_dir.canonicalize()?.to_string_lossy().into_owned(); + if binding + .config_dir + .as_ref() + .is_some_and(|expected| expected != &config_dir) + || (binding.config_dir.is_some() && binding.default_login != default_login) + { + bail!("cswap profile changed; this session cannot resume in another profile"); + } + if binding + .directory_identity + .as_ref() + .is_some_and(|expected| expected != &directory_identity) + { + bail!("cswap profile directory was replaced; refusing to resume in another profile"); + } + // A selected subscription must not be hijacked by ambient API credentials, + // including cswap's default-account fast path which retains the full env. + for key in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + ] { + command.env_remove(key); + } + for key in [ + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + ] { + if std::env::var_os(key).is_some_and(|v| !v.is_empty() && v != "0" && v != "false") { + bail!("selected cswap subscription conflicts with {key}; remove the provider override first"); + } + } + let mut probe = std::process::Command::new(&real_bin); + probe.args(["auth", "status", "--json"]).env( + "PATH", + std::env::var_os(ORIGINAL_PATH_ENV).unwrap_or_default(), + ); + for (key, value) in command.get_envs() { + if let Some(value) = value { + probe.env(key, value); + } else { + probe.env_remove(key); + } + } + let status = bounded_auth_probe(probe)?; + let identity: serde_json::Value = serde_json::from_slice(&status.stdout) + .context("Claude auth status returned invalid JSON")?; + if !status.status.success() + || identity["loggedIn"].as_bool() != Some(true) + || identity["authMethod"].as_str() != Some("claude.ai") + { + bail!("selected Claude profile is not logged into a subscription; use cswap to repair the login"); + } + if !binding.default_login { + let email = identity["email"] + .as_str() + .context("Claude auth status has no account identity")?; + let org = identity["orgId"].as_str().unwrap_or(""); + if account_id(email, org) != binding.id { + bail!("cswap account identity changed; refusing to use another account"); + } + } + binding.config_dir = Some(config_dir.clone()); + binding.directory_identity = Some(directory_identity); + binding.default_login = default_login; + let transcript = anthropic_project_dir_in(&config_dir.join("projects"), &request.cwd) + .join(format!("{}.jsonl", request.uuid)); + let mut args = args.to_vec(); + let flag = args + .iter() + .position(|arg| arg == "--session-id" || arg == "--resume") + .context("cswap relay has no session UUID argument")?; + args[flag] = if transcript.exists() { + "--resume" + } else { + "--session-id" + } + .into(); + atomic_write_durable( + &directory.join("binding.json"), + &serde_json::to_vec(&binding)?, + )?; + exec(command, &args) +} + +fn verify_subscription_settings(config_dir: &Path, cwd: &Path) -> Result<()> { + for path in [ + config_dir.join("settings.json"), + cwd.join(".claude/settings.json"), + cwd.join(".claude/settings.local.json"), + ] { + let body = match std::fs::read(&path) { + Ok(body) => body, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => bail!("could not verify Claude settings for the selected subscription"), + }; + let settings: serde_json::Value = serde_json::from_slice(&body) + .context("selected subscription has invalid Claude settings")?; + if settings + .get("apiKeyHelper") + .is_some_and(|value| !value.is_null() && value.as_str() != Some("")) + { + bail!( + "selected subscription conflicts with apiKeyHelper; remove the auth helper first" + ); + } + if let Some(env) = settings["env"].as_object() { + for key in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + ] { + if env.get(key).is_some_and(|value| { + !value.is_null() + && value.as_str() != Some("") + && value.as_str() != Some("0") + && value.as_str() != Some("false") + && value != false + }) { + bail!("selected subscription conflicts with {key} in Claude settings"); + } + } + } + } + Ok(()) +} + +fn bounded_auth_probe(mut command: std::process::Command) -> Result { + use std::io::Read; + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + let mut child = command + .spawn() + .context("could not verify selected Claude login")?; + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("Claude auth status timed out; the profile and its history were kept"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stdout = Vec::new(); + child + .stdout + .take() + .context("missing auth status output")? + .take(64 * 1024) + .read_to_end(&mut stdout)?; + Ok(std::process::Output { + status, + stdout, + stderr: Vec::new(), + }) +} + +fn exec(mut command: std::process::Command, args: &[OsString]) -> Result<()> { + command.args(args); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(command.exec()).context("could not execute Claude") + } + #[cfg(not(unix))] + { + let status = command.status()?; + std::process::exit(status.code().unwrap_or(1)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_references_are_identity_based_and_labels_are_redacted() { + let body = br#"{"schemaVersion":1,"accounts":[{"number":2,"email":"alice@example.test","organizationUuid":"org","alias":"work","active":false}]}"#; + let account = parse_accounts(body).unwrap().pop().unwrap(); + assert_eq!(account.id, account_id("alice@example.test", "org")); + assert_eq!(account.label, "work (a***@example.test)"); + assert!(!serde_json::to_string(&account).unwrap().contains("alice@")); + let moved = String::from_utf8(body.to_vec()) + .unwrap() + .replace("\"number\":2", "\"number\":7"); + assert_eq!(parse_accounts(moved.as_bytes()).unwrap()[0].id, account.id); + assert_ne!(account.id, account_id("alice@example.test", "another-org")); + } + + #[test] + fn discovery_fails_closed_on_unknown_shapes_or_duplicate_identities() { + for body in [ + r#"{"schemaVersion":2,"accounts":[]}"#, + r#"{"schemaVersion":1}"#, + r#"{"schemaVersion":1,"accounts":[{"number":1,"email":"a@b"},{"number":2,"email":"a@b"}]}"#, + ] { + assert!(parse_accounts(body.as_bytes()).is_err()); + } + assert!(ClaudeAccountBinding::requested("2".into()).is_err()); + } + + #[test] + fn subscription_settings_cannot_override_authentication() { + let tmp = tempfile::tempdir().unwrap(); + let profile = tmp.path().join("profile"); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&profile).unwrap(); + std::fs::create_dir_all(project.join(".claude")).unwrap(); + assert!(verify_subscription_settings(&profile, &project).is_ok()); + for body in [ + r#"{"apiKeyHelper":"return-a-key"}"#, + r#"{"env":{"ANTHROPIC_API_KEY":"must-not-win"}}"#, + r#"{"env":{"CLAUDE_CODE_USE_VERTEX":"1"}}"#, + ] { + std::fs::write(profile.join("settings.json"), body).unwrap(); + assert!(verify_subscription_settings(&profile, &project).is_err()); + } + std::fs::write( + profile.join("settings.json"), + r#"{"env":{"CLAUDE_CODE_USE_VERTEX":"0"},"theme":"dark"}"#, + ) + .unwrap(); + assert!(verify_subscription_settings(&profile, &project).is_ok()); + } +} diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs index 1d786e488..ab672f18b 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -48,12 +48,15 @@ use futures::stream::BoxStream; use serde_json::json; use tokio::sync::{broadcast, mpsc}; +use crate::execution::claude_accounts::CswapLaunch; use crate::execution::claude_common; use crate::execution::progress_bridge::{ append_event, build_chat_session_reset_event_with_reason, progress_jsonl_from_env, }; use crate::execution::session_status::{read_status_file, write_context_file, write_status_file}; -use crate::execution::transcript_tail::anthropic_project_dir; +use crate::execution::transcript_tail::{ + anthropic_bound_project_dir, anthropic_project_dir, anthropic_project_dir_in, +}; use crate::{ AgentSpecBrief, AgentVendor, ChoiceOption, ChoicePrompt, DetachOutcome, DetachedActivity, Directive, DirectiveOutcome, ExecutionMode, HarnessAdapter, HarnessError, RecoveredTurn, @@ -1721,7 +1724,14 @@ impl ClaudeStreamJsonAdapter { ); } } - let init = Self::init_transport(transport).await; + let timeout = if env.iter().any(|(key, _)| key == "CCTEAM_CSWAP_LAUNCH") + && std::env::var_os("CCTEAM_STREAM_JSON_INIT_TIMEOUT_MS").is_none() + { + Duration::from_secs(60) + } else { + init_timeout() + }; + let init = Self::init_transport(transport, timeout).await; if init.is_err() { // The failed handshake already killed the child: a record left // behind would read as a body that exited unobserved, and the @@ -1747,7 +1757,7 @@ impl ClaudeStreamJsonAdapter { .await .map_err(|e| HarnessError::SpawnFailed(format!("ccteam-exec.v1 connect: {e:#}")))?; let transport = StreamJsonTransport::spawn_from_io(reader, writer, None); - Self::init_transport(transport).await + Self::init_transport(transport, init_timeout()).await } /// Shared `initialize` control_request handshake — see @@ -1755,9 +1765,10 @@ impl ClaudeStreamJsonAdapter { /// the capability handshake stream-json uses. async fn init_transport( transport: StreamJsonTransport, + timeout: Duration, ) -> Result<(Arc, protocol::SystemMsg), HarnessError> { match transport - .request_control("initialize", json!({}), init_timeout()) + .request_control("initialize", json!({}), timeout) .await { Ok(body) if body.subtype == "success" => Ok(( @@ -1881,7 +1892,22 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // a local resume failure — same contract, just optimistic instead // of fs-checked (tech-design §4.4: satellite `--resume` is how a // remote rebuild continues context, PRD F3.4). - let resume = ctx.remote.is_some() || Self::session_jsonl_exists(&ctx.cwd, &uuid); + if ctx.claude_account.is_some() && ctx.remote.is_some() { + return Err(HarnessError::SpawnFailed( + "cswap accounts are not supported on remote projects".into(), + )); + } + let resume = ctx.remote.is_some() + || ctx + .claude_account + .as_ref() + .and_then(|account| account.config_dir.as_ref()) + .map(|dir| { + anthropic_project_dir_in(&dir.join("projects"), &ctx.cwd) + .join(format!("{uuid}.jsonl")) + .exists() + }) + .unwrap_or_else(|| Self::session_jsonl_exists(&ctx.cwd, &uuid)); // v0.8.24 C1 / v0.9.0 W3 — curated per-session MCP. `--mcp-config` // is a RELATIVE path (`.ccteam/chat//mcp.json`, cwd = project @@ -1930,6 +1956,7 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // `ccteam-exec.v1` WS bridge — tech-design §0.4: execution location // is a transport parameter); everything after this `match` is // identical for both. + let mut effective_account = ctx.claude_account.clone(); let (transport, init) = if let Some(remote) = ctx.remote.as_ref() { let build_spec = |resume: bool| { Self::build_exec_spec(ctx, make_argv(resume), &env, ship_mcp, &mcp_relpath) @@ -1960,6 +1987,21 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { } } else { let body = Some((ctx.project_dir.as_path(), ctx.sid.as_str())); + let launch = match ctx.claude_account.as_ref() { + Some(account) => Some( + CswapLaunch::prepare( + account, + &make_argv(resume), + &env, + &ctx.cwd, + &crate::execution::turns_mirror::chat_dir(&ctx.project_dir, &ctx.sid), + &uuid, + ) + .await + .map_err(|error| HarnessError::SpawnFailed(format!("cswap: {error:#}")))?, + ), + None => None, + }; // `resume` here means the transcript `.jsonl` is on disk, and // claude refuses `--session-id ` for an existing transcript // ("Session ID … is already in use", exit 1): a fresh fallback on @@ -1967,7 +2009,9 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // resume into a second, misleading failure. Surface the resume // failure itself. Nothing sticks: the transcript is untouched and // the next message tries `--resume` again (docs-local issue #211). - Self::spawn_and_init(&make_argv(resume), &env, &ctx.cwd, body) + let result = Self::spawn_and_init( + &launch.as_ref().map(|launch| launch.argv.clone()).unwrap_or_else(|| make_argv(resume)), + launch.as_ref().map(|launch| launch.env.as_slice()).unwrap_or(&env), &ctx.cwd, body) .await .map_err(|err| { if !resume { @@ -1983,7 +2027,19 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { "claude --resume {uuid} failed: {err}. The conversation is intact on \ disk; the next resume retries it" )) - })? + })?; + if let Some(launch) = launch { + match launch.binding() { + Ok(binding) => effective_account = Some(binding), + Err(error) => { + let _ = result.0.shutdown().await; + return Err(HarnessError::SpawnFailed(format!( + "cswap profile confirmation failed: {error:#}" + ))); + } + } + } + result }; let identity = SessionIdentity { @@ -2173,6 +2229,7 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { "role": spec.role, "project_dir": ctx.project_dir.to_string_lossy(), "cwd": ctx.cwd.to_string_lossy(), + "claude_account": effective_account, }), }) } @@ -2549,7 +2606,8 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { if ctx.vendor_uuid.is_empty() { return None; } - let path = anthropic_project_dir(&ctx.cwd)?.join(format!("{}.jsonl", ctx.vendor_uuid)); + let path = anthropic_bound_project_dir(&ctx.cwd, ctx.claude_account.as_ref())? + .join(format!("{}.jsonl", ctx.vendor_uuid)); let observed_until = ctx.observed_until; let last = ctx.last_observed_assistant.clone(); tokio::task::spawn_blocking(move || { @@ -2564,7 +2622,8 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { if ctx.vendor_uuid.is_empty() { return None; } - let path = anthropic_project_dir(&ctx.cwd)?.join(format!("{}.jsonl", ctx.vendor_uuid)); + let path = anthropic_bound_project_dir(&ctx.cwd, ctx.claude_account.as_ref())? + .join(format!("{}.jsonl", ctx.vendor_uuid)); tokio::task::spawn_blocking(move || recovery::activity(&path, std::time::SystemTime::now())) .await .ok() @@ -2853,9 +2912,17 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // it belongs. if let Some(cwd) = h.raw_extras.get("cwd").and_then(|v| v.as_str()) { let (cwd, uuid) = (PathBuf::from(cwd), h.identity.clone()); - let gating = tokio::task::spawn_blocking(move || turn_gating::read(&cwd, &uuid)) - .await - .unwrap_or(None); + let config_dir = h + .raw_extras + .get("claude_account") + .and_then(|value| value.get("config_dir")) + .and_then(|value| value.as_str()) + .map(PathBuf::from); + let gating = tokio::task::spawn_blocking(move || { + turn_gating::read(&cwd, &uuid, config_dir.as_deref()) + }) + .await + .unwrap_or(None); status.goal = gating.as_ref().and_then(|gating| gating.goal.clone()); status.stop_hook_blocks = gating.map(|gating| gating.stop_hook_blocks); } diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs b/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs index 4747e6729..2922e16a0 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs @@ -115,6 +115,11 @@ impl StreamJsonTransport { .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + // Only account launches clear inherited profile selection. Normal + // Claude starts preserve the daemon user's existing environment. + if env.iter().any(|(key, _)| key == "CCTEAM_CSWAP_LAUNCH") { + cmd.env_remove("CLAUDE_CONFIG_DIR"); + } // The body leads its own process group, so ending it (a user stop, or // a detached body a queued turn is waiting on) can end the background // processes it started too — `session_body::terminate` signals the @@ -402,7 +407,7 @@ async fn run_reader( let parsed: Outbound = match serde_json::from_str(trimmed) { Ok(v) => v, Err(err) => { - tracing::warn!(error = %err, line = %trimmed, "stream-json: parse failure"); + tracing::warn!(error = %err, "stream-json: discarded non-protocol output"); continue; } }; diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs index 4c10b5952..a6e6c0670 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs @@ -15,7 +15,7 @@ use std::path::Path; use std::sync::LazyLock; use crate::execution::journal::{FoldCache, LineFold}; -use crate::execution::transcript_tail::anthropic_project_dir; +use crate::execution::transcript_tail::{anthropic_project_dir, anthropic_project_dir_in}; /// The session's current `/goal` + recent Stop-hook refusals. Read together /// because they are one answer: a goal whose condition is not met yet is @@ -155,8 +155,12 @@ impl GatingScan { /// met}}` record on the user turn. `None` when the transcript is absent or /// unreadable, which is what makes "cannot tell" distinguishable from /// "nothing refused". -pub(super) fn read(cwd: &Path, uuid: &str) -> Option { - let path = anthropic_project_dir(cwd)?.join(format!("{uuid}.jsonl")); +pub(super) fn read(cwd: &Path, uuid: &str, config_dir: Option<&Path>) -> Option { + let directory = match config_dir { + Some(dir) => anthropic_project_dir_in(&dir.join("projects"), cwd), + None => anthropic_project_dir(cwd)?, + }; + let path = directory.join(format!("{uuid}.jsonl")); read_path(&path, chrono::Utc::now()) } diff --git a/crates/ccteam-harness/src/execution/experience.rs b/crates/ccteam-harness/src/execution/experience.rs index 2ab9ac65c..4020d0e8b 100644 --- a/crates/ccteam-harness/src/execution/experience.rs +++ b/crates/ccteam-harness/src/execution/experience.rs @@ -553,6 +553,7 @@ mod tests { let project = tmp.path(); let now = Utc::now(); let meta = super::super::session_meta::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -631,6 +632,7 @@ mod tests { // Seed meta + turns for s1. let now = Utc::now(); let meta = super::super::session_meta::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-harness/src/execution/mod.rs b/crates/ccteam-harness/src/execution/mod.rs index 6c04b198b..16d681ad0 100644 --- a/crates/ccteam-harness/src/execution/mod.rs +++ b/crates/ccteam-harness/src/execution/mod.rs @@ -1,6 +1,7 @@ //! Execution support modules shared by concrete harness adapters. //! pub mod acp; +pub mod claude_accounts; pub mod claude_bg; pub mod claude_common; pub mod claude_stream_json; diff --git a/crates/ccteam-harness/src/execution/session_meta.rs b/crates/ccteam-harness/src/execution/session_meta.rs index 1bf681a2e..309a7e9ec 100644 --- a/crates/ccteam-harness/src/execution/session_meta.rs +++ b/crates/ccteam-harness/src/execution/session_meta.rs @@ -149,6 +149,9 @@ impl ManagedBy { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMeta { + /// Private cswap binding, including the actual profile selected at exec. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_account: Option, pub sid: String, pub slug: String, pub vendor: AgentVendor, @@ -707,6 +710,7 @@ mod title_tests { fn blank_meta() -> SessionMeta { SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -747,6 +751,7 @@ mod title_tests { fn rescans_see_every_rewrite_addition_and_removal() { let tmp = tempfile::TempDir::new().unwrap(); let meta = |sid: &str, title: &str| SessionMeta { + claude_account: None, sid: sid.into(), title: Some(title.into()), ..blank_meta() @@ -794,6 +799,7 @@ mod title_tests { for _ in 0..50 { let tmp = tempfile::TempDir::new().unwrap(); let meta = |title: &str| SessionMeta { + claude_account: None, title: Some(title.into()), ..blank_meta() }; @@ -818,6 +824,7 @@ mod title_tests { fn an_unchanged_stamp_on_a_fresh_file_is_not_trusted() { let tmp = tempfile::TempDir::new().unwrap(); let meta = |title: &str| SessionMeta { + claude_account: None, title: Some(title.into()), ..blank_meta() }; diff --git a/crates/ccteam-harness/src/execution/transcript_tail.rs b/crates/ccteam-harness/src/execution/transcript_tail.rs index 5860b8caa..0b3ed1adc 100644 --- a/crates/ccteam-harness/src/execution/transcript_tail.rs +++ b/crates/ccteam-harness/src/execution/transcript_tail.rs @@ -181,6 +181,22 @@ pub fn anthropic_project_dir(cwd: &Path) -> Option { Some(resolve_project_dir_in(&base, cwd)) } +/// Resolve a managed sid's bound Claude profile for reads that outlive its +/// process (recovery, titles). An unreadable binding must never fall back to +/// another account's history. +pub fn anthropic_bound_project_dir( + cwd: &Path, + account: Option<&super::claude_accounts::ClaudeAccountBinding>, +) -> Option { + match account { + Some(account) => Some(anthropic_project_dir_in( + &account.config_dir.as_ref()?.join("projects"), + cwd, + )), + None => anthropic_project_dir(cwd), + } +} + /// [`anthropic_project_dir`] against an explicitly supplied Claude projects /// root — the injection seam (same shape as [`discover_active_session_in`]) so /// tests never touch the real `~/.claude/projects/`. diff --git a/crates/ccteam-harness/src/execution/vendor_title.rs b/crates/ccteam-harness/src/execution/vendor_title.rs index 8eba87c60..2f9a92fa3 100644 --- a/crates/ccteam-harness/src/execution/vendor_title.rs +++ b/crates/ccteam-harness/src/execution/vendor_title.rs @@ -71,6 +71,12 @@ fn resolve_claude_uuid(target: &SessionTitleTarget) -> Option { /// (stream-json and the frozen terminal protocol) — the title surface is a /// property of the vendor, not of the wire protocol. pub fn push_claude_custom_title(target: &SessionTitleTarget, title: &str) -> TitleSync { + if let Some(account) = &target.claude_account { + let Some(config_dir) = &account.config_dir else { + return TitleSync::Deferred("session profile has not been confirmed".into()); + }; + return push_claude_custom_title_in(&config_dir.join("projects"), target, title); + } let Some(home) = dirs::home_dir() else { return TitleSync::Deferred("no home dir to resolve ~/.claude/projects".into()); }; @@ -122,6 +128,7 @@ mod tests { fn target(project_dir: &Path, uuid: &str) -> SessionTitleTarget { SessionTitleTarget { + claude_account: None, sid: "s7".into(), vendor_uuid: uuid.into(), project_dir: project_dir.to_path_buf(), diff --git a/crates/ccteam-harness/tests/claude_agent_smoke_test.rs b/crates/ccteam-harness/tests/claude_agent_smoke_test.rs index fc97dd91b..08b9c011a 100644 --- a/crates/ccteam-harness/tests/claude_agent_smoke_test.rs +++ b/crates/ccteam-harness/tests/claude_agent_smoke_test.rs @@ -170,6 +170,7 @@ fn write_hook_logger(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf) { fn make_ctx(cwd: &Path) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: SLUG.to_string(), @@ -496,6 +497,7 @@ fn seed_hitl_persona(project_dir: &Path, victim: &Path) { fn make_ctx_hitl(cwd: &Path) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: HITL_SLUG.to_string(), @@ -635,6 +637,7 @@ async fn run_hitl_decision_turn(allow: bool, tag: &str) -> (bool, bool) { let adapter = ClaudeTuiAdapter::new(); let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.clone(), diff --git a/crates/ccteam-harness/tests/claude_stream_json_remote_test.rs b/crates/ccteam-harness/tests/claude_stream_json_remote_test.rs index 047e601e3..8473ed56f 100644 --- a/crates/ccteam-harness/tests/claude_stream_json_remote_test.rs +++ b/crates/ccteam-harness/tests/claude_stream_json_remote_test.rs @@ -117,6 +117,7 @@ fn ctx_remote( target: RemoteExecTarget, ) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), diff --git a/crates/ccteam-harness/tests/claude_stream_json_test.rs b/crates/ccteam-harness/tests/claude_stream_json_test.rs index 08c7e2fb8..03bafdf7c 100644 --- a/crates/ccteam-harness/tests/claude_stream_json_test.rs +++ b/crates/ccteam-harness/tests/claude_stream_json_test.rs @@ -234,6 +234,7 @@ fn setup(tmp: &Path) -> PathBuf { fn ctx(tmp: &Path, slug: &str, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), @@ -578,6 +579,7 @@ async fn child_death_then_restart_recovers() { fn ctx_hitl(tmp: &Path, slug: &str, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, permission_mode: PermissionMode::Hitl, ..ctx(tmp, slug, sid) @@ -2601,6 +2603,7 @@ async fn recover_unobserved_turn_reads_the_vendor_transcript() { .with_timezone(&chrono::Utc); let recovered = adapter .recover_unobserved_turn(&UnobservedTurnCtx { + claude_account: None, sid: "s43".into(), slug: "proj".into(), cwd: tmp.path().to_path_buf(), @@ -2623,6 +2626,7 @@ async fn recover_unobserved_turn_reads_the_vendor_transcript() { .with_timezone(&chrono::Utc); assert!(adapter .recover_unobserved_turn(&UnobservedTurnCtx { + claude_account: None, sid: "s43".into(), slug: "proj".into(), cwd: tmp.path().to_path_buf(), diff --git a/crates/ccteam-harness/tests/claude_tui_env_test.rs b/crates/ccteam-harness/tests/claude_tui_env_test.rs index bf9d4edc6..db8315e3f 100644 --- a/crates/ccteam-harness/tests/claude_tui_env_test.rs +++ b/crates/ccteam-harness/tests/claude_tui_env_test.rs @@ -39,6 +39,7 @@ const F175_SID: &str = "s1"; fn make_ctx(slug: &str, tmp: &tempfile::TempDir) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), diff --git a/crates/ccteam-harness/tests/claude_tui_reattach_test.rs b/crates/ccteam-harness/tests/claude_tui_reattach_test.rs index 4f43840ee..9258cfeb1 100644 --- a/crates/ccteam-harness/tests/claude_tui_reattach_test.rs +++ b/crates/ccteam-harness/tests/claude_tui_reattach_test.rs @@ -54,6 +54,7 @@ fn fake_claude_script(tmp: &tempfile::TempDir) -> PathBuf { fn make_ctx(slug: &str, sid: &str, role: &str, tmp: &tempfile::TempDir) -> SpawnCtx { let _ = role; // role rides on AgentSpecBrief, not the pane identity. SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), diff --git a/crates/ccteam-harness/tests/claude_tui_resume_test.rs b/crates/ccteam-harness/tests/claude_tui_resume_test.rs index 48cc07152..603880eb7 100644 --- a/crates/ccteam-harness/tests/claude_tui_resume_test.rs +++ b/crates/ccteam-harness/tests/claude_tui_resume_test.rs @@ -147,6 +147,7 @@ fn make_ctx(slug: &str, tmp: &tempfile::TempDir) -> SpawnCtx { /// same-(project,role) sessions stay distinct). fn make_ctx_sid(slug: &str, sid: &str, tmp: &tempfile::TempDir) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), @@ -167,6 +168,7 @@ fn make_ctx_sid(slug: &str, sid: &str, tmp: &tempfile::TempDir) -> SpawnCtx { /// the hitl spawn drops the skip flag and carries `--permission-mode default`. fn make_ctx_hitl(slug: &str, tmp: &tempfile::TempDir) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.to_string(), diff --git a/crates/ccteam-harness/tests/claude_tui_test.rs b/crates/ccteam-harness/tests/claude_tui_test.rs index 6384b1671..9bf3e778e 100644 --- a/crates/ccteam-harness/tests/claude_tui_test.rs +++ b/crates/ccteam-harness/tests/claude_tui_test.rs @@ -46,6 +46,7 @@ fn kill_session_quiet(name: &str) { async fn spawn_time_effort_is_refused_rather_than_ignored() { let tmp = tempfile::TempDir::new().unwrap(); let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "tui-effort".into(), @@ -96,6 +97,7 @@ async fn start_thread_spawns_tmux_and_returns_handle() { let brief = AgentSpecBrief { role: role.clone() }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.clone(), @@ -148,6 +150,7 @@ async fn submit_turn_sends_literal_text_to_tmux_pane() { let brief = AgentSpecBrief { role: role.clone() }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.clone(), @@ -215,6 +218,7 @@ async fn submit_turn_artifact_uses_read_protocol() { let brief = AgentSpecBrief { role: role.clone() }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.clone(), @@ -502,6 +506,7 @@ async fn claude_directive_bare_model_passes_through_to_native_picker() { role: "alice".to_string(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: slug.clone(), diff --git a/crates/ccteam-harness/tests/codex_app_server_test.rs b/crates/ccteam-harness/tests/codex_app_server_test.rs index 1d891b376..64c23652b 100644 --- a/crates/ccteam-harness/tests/codex_app_server_test.rs +++ b/crates/ccteam-harness/tests/codex_app_server_test.rs @@ -64,6 +64,7 @@ async fn real_codex_app_server_start_thread_smoke() { role: "real-smoke".to_string(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "real-codex-smoke".to_string(), @@ -130,6 +131,7 @@ async fn real_codex_reply_roundtrip_proves_model_output() { role: "real-reply".to_string(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "real-codex-reply".to_string(), @@ -328,6 +330,7 @@ async fn raw_extras_transport_is_resolved_tag() { role: "demo".into(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "test".into(), @@ -393,6 +396,7 @@ async fn spawn_time_model_and_effort_ride_the_first_turn_start() { role: "demo".into(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "tuned".into(), @@ -473,6 +477,7 @@ async fn submit_turn_resumes_unloaded_thread_before_turn_start() { role: "demo".into(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "ensure-loaded".into(), @@ -585,6 +590,7 @@ async fn ensure_loaded_resume_carries_the_per_thread_mcp_principal() { role: String::new(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "excore".into(), @@ -670,6 +676,7 @@ async fn f10_real_codex_stdio_new_smoke() { role: "f10-stdio-smoke".to_string(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "f10-stdio-smoke".to_string(), @@ -1016,6 +1023,7 @@ async fn adapter_returns_spawn_failed_when_socket_missing() { role: "demo".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "test".into(), @@ -1096,6 +1104,7 @@ async fn adapter_sends_initialize_handshake_before_thread_start() { role: "demo".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "test".into(), @@ -1200,6 +1209,7 @@ async fn adapter_start_thread_against_scripted_peer() { role: "demo".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "test".into(), @@ -1272,6 +1282,7 @@ async fn adapter_maps_system_directives_to_command_rpcs() { role: "demo".into(), }; let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "test".into(), @@ -1533,6 +1544,7 @@ async fn d2_start_in( role: "demo".into(), }, &SpawnCtx { + claude_account: None, generation, mode: None, slug: "test".into(), @@ -1712,6 +1724,7 @@ async fn set_session_title_pushes_thread_name_set() { let (adapter, h, seen, peer, sock) = d2_start("d2-title").await; let target = SessionTitleTarget { + claude_account: None, sid: "s42".into(), vendor_uuid: h.identity.clone(), project_dir: std::env::temp_dir(), @@ -1742,6 +1755,7 @@ async fn set_session_title_pushes_thread_name_set() { async fn set_session_title_without_a_live_thread_is_deferred() { let adapter = CodexAppServerAdapter::new(); let target = SessionTitleTarget { + claude_account: None, sid: "s43".into(), vendor_uuid: "tid-gone".into(), project_dir: std::env::temp_dir(), @@ -3862,6 +3876,7 @@ async fn start_thread_injects_per_thread_mcp_config_with_identity() { role: "reviewer".into(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -3951,6 +3966,7 @@ async fn start_thread_resumes_persisted_vendor_uuid_after_restart() { // Simulate the "before the restart" state: a prior codex session whose // vendor_uuid is persisted in meta.json (what apply_new_session writes). let meta = ccteam_harness::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -3993,6 +4009,7 @@ async fn start_thread_resumes_persisted_vendor_uuid_after_restart() { role: "reviewer".into(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -4057,6 +4074,7 @@ async fn start_thread_resumes_persisted_vendor_uuid_after_restart() { async fn start_thread_rejects_remote_ctx_readable() { let tmp = TempDir::new().unwrap(); let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -4145,6 +4163,7 @@ impl Drop for FakeAppServerEnv { fn fake_spawn_ctx(tmp: &std::path::Path, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "fake-codex".to_string(), diff --git a/crates/ccteam-harness/tests/dsh_acp_test.rs b/crates/ccteam-harness/tests/dsh_acp_test.rs index 963e1b374..be2f452ba 100644 --- a/crates/ccteam-harness/tests/dsh_acp_test.rs +++ b/crates/ccteam-harness/tests/dsh_acp_test.rs @@ -242,6 +242,7 @@ fn adapter() -> DshAcpAdapter { fn spawn_ctx(tmp: &TempDir, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -269,6 +270,7 @@ fn spawn_ctx_with_model(tmp: &TempDir, sid: &str) -> SpawnCtx { fn write_meta(project: &Path, sid: &str, vendor_uuid: &str) { let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-harness/tests/grok_acp_test.rs b/crates/ccteam-harness/tests/grok_acp_test.rs index c5ad2394a..247fb04f2 100644 --- a/crates/ccteam-harness/tests/grok_acp_test.rs +++ b/crates/ccteam-harness/tests/grok_acp_test.rs @@ -66,6 +66,7 @@ fn clear_fake() { fn spawn_ctx(tmp: &TempDir, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -547,6 +548,7 @@ async fn load_resume_filters_is_replay() { let sid = "s-resume"; // Seed meta as if a prior session existed with known ACP sessionId. let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -812,6 +814,7 @@ async fn session_new_and_load_carry_mcp_servers() { } let ctx_with_secret = |sid: &str| SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -843,6 +846,7 @@ async fn session_new_and_load_carry_mcp_servers() { // Phase B — cold resume via session/load (meta carries the vendor_uuid). let sid_load = "s-load-mcp"; let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -943,6 +947,7 @@ async fn session_new_offers_this_sessions_principal_verbatim() { role: String::new(), }, &SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), diff --git a/crates/ccteam-harness/tests/kimi_acp_test.rs b/crates/ccteam-harness/tests/kimi_acp_test.rs index 2aaed6667..36b4c3bc4 100644 --- a/crates/ccteam-harness/tests/kimi_acp_test.rs +++ b/crates/ccteam-harness/tests/kimi_acp_test.rs @@ -54,6 +54,7 @@ fn clear_fake() { fn spawn_ctx(tmp: &TempDir, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -321,6 +322,7 @@ async fn resume_prefers_session_resume_no_replay() { let project = tmp.path(); let sid = "s-resume"; let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -417,6 +419,7 @@ async fn resume_carries_mcp_servers() { } let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -453,6 +456,7 @@ async fn resume_carries_mcp_servers() { // A SpawnCtx WITH a secret → `acp_mcp_servers_http` yields a non-empty array. let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), diff --git a/crates/ccteam-harness/tests/opencode_acp_test.rs b/crates/ccteam-harness/tests/opencode_acp_test.rs index 224bd7649..330caa674 100644 --- a/crates/ccteam-harness/tests/opencode_acp_test.rs +++ b/crates/ccteam-harness/tests/opencode_acp_test.rs @@ -57,6 +57,7 @@ fn clear_fake() { fn spawn_ctx(tmp: &TempDir, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), @@ -264,6 +265,7 @@ async fn resume_prefers_session_resume_no_replay() { let project = tmp.path(); let sid = "s-resume"; let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -361,6 +363,7 @@ async fn resume_carries_mcp_servers() { } let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -397,6 +400,7 @@ async fn resume_carries_mcp_servers() { // A SpawnCtx WITH a secret → `acp_mcp_servers_http` yields a non-empty array. let ctx = SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "demo".into(), diff --git a/crates/ccteam-harness/tests/pi_bridge_test.rs b/crates/ccteam-harness/tests/pi_bridge_test.rs index 982867633..21a53ccc7 100644 --- a/crates/ccteam-harness/tests/pi_bridge_test.rs +++ b/crates/ccteam-harness/tests/pi_bridge_test.rs @@ -70,6 +70,7 @@ impl TestEnv { fn ctx(&self, permission_mode: PermissionMode) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "pi-bridge".into(), diff --git a/crates/ccteam-harness/tests/pi_rpc_test.rs b/crates/ccteam-harness/tests/pi_rpc_test.rs index fe7944dc3..b89247a31 100644 --- a/crates/ccteam-harness/tests/pi_rpc_test.rs +++ b/crates/ccteam-harness/tests/pi_rpc_test.rs @@ -85,6 +85,7 @@ impl PiTestEnv { fn ctx(&self, sid: &str) -> SpawnCtx { SpawnCtx { + claude_account: None, generation: 0, mode: None, slug: "pi-test".to_string(), diff --git a/crates/ccteam-harness/tests/session_meta_test.rs b/crates/ccteam-harness/tests/session_meta_test.rs index ec3d59cde..5af62f0fa 100644 --- a/crates/ccteam-harness/tests/session_meta_test.rs +++ b/crates/ccteam-harness/tests/session_meta_test.rs @@ -15,6 +15,7 @@ use tempfile::TempDir; fn sample_meta(sid: &str, slug: &str, last_active: &str, uuid: &str) -> SessionMeta { SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-im/src/external_nodes.rs b/crates/ccteam-im/src/external_nodes.rs index b8587ef36..46159b99c 100644 --- a/crates/ccteam-im/src/external_nodes.rs +++ b/crates/ccteam-im/src/external_nodes.rs @@ -64,6 +64,7 @@ pub fn external_node_meta( AgentVendor::Claude | AgentVendor::Codex | AgentVendor::Pi => SessionProtocol::StreamJson, }; let mut meta = SessionMeta { + claude_account: None, model_pinned_generation: None, tool_face: None, managed_by: ManagedBy::External, diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 39ac68e4c..e6f19d2a1 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -64,6 +64,16 @@ pub struct RoleNotFound { pub role: String, } +/// Account grants are independent from project visibility (#229). +#[derive(Debug, thiserror::Error)] +#[error("cswap account is not available to this identity")] +pub struct CswapAccountDenied; + +/// The frozen terminal protocol and remote projects do not support cswap. +#[derive(Debug, thiserror::Error)] +#[error("cswap accounts require a local Claude stream-json session")] +pub struct CswapAccountUnsupported; + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] struct ChatKey { channel: String, @@ -2487,6 +2497,9 @@ pub struct SessionInventory { // `assert_eq!`/`==`, which only need `PartialEq`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionView { + /// Display-only account facts; private profile paths stay in meta.json. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_account: Option, /// Gateway session id (`s{n}`) — the network-API session namespace. pub sid: String, /// Project slug the session runs in. @@ -2750,6 +2763,8 @@ pub struct StartOutcome { /// exist so a caller can pick well; they are never a gate. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SpawnTuning { + /// Stable identity reference returned by optional cswap discovery. + pub claude_account: Option, /// Explicit model id; overrides the role's `model:` frontmatter. pub model: Option, /// Explicit reasoning-effort token (vendor-specific value set — the @@ -2765,6 +2780,7 @@ impl SpawnTuning { fn normalized(self) -> Self { let clean = |s: Option| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); Self { + claude_account: clean(self.claude_account), model: clean(self.model), effort: clean(self.effort), mode: clean(self.mode), @@ -3102,6 +3118,7 @@ struct RoutingState { /// stale session's `system:init` — while the on-demand resume path runs the /// same core under its already-held lock. One construction site, no drift. struct MetaRebuildPlan { + claude_account: Option, generation: u64, sid: String, slug: String, @@ -3154,6 +3171,7 @@ struct MetaRebuildPlan { /// construction site (`plan_new_session`), no drift between the inline and /// lock-dropping callers. struct NewSessionPlan { + claude_account: Option, generation: u64, id: String, /// Canonical resource owner used by session ACL and persisted meta. @@ -3214,6 +3232,7 @@ struct NewSessionPlan { /// marker from the OLD thread (`identity@started_at`); apply discards the /// freshly spawned thread if the session vanished or was replaced meanwhile. struct ResumeDeadPlan { + claude_account: Option, session_id: String, project: String, role: String, @@ -3930,6 +3949,17 @@ impl Gateway { BodyGate::PostMortem(body) => Some(body), }; let (host, wire_slug) = self.ensure_session_host_binding(slug, &meta.host)?; + if let Some(account) = &meta.claude_account { + if meta.vendor != AgentVendor::Claude + || meta.protocol != SessionProtocol::StreamJson + || host != ccteam_core::LOCAL_HOST + { + return Err(CswapAccountUnsupported.into()); + } + if !account.default_login { + self.authorize_cswap_account(reply_to, &account.id)?; + } + } let role_detail = ensure_role_exists(&cwd, &meta.role).ok().flatten(); // Both tuning axes, one precedence — see `respawn_tuning`. let (model_id, effort) = respawn_tuning( @@ -3973,6 +4003,7 @@ impl Gateway { self.rebuild_reservations .insert(meta.sid.clone(), generation); Ok(MetaRebuildPlan { + claude_account: meta.claude_account.clone(), generation, sid: meta.sid.clone(), slug: slug.to_string(), @@ -4054,6 +4085,7 @@ impl Gateway { role: plan.role.clone(), }, &SpawnCtx { + claude_account: plan.claude_account.clone(), generation: plan.generation, slug: plan.slug.clone(), sid: plan.sid.clone(), @@ -5009,6 +5041,7 @@ impl Gateway { }; let activity = adapter .detached_activity(&UnobservedTurnCtx { + claude_account: meta.claude_account.clone(), sid: d.sid.clone(), slug: d.slug.clone(), cwd: d.cwd.clone(), @@ -5304,6 +5337,7 @@ impl Gateway { }; let recovered = adapter .recover_unobserved_turn(&UnobservedTurnCtx { + claude_account: meta.claude_account.clone(), sid: sid.to_string(), slug: slug.to_string(), cwd: cwd.to_path_buf(), @@ -6181,8 +6215,17 @@ impl Gateway { role, permission_mode, protocol, - tuning, + mut tuning, } = parse_new_command_args(&args)?; + if vendor == AgentVendor::Claude && tuning.claude_account.is_none() { + tuning.claude_account = self + .current_session + .get(chat) + .and_then(|sid| self.session_catalog.find_or_load(&sid, &self.projects)) + .and_then(|entry| entry.meta.claude_account) + .filter(|binding| !binding.default_login) + .map(|binding| binding.id); + } let project = self.require_current_project(chat)?; let handle = role.clone(); let outcome = self @@ -7608,6 +7651,19 @@ impl Gateway { // v0.8.24 A-U3 — an explicit composer choice beats the role's // `model:` frontmatter; effort has no role-level default. let tuning = tuning.normalized(); + let claude_account = tuning + .claude_account + .map(|id| { + if vendor != AgentVendor::Claude + || protocol != SessionProtocol::StreamJson + || host != ccteam_core::LOCAL_HOST + { + return Err(CswapAccountUnsupported.into()); + } + self.authorize_cswap_account(&reply_to, &id)?; + ccteam_harness::execution::claude_accounts::ClaudeAccountBinding::requested(id) + }) + .transpose()?; let model_id = tuning.model.or_else(|| { // Pi owns a vendor-specific `pi.model` frontmatter key. Passing // the generic role model through SpawnCtx would make it look like @@ -7669,6 +7725,7 @@ impl Gateway { .tenant_project_owner(&project) .unwrap_or_else(|| canonical_owner(&reply_to)); Ok(NewSessionPlan { + claude_account, generation, id, owner, @@ -7768,6 +7825,7 @@ impl Gateway { role: plan.role.clone(), }, &SpawnCtx { + claude_account: plan.claude_account.clone(), generation: plan.generation, slug: plan.project.clone(), sid: plan.id.clone(), @@ -7810,6 +7868,12 @@ impl Gateway { } }); let mut meta = SessionMeta { + claude_account: thread + .raw_extras + .get("claude_account") + .filter(|value| !value.is_null()) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .or_else(|| plan.claude_account.clone()), model_pinned_generation: None, managed_by: Default::default(), stopped_at: None, @@ -7872,6 +7936,7 @@ impl Gateway { let meta = prepared_meta.unwrap_or_else(|| Self::meta_for_new_session(&plan, &thread, trigger)); let NewSessionPlan { + claude_account: _, generation, id, owner, @@ -8060,6 +8125,26 @@ impl Gateway { )); } }; + let claude_account = self + .session_catalog + .find_or_load(&sid, &self.projects) + .and_then(|entry| entry.meta.claude_account); + if claude_account.is_none() + && old_thread + .raw_extras + .get("claude_account") + .is_some_and(|value| !value.is_null()) + { + return Err(anyhow!( + "session account metadata is unavailable; the current process was kept" + )); + } + if let Some(account) = claude_account + .as_ref() + .filter(|account| !account.default_login) + { + self.authorize_cswap_account(chat, &account.id)?; + } // Tear down the old pane + its event pump before re-spawning so the // same-sid pane is recreated cleanly and no stale pump keeps draining // the retired transcript. @@ -8111,6 +8196,7 @@ impl Gateway { model_id.clone(), effort.clone(), mode.clone(), + claude_account, permission_mode, secret.clone(), &host, @@ -11122,6 +11208,11 @@ impl Gateway { let host = s.host.clone(); let permission_mode = s.permission_mode; let secret = s.secret.clone(); + let had_account = s + .thread + .raw_extras + .get("claude_account") + .is_some_and(|value| !value.is_null()); let fenced_generation = s.generation; // Sync a possibly-registered-after-start project from the config.yaml // SoT before the lookup (mirrors start_session / `/role`). @@ -11139,6 +11230,23 @@ impl Gateway { .find_or_load(session_id, &self.projects) .map(|entry| entry.meta); let mode = meta.as_ref().and_then(|m| m.mode.clone()); + let claude_account = meta.as_ref().and_then(|meta| meta.claude_account.clone()); + if had_account && claude_account.is_none() { + return Err(anyhow!( + "session account metadata is unavailable; refusing to resume with default login" + )); + } + if let Some(account) = &claude_account { + if vendor != AgentVendor::Claude + || protocol != SessionProtocol::StreamJson + || host != ccteam_core::LOCAL_HOST + { + return Err(CswapAccountUnsupported.into()); + } + if !account.default_login { + self.authorize_cswap_account(&owner, &account.id)?; + } + } let (model_id, effort) = respawn_tuning( &cwd, session_id, @@ -11160,6 +11268,7 @@ impl Gateway { // apply installs this same value on the live entry (issue #14②). let generation = self.next_live_generation(); Ok(Some(ResumeDeadPlan { + claude_account, session_id: session_id.to_string(), project, role, @@ -11204,6 +11313,7 @@ impl Gateway { role: plan.role.clone(), }, &SpawnCtx { + claude_account: plan.claude_account.clone(), generation: plan.generation, slug: plan.project.clone(), sid: plan.session_id.clone(), @@ -11233,6 +11343,7 @@ impl Gateway { thread: ThreadHandle, ) -> Result<()> { let ResumeDeadPlan { + claude_account: _, session_id, project, role, @@ -11333,6 +11444,7 @@ impl Gateway { effort: Option, // Replayed from `meta.mode`, same contract. mode: Option, + claude_account: Option, permission_mode: PermissionMode, secret: String, host: &str, @@ -11364,6 +11476,7 @@ impl Gateway { role: role.to_string(), }, &SpawnCtx { + claude_account, generation, slug: slug.to_string(), sid: sid.to_string(), @@ -12492,6 +12605,51 @@ impl Gateway { Principal::Guest(canonical_owner(chat).identity()) } + /// Optional account discovery and creation share the same principal + /// resolver and grants. `None` means operator, `Some` is the allowed set. + pub fn cswap_account_access(&self, web_chat_id: &str) -> Result>> { + self.cswap_account_access_for_chat(&ChatKey::new("web", web_chat_id, web_chat_id)) + } + + fn cswap_account_access_for_chat(&self, chat: &ChatKey) -> Result>> { + // Stored owners use user:; resolve that trusted canonical owner + // through the existing web principal parser for cold resumes. + let web_owner = + (chat.channel == "user").then(|| ChatKey::new("web", &chat.chat_id, &chat.chat_id)); + let principal = self.principal(web_owner.as_ref().unwrap_or(chat)); + if principal == Principal::Operator { + return Ok(None); + } + let owner = match principal { + Principal::Tenant(id) => ccteam_core::identity::owner_tag(&id, false), + Principal::Guest(owner) => owner, + Principal::Operator => unreachable!(), + }; + let Some(paths) = &self.project_paths else { + return Ok(Some(Vec::new())); + }; + let config = ccteam_core::config::load(&paths.root)?; + Ok(Some( + config + .cswap_account_grants + .get(&owner) + .cloned() + .unwrap_or_default(), + )) + } + + fn authorize_cswap_account(&self, chat: &ChatKey, id: &str) -> Result<()> { + let access = self.cswap_account_access_for_chat(chat)?; + if !ccteam_core::identity::can_use_cswap_account( + access.is_none(), + access.as_deref().unwrap_or_default(), + id, + ) { + return Err(CswapAccountDenied.into()); + } + Ok(()) + } + /// Whether this IM chat speaks for the box owner. /// /// - platform bound `Named` → only the listed chats. A platform lists @@ -13719,6 +13877,10 @@ impl Gateway { .map(|guard| guard.pending_for_sid(&s.id).is_some()) .unwrap_or(false); SessionView { + claude_account: meta + .as_ref() + .and_then(|meta| meta.claude_account.as_ref()) + .map(|account| account.display()), sid: s.id.clone(), project: s.project.clone(), role: s.role.clone(), @@ -14235,6 +14397,7 @@ impl Gateway { let now = chrono::Utc::now().to_rfc3339(); let owner_tag = canonical_owner(&caller).identity(); let mut meta = SessionMeta { + claude_account: None, model_pinned_generation: None, managed_by: Default::default(), stopped_at: None, @@ -14339,6 +14502,7 @@ impl Gateway { let sid = format!("s{}", guard.next_session); let now = chrono::Utc::now().to_rfc3339(); let mut meta = SessionMeta { + claude_account: None, model_pinned_generation: None, managed_by: Default::default(), stopped_at: None, @@ -15611,6 +15775,24 @@ impl Gateway { tuning, )?; plan.remote = host_target.remote; + if plan.vendor == AgentVendor::Claude && plan.claude_account.is_none() { + if let Some(binding) = parent_sid + .as_ref() + .and_then(|sid| self.session_catalog.find_or_load(sid, &self.projects)) + .and_then(|entry| entry.meta.claude_account) + .filter(|account| !account.default_login) + { + self.authorize_cswap_account(&plan.reply_to, &binding.id)?; + if plan.protocol != SessionProtocol::StreamJson + || plan.host != ccteam_core::LOCAL_HOST + { + anyhow::bail!( + "inherited cswap account requires a local Claude stream-json session" + ); + } + plan.claude_account = Some(binding); + } + } plan.parent_sid = parent_sid.clone(); plan.spawned_by_role = spawned_by_role; plan.delegation_depth = child_depth; @@ -15780,6 +15962,24 @@ impl Gateway { tuning, )?; plan.remote = host_target.remote; + if plan.vendor == AgentVendor::Claude && plan.claude_account.is_none() { + if let Some(binding) = parent_sid + .as_ref() + .and_then(|sid| guard.session_catalog.find_or_load(sid, &guard.projects)) + .and_then(|entry| entry.meta.claude_account) + .filter(|account| !account.default_login) + { + guard.authorize_cswap_account(&plan.reply_to, &binding.id)?; + if plan.protocol != SessionProtocol::StreamJson + || plan.host != ccteam_core::LOCAL_HOST + { + anyhow::bail!( + "inherited cswap account requires a local Claude stream-json session" + ); + } + plan.claude_account = Some(binding); + } + } plan.parent_sid = parent_sid.clone(); plan.spawned_by_role = spawned_by_role; plan.delegation_depth = child_depth; @@ -18875,6 +19075,7 @@ impl Gateway { self.persist_session_meta(&dir, &meta)?; let target = SessionTitleTarget { + claude_account: meta.claude_account.clone(), sid: sid.to_string(), vendor_uuid: meta.vendor_uuid.clone(), project_dir: dir, @@ -18959,6 +19160,9 @@ fn detached_body_view( ) -> SessionView { let m = |f: &dyn Fn(&SessionMeta) -> String| meta.map(f).unwrap_or_default(); SessionView { + claude_account: meta + .and_then(|meta| meta.claude_account.as_ref()) + .map(|account| account.display()), sid: d.sid.clone(), project: d.slug.clone(), role: m(&|m| m.role.clone()), @@ -19137,6 +19341,10 @@ fn released_session_view( current: &std::collections::HashSet, ) -> SessionView { SessionView { + claude_account: meta + .claude_account + .as_ref() + .map(|account| account.display()), sid: meta.sid.clone(), project: slug.to_string(), role: meta.role.clone(), @@ -19180,6 +19388,10 @@ fn external_node_view(meta: &SessionMeta) -> SessionView { .max(0) as u64 }); SessionView { + claude_account: meta + .claude_account + .as_ref() + .map(|account| account.display()), sid: meta.sid.clone(), project: meta.slug.clone(), role: String::new(), @@ -21387,6 +21599,7 @@ fn parse_new_command_args(args: &[&str]) -> Result { "model" | "m" => tuning.model = Some(value.to_string()), "effort" | "e" => tuning.effort = Some(value.to_string()), "mode" => tuning.mode = Some(value.to_string()), + "claude_account" => tuning.claude_account = Some(value.to_string()), other => { return Err(anyhow!( "/new: unknown option `{other}=` (accepts model= / m=, effort= / e=, mode=)\nsyntax: {NEW_COMMAND_SYNTAX}" @@ -21579,6 +21792,7 @@ mod tests { assert_eq!( parsed.tuning, SpawnTuning { + claude_account: None, mode: None, model: Some("kimi-code/k3".to_string()), effort: Some("max".to_string()), @@ -24338,6 +24552,155 @@ mod tests { ); } + #[tokio::test] + async fn cswap_role_switch_never_defaults_after_metadata_loss() { + for corrupt in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + seed_role_with_model(tmp.path(), "reviewer", None); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake.clone(), "alpha", tmp.path()); + let id = ccteam_harness::execution::claude_accounts::account_id("two@example.test", ""); + let created = gateway + .create_session_api_tuned( + "alpha".into(), + String::new(), + AgentVendor::Claude, + PermissionMode::Skip, + SessionProtocol::StreamJson, + "web-api".into(), + SpawnTuning { + claude_account: Some(id.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + let mut binding = + ccteam_harness::execution::claude_accounts::ClaudeAccountBinding::requested(id) + .unwrap(); + binding.config_dir = Some(tmp.path().join("account-profile")); + gateway + .sessions + .get_mut(&created.sid) + .unwrap() + .thread + .raw_extras["claude_account"] = serde_json::to_value(binding).unwrap(); + let meta_path = ccteam_harness::execution::session_meta::session_meta_path( + tmp.path(), + &created.sid, + ); + if corrupt { + std::fs::write(&meta_path, "invalid-json").unwrap(); + } else { + std::fs::remove_file(&meta_path).unwrap(); + } + gateway.session_catalog = Default::default(); + let started = fake.starts.load(Ordering::SeqCst); + let result = gateway + .switch_current_role( + &ChatKey::new("web", "web-api", "web-api"), + "reviewer".into(), + ) + .await; + assert!( + result.is_err(), + "missing/corrupt account metadata must not trigger a default spawn" + ); + assert_eq!(fake.starts.load(Ordering::SeqCst), started); + assert!(gateway.sessions.contains_key(&created.sid)); + } + } + + #[tokio::test] + async fn cswap_selection_is_authorized_before_allocating_a_session() { + let tmp = tempfile::tempdir().unwrap(); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake, "alpha", tmp.path()); + let id = ccteam_harness::execution::claude_accounts::account_id("two@example.test", ""); + let tuning = SpawnTuning { + claude_account: Some(id.clone()), + ..Default::default() + }; + let denied = gateway.plan_new_session( + ChatKey::new("web", "tenant", "tenant"), + "alpha".into(), + AgentVendor::Claude, + String::new(), + String::new(), + PermissionMode::Skip, + SessionProtocol::StreamJson, + tuning.clone(), + ); + assert!(denied.err().unwrap().is::()); + assert_eq!(gateway.next_session, 0); + for (vendor, protocol) in [ + (AgentVendor::Codex, SessionProtocol::StreamJson), + (AgentVendor::Claude, SessionProtocol::Terminal), + ] { + let denied = gateway.plan_new_session( + ChatKey::new("web", "web-api", "web-api"), + "alpha".into(), + vendor, + String::new(), + String::new(), + PermissionMode::Skip, + protocol, + tuning.clone(), + ); + assert!(denied.err().unwrap().is::()); + } + let plan = gateway + .plan_new_session( + ChatKey::new("web", "web-api", "web-api"), + "alpha".into(), + AgentVendor::Claude, + String::new(), + String::new(), + PermissionMode::Skip, + SessionProtocol::StreamJson, + tuning, + ) + .unwrap(); + assert_eq!(plan.claude_account.as_ref().unwrap().id, id); + let mut binding = plan.claude_account.clone().unwrap(); + binding.config_dir = Some(tmp.path().join("private-account")); + binding.slot = "2".into(); + binding.label = "work".into(); + let thread = ThreadHandle { + vendor: AgentVendor::Claude, + mode: ccteam_harness::ExecutionMode::Chat, + identity: "vendor-id".into(), + started_at: chrono::Utc::now(), + raw_extras: serde_json::json!({"vendor_uuid":"vendor-id","claude_account":binding}), + }; + let meta = Gateway::meta_for_new_session(&plan, &thread, None); + assert_eq!( + meta.claude_account.as_ref().unwrap().config_dir, + Some(tmp.path().join("private-account")) + ); + ccteam_harness::execution::session_meta::write_session_meta(tmp.path(), &meta).unwrap(); + assert_eq!( + ccteam_harness::execution::transcript_tail::anthropic_bound_project_dir( + tmp.path(), + meta.claude_account.as_ref() + ), + Some( + ccteam_harness::execution::transcript_tail::anthropic_project_dir_in( + &tmp.path().join("private-account/projects"), + tmp.path() + ) + ) + ); + let public = + serde_json::to_string(&released_session_view(&meta, "alpha", &Default::default())) + .unwrap(); + assert!( + !public.contains("private-account"), + "private profile paths must not escape through session DTOs" + ); + assert!(!public.contains("config_dir")); + } + /// v0.8.24 A-U3 — an explicit `SpawnTuning` (composer model + effort) /// reaches the adapter's `SpawnCtx`, and the explicit model beats the /// role's `model:` frontmatter; without tuning the role default holds @@ -24359,6 +24722,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some("future-model-from-vendor".into()), effort: Some("max".into()), @@ -24392,6 +24756,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some(" ".into()), effort: Some("".into()), @@ -24433,6 +24798,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some("opus".into()), effort: Some("xhigh".into()), @@ -24524,6 +24890,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some("claude-sonnet-5".into()), effort: Some("xhigh".into()), @@ -24589,6 +24956,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: None, effort: None, @@ -24659,6 +25027,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some("opus".into()), effort: Some("xhigh".into()), @@ -24849,6 +25218,7 @@ mod tests { SessionProtocol::StreamJson, "web-api".into(), SpawnTuning { + claude_account: None, mode: None, model: Some("claude-sonnet-5".into()), effort: Some("xhigh".into()), @@ -34165,6 +34535,7 @@ mod tests { // A stopped session s1 belongs to project alpha (meta.json on disk). let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, managed_by: Default::default(), @@ -34244,6 +34615,7 @@ mod tests { // A stopped session s1 (meta.json on disk, never spawned → not live). let meta = SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, managed_by: Default::default(), diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 9af2d6502..378a619b1 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -2114,6 +2114,7 @@ async fn run_session_tool( /// refused rather than silently ignored, because "my model setting did /// nothing" is a far worse bug report than "that parameter is not for this". const AGENT_SPAWN_ONLY_PARAMS: &[&str] = &[ + "claude_account", "vendor", "model", "effort", @@ -2721,6 +2722,12 @@ async fn run_agent_spawn_at( .filter(|s| !s.is_empty()) .map(String::from); let tuning = crate::gateway::SpawnTuning { + claude_account: args + .get("claude_account") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), model, effort, mode, diff --git a/crates/ccteam-im/src/mcp/protocol.rs b/crates/ccteam-im/src/mcp/protocol.rs index a0f8d0fb7..1f0996793 100644 --- a/crates/ccteam-im/src/mcp/protocol.rs +++ b/crates/ccteam-im/src/mcp/protocol.rs @@ -302,9 +302,10 @@ pub fn session_tool_definitions() -> Vec { "description": "Harness for a new session (default claude)." }, "wait": { "type": "integer", "description": "Seconds to block inline, 0-240 (default 0 = async)." }, - "model": { "type": "string", "description": "Model id, passed to the vendor verbatim." }, - "effort": { "type": "string", "description": "Reasoning-effort token, passed verbatim to the vendor." }, - "role": { "type": "string", "description": "Work-role `.claude/agents/.md`; omit for roleless." }, + "model": { "type": "string", "description": "Vendor model id." }, + "effort": { "type": "string", "description": "Vendor effort token." }, + "claude_account": { "type": "string", "description": "Local cswap account id." }, + "role": { "type": "string", "description": "Project role; omit for roleless." }, "project": { "type": "string", "description": "Workspace slug. Required on an enrolled client's first call." }, "title": { "type": "string", "description": "Ledger label (<=80 chars); never sent to the agent." }, "notify": { @@ -934,6 +935,7 @@ mod tests { "wait", "model", "effort", + "claude_account", "role", "project", "title", diff --git a/crates/ccteam-web/src/routes/agents.rs b/crates/ccteam-web/src/routes/agents.rs index a09816f08..81fbbaf3c 100644 --- a/crates/ccteam-web/src/routes/agents.rs +++ b/crates/ccteam-web/src/routes/agents.rs @@ -517,6 +517,7 @@ mod tests { depth: u32, ) { let mut m = ccteam_harness::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, @@ -630,6 +631,7 @@ mod tests { live.insert( "s1".to_string(), SessionView { + claude_account: None, driveable: true, detached: None, context_pct: None, @@ -723,6 +725,7 @@ mod tests { live.insert( "s1".to_string(), SessionView { + claude_account: None, driveable: true, detached: None, context_pct: None, diff --git a/crates/ccteam-web/src/routes/claude_accounts.rs b/crates/ccteam-web/src/routes/claude_accounts.rs new file mode 100644 index 000000000..7ca5a4eef --- /dev/null +++ b/crates/ccteam-web/src/routes/claude_accounts.rs @@ -0,0 +1,53 @@ +//! Optional local cswap discovery. Account use is authorized again at the +//! gateway's common spawn boundary; project visibility is not a spend grant. + +use crate::{auth::Identity, state::AppState}; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Extension, Json, +}; + +#[utoipa::path( + get, path = "/api/v1/vendors/claude/accounts", tag = "sessions", + responses((status = 200, description = "Optional cswap availability and authorized subscription accounts", body = serde_json::Value)) +)] +pub(crate) async fn handle_claude_accounts( + State(app): State, + Extension(identity): Extension, +) -> Response { + let unavailable = |message: String| { + Json(serde_json::json!({"available": false, "accounts": [], "message": message})) + .into_response() + }; + let Some(gateway) = &app.gateway else { + return unavailable("Gateway is unavailable; default login remains available".into()); + }; + let access = match gateway + .lock() + .await + .cswap_account_access(&identity.web_chat_id()) + { + Ok(access) => access, + Err(_) => return unavailable("Account permissions are unavailable".into()), + }; + if access.as_ref().is_some_and(Vec::is_empty) { + return unavailable("No cswap accounts are assigned to this identity".into()); + } + match app.cswap_accounts.accounts().await { + Ok(accounts) => { + let accounts: Vec<_> = accounts + .into_iter() + .filter(|account| { + ccteam_core::identity::can_use_cswap_account( + access.is_none(), + access.as_deref().unwrap_or_default(), + &account.id, + ) + }) + .collect(); + Json(serde_json::json!({"available": true, "accounts": accounts})).into_response() + } + Err(error) => unavailable(error.to_string()), + } +} diff --git a/crates/ccteam-web/src/routes/mod.rs b/crates/ccteam-web/src/routes/mod.rs index 0dfa649d1..ca593a83f 100644 --- a/crates/ccteam-web/src/routes/mod.rs +++ b/crates/ccteam-web/src/routes/mod.rs @@ -57,6 +57,7 @@ pub mod marketplace; // Spawn-tuning discovery (`GET /api/v1/models`): per-vendor observed model // ids + reasoning-effort ladders, so a caller of `POST .../sessions` can see // what `model` / `effort` a vendor takes. Advisory, never a spawn gate. +pub mod claude_accounts; pub mod models; // v0.8.7 W5 (Item E) — OpenAPI auto-docs. Aggregates every `/api/v1` // handler into one `OpenApiRouter` (single source with the route table) diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index 2612bf31b..34fc91771 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -156,6 +156,7 @@ fn build_api_v1() -> OpenApiRouter { // spawn-tuning discovery — the affordance side of `POST .../sessions` // taking `model` + `effort` (no project named, so no project ACL). .routes(routes!(super::models::handle_models)) + .routes(routes!(super::claude_accounts::handle_claude_accounts)) // v0.8.18 柱1 — host-keyed agent report (list / detail / register-mcp) .routes(routes!(super::hosts::handle_hosts)) .routes(routes!(super::hosts::handle_host_detail)) diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index a3656213d..a6f04d841 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -322,6 +322,10 @@ fn apply_progress_activity_status( /// Accepts form or JSON via [`FormOrJson`]. #[derive(Debug, Deserialize, ToSchema)] pub struct CreateSessionForm { + /// Optional stable cswap account id returned by GET /vendors/claude/accounts. + /// Only local Claude stream-json supports account selection. + #[serde(default)] + pub claude_account: Option, pub role: String, #[serde(default)] pub vendor: Option, @@ -384,6 +388,7 @@ fn spawn_tuning_from_form( mode: Option, ) -> ccteam_im::gateway::SpawnTuning { ccteam_im::gateway::SpawnTuning { + claude_account: None, model, effort, mode, @@ -466,12 +471,25 @@ pub(crate) async fn handle_create_session( }; // v0.8.24 A-U3 — explicit model/effort from the composer menu. - let tuning = spawn_tuning_from_form( + let mut tuning = spawn_tuning_from_form( vendor, form.model.clone(), form.effort.clone(), form.mode.clone(), ); + tuning.claude_account = form + .claude_account + .clone() + .filter(|value| !value.trim().is_empty()); + if tuning.claude_account.is_some() + && (vendor != AgentVendor::Claude || protocol != SessionProtocol::StreamJson) + { + return create_error( + StatusCode::UNPROCESSABLE_ENTITY, + "cswap accounts require local Claude stream-json".into(), + mode, + ); + } let created = ccteam_im::gateway::Gateway::create_session_api_tuned_shared( Arc::clone(gw), slug.clone(), @@ -486,6 +504,12 @@ pub(crate) async fn handle_create_session( .await; match created { Ok(created) => (StatusCode::CREATED, Json(json!({"sid": created.sid}))).into_response(), + Err(error) if error.is::() => { + create_error(StatusCode::FORBIDDEN, error.to_string(), mode) + } + Err(error) if error.is::() => { + create_error(StatusCode::UNPROCESSABLE_ENTITY, error.to_string(), mode) + } // v0.8.7 review-fix (R-M6) — distinguish a caller mistake (the named // role has no `.claude/agents/.md`) from a real internal failure // (adapter spawn / fs error). A bad role is a client error → 422 @@ -2955,6 +2979,7 @@ mod tests { fn view(sid: &str) -> SessionView { SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, @@ -3036,6 +3061,7 @@ mod tests { write_project_state(&paths, "demo"); let projection = test_projection(&paths); let mut views = vec![SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, @@ -3090,6 +3116,7 @@ mod tests { let projection = test_projection(&paths); let mut views = vec![ SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, @@ -3116,6 +3143,7 @@ mod tests { delegation_depth: 0, }, SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, @@ -3177,6 +3205,7 @@ mod tests { write_project_state(&paths, "demo"); let projection = test_projection(&paths); let mut views = vec![SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, @@ -3225,6 +3254,7 @@ mod tests { write_project_state(&paths, "demo"); let projection = test_projection(&paths); let mut views = vec![SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, diff --git a/crates/ccteam-web/src/routes/status.rs b/crates/ccteam-web/src/routes/status.rs index 05c963fca..ffa269ea6 100644 --- a/crates/ccteam-web/src/routes/status.rs +++ b/crates/ccteam-web/src/routes/status.rs @@ -659,6 +659,7 @@ mod tests { fn view(sid: &str, project: &str, vendor: &str) -> ccteam_im::gateway::SessionView { ccteam_im::gateway::SessionView { + claude_account: None, driveable: true, residency: "resident".to_string(), detached: None, diff --git a/crates/ccteam-web/src/state.rs b/crates/ccteam-web/src/state.rs index f2c3a608b..37b4113bb 100644 --- a/crates/ccteam-web/src/state.rs +++ b/crates/ccteam-web/src/state.rs @@ -129,6 +129,7 @@ pub struct AppState { pub vendor_installs: Arc, /// VENDOR-QUOTA-1 — per-vendor quota probe service (HTTP + 5min cache). pub vendor_quotas: Arc, + pub cswap_accounts: Arc, /// Coalesces concurrent daemon-wide status aggregations. The completed /// result is never retained after its in-flight computation finishes. pub(crate) status_singleflight: crate::routes::status::StatusSingleflight, @@ -208,6 +209,9 @@ impl AppState { crate::routes::vendor_install::VendorInstallManager::default(), ), vendor_quotas: Arc::new(crate::routes::vendor_quota::VendorQuotaService::default()), + cswap_accounts: Arc::new( + ccteam_harness::execution::claude_accounts::CswapAccountService::default(), + ), status_singleflight: crate::routes::status::StatusSingleflight::default(), file_previews: Arc::new(crate::routes::files::PreviewRegistry::default()), } diff --git a/crates/ccteam-web/tests/agents_test.rs b/crates/ccteam-web/tests/agents_test.rs index 0bd4dd5a0..8f44c0a8e 100644 --- a/crates/ccteam-web/tests/agents_test.rs +++ b/crates/ccteam-web/tests/agents_test.rs @@ -232,6 +232,7 @@ fn idle_meta( observed_model: Option<&str>, ) { let m = ccteam_harness::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-web/tests/claude_accounts_test.rs b/crates/ccteam-web/tests/claude_accounts_test.rs new file mode 100644 index 000000000..ca9312438 --- /dev/null +++ b/crates/ccteam-web/tests/claude_accounts_test.rs @@ -0,0 +1,114 @@ +//! Discovery never turns project access into permission to spend every local +//! subscription. This integration process owns its temporary HOME and store. +#![cfg(unix)] + +use ccteam_core::{config::CcteamConfig, tenants::TenantRegistry, CcteamPaths}; +use ccteam_harness::{execution::claude_accounts::account_id, ClaudeStreamJsonAdapter}; +use ccteam_im::gateway::Gateway; +use ccteam_web::{router_with_state, AppState, AuthState}; +use std::{os::unix::fs::PermissionsExt, sync::Arc}; + +#[tokio::test] +async fn account_discovery_filters_grants_before_spawning_the_optional_tool() { + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("HOME", tmp.path().join("home")); + std::env::set_var("CCTEAM_HOME", tmp.path().join("state")); + let paths = CcteamPaths { + root: tmp.path().join("state"), + projects_root: tmp.path().join("projects"), + }; + std::fs::create_dir_all(paths.users_dir()).unwrap(); + let mut tenants = TenantRegistry::default(); + let alice = tenants.add("alice"); + let bob = tenants.add("bob"); + tenants.save(&paths.users_dir()).unwrap(); + let first = account_id("one@example.test", ""); + let second = account_id("two@example.test", ""); + let mut config = CcteamConfig::default(); + config + .cswap_account_grants + .insert(format!("user:{}", alice.id), vec![first.clone()]); + ccteam_core::config::save(&paths.root, &config).unwrap(); + let calls = tmp.path().join("calls"); + let cswap = tmp.path().join("cswap"); + std::fs::write(&cswap, format!("#!/bin/sh\necho x >> '{}'\nif [ \"$1\" = run ]; then echo '--no-share-history --require-session'; exit 0; fi\nprintf '%s\\n' '{{\"schemaVersion\":1,\"accounts\":[{{\"number\":1,\"email\":\"one@example.test\"}},{{\"number\":2,\"email\":\"two@example.test\"}}]}}'\n", calls.display())).unwrap(); + std::fs::set_permissions(&cswap, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::env::set_var("CCTEAM_CSWAP_BIN", &cswap); + let mut gateway = Gateway::new( + Arc::new(ClaudeStreamJsonAdapter::new()), + "project", + tmp.path(), + ); + gateway.enable_project_creation(paths.clone()); + let admin = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let mut state = AppState::with_auth(paths.clone(), AuthState::enabled(admin.into())); + state.gateway = Some(Arc::new(tokio::sync::Mutex::new(gateway))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router_with_state(state)) + .await + .unwrap(); + }); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let url = format!("http://{address}/api/v1/vendors/claude/accounts"); + let denied: serde_json::Value = client + .get(&url) + .bearer_auth(format!("ccteam:{}", bob.web_token)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(denied["available"], false); + assert_eq!(denied["accounts"], serde_json::json!([])); + assert!( + !calls.exists(), + "an ungranted tenant must not even invoke account discovery" + ); + let allowed: serde_json::Value = client + .get(&url) + .bearer_auth(format!("ccteam:{}", alice.web_token)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(allowed["accounts"].as_array().unwrap().len(), 1); + assert_eq!(allowed["accounts"][0]["id"], first); + assert!(!allowed.to_string().contains(&second)); + assert!(!allowed.to_string().contains("one@")); + let operator: serde_json::Value = client + .get(&url) + .bearer_auth(format!("ccteam:{admin}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(operator["accounts"].as_array().unwrap().len(), 2); + assert_eq!( + std::fs::read_to_string(&calls).unwrap().lines().count(), + 2, + "discovery is coalesced and cached" + ); + config.cswap_account_grants.clear(); + ccteam_core::config::save(&paths.root, &config).unwrap(); + let revoked: serde_json::Value = client + .get(&url) + .bearer_auth(format!("ccteam:{}", alice.web_token)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + revoked["accounts"], + serde_json::json!([]), + "cache must not retain a revoked grant" + ); +} diff --git a/crates/ccteam-web/tests/status_test.rs b/crates/ccteam-web/tests/status_test.rs index 02d5987f7..c6b9b690a 100644 --- a/crates/ccteam-web/tests/status_test.rs +++ b/crates/ccteam-web/tests/status_test.rs @@ -79,6 +79,7 @@ fn write_one_tracked_session(root: &std::path::Path) { ccteam_harness::write_session_meta( &project_dir, &ccteam_harness::SessionMeta { + claude_account: None, model_pinned_generation: None, mode: None, tool_face: None, diff --git a/crates/ccteam-web/web/src/components/ChatComposer.tsx b/crates/ccteam-web/web/src/components/ChatComposer.tsx index 61f4dbbf9..4bad4138e 100644 --- a/crates/ccteam-web/web/src/components/ChatComposer.tsx +++ b/crates/ccteam-web/web/src/components/ChatComposer.tsx @@ -337,6 +337,7 @@ export function ChatComposer({ allowedVendors, catalog: catalogOverride, topSlot, + advancedSlot, sendTestId = "composer-send", uploadSlug, prefill, @@ -380,6 +381,8 @@ export function ChatComposer({ catalog?: VendorCatalog; /** Home's inline new-project row renders inside the composer card. */ topSlot?: React.ReactNode; + /** Optional new-session controls; conversation composers never change accounts. */ + advancedSlot?: React.ReactNode; sendTestId?: string; /** Project slug uploads + the skill picker target. Omit (e.g. while the * Home new-project row is open) to disable attaching with a hint. */ @@ -1062,6 +1065,7 @@ export function ChatComposer({ ✓ ))} + {advancedSlot} } + + ); +} diff --git a/crates/ccteam-web/web/src/index.css b/crates/ccteam-web/web/src/index.css index 9cd48e114..a23ba4db0 100644 --- a/crates/ccteam-web/web/src/index.css +++ b/crates/ccteam-web/web/src/index.css @@ -1157,6 +1157,11 @@ html.dark .cockpit-markdown a { color: #67e8f9; } } .cockpit-markdown pre.has-copy { padding-top: 2.2rem; } .cockpit-markdown .code-copy:hover { color: var(--text); } +.cswap-options { padding: 0.5rem 0.75rem; } +.cswap-options summary { cursor: pointer; color: var(--text-muted); } +.cswap-options label { display: grid; gap: 0.35rem; margin: 0.6rem 0; } +.cswap-options select { width: 100%; max-width: 20rem; padding: 0.4rem; background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: 0.35rem; } +.cswap-options small { display: block; max-width: 20rem; color: var(--text-muted); white-space: normal; } .cockpit-markdown hr { border: none; border-top: 1px solid var(--border-soft); margin: 0.75rem 0; } .cockpit-markdown .cockpit-table-wrap { margin: 0.75rem 0; diff --git a/crates/ccteam-web/web/src/lib/sessionsApi.ts b/crates/ccteam-web/web/src/lib/sessionsApi.ts index c94b449aa..fe685fa70 100644 --- a/crates/ccteam-web/web/src/lib/sessionsApi.ts +++ b/crates/ccteam-web/web/src/lib/sessionsApi.ts @@ -23,6 +23,7 @@ import { backgroundHeaders } from "./backgroundRequest"; * `crates/ccteam-im/src/gateway.rs::SessionView`). `sid` is the gateway * `s{n}` id; `permission_mode` is `"skip"` | `"hitl"` (W2). */ export interface SessionView { + claude_account?: CswapAccount | null; sid: string; project: string; role: string; @@ -467,6 +468,7 @@ export function resolveApproval( * server-side when omitted; pass `"hitl"` to opt the new session into W2 * IM-approval prompts for non-allowlist tool calls. */ export interface CreateSessionOpts { + claude_account?: string; role: string; vendor?: string; permission_mode?: "skip" | "hitl"; @@ -504,9 +506,25 @@ export function createSession( if (opts.model) body.model = opts.model; if (opts.effort) body.effort = opts.effort; if (opts.mode) body.mode = opts.mode; + if (opts.claude_account) body.claude_account = opts.claude_account; return postJson(sessionsUrl(slug), body); } +export interface CswapAccount { + id: string; + slot: string; + label: string; + /** Discovery: currently the default login. Session: default fallback mode. */ + active: boolean; +} +export interface CswapAccounts { + available: boolean; + accounts: CswapAccount[]; + message?: string; +} +export const getCswapAccounts = () => + getJson("/api/v1/vendors/claude/accounts"); + // ── v0.8.21 history / resume / external-import ─────────────────────────────── /** A stopped ccteam session from `GET .../sessions/history`. */ diff --git a/crates/ccteam-web/web/src/pages/HomeView.tsx b/crates/ccteam-web/web/src/pages/HomeView.tsx index 9682edd07..1fb738a55 100644 --- a/crates/ccteam-web/web/src/pages/HomeView.tsx +++ b/crates/ccteam-web/web/src/pages/HomeView.tsx @@ -22,6 +22,7 @@ import { useEffect, useRef, useState } from "react"; import { Folder, GitBranch, Globe } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { ChatComposer } from "../components/ChatComposer"; +import { CswapAccountPicker } from "../components/CswapAccountPicker"; import { useVendorCatalog } from "../hooks/useVendorCatalog"; import type { TurnAttachment } from "../lib/attachmentsApi"; import { VendorChip } from "../components/VendorChip"; @@ -211,6 +212,7 @@ export default function HomeView({ const [hostDetails, setHostDetails] = useState>({}); const [host, setHost] = useState("local"); const [draft, setDraft] = useState(() => loadModelDraft()); + const [claudeAccount, setClaudeAccount] = useState(""); // What each installed vendor actually declares (models + effort tokens); // `{}` until it loads / on an older daemon → the static registry answers. const catalog = useVendorCatalog(); @@ -383,6 +385,8 @@ export default function HomeView({ protocol: wireProtocol(effectiveDraft), model: modelSwitchFor(effectiveDraft, catalog) ?? undefined, effort: effortSwitchFor(effectiveDraft, catalog) ?? undefined, + claude_account: effectiveDraft.vendor === "claude" && effectiveHost === "local" && wireProtocol(effectiveDraft) === "stream-json" + ? claudeAccount || undefined : undefined, }); await submitTurn(sid, text, attachments); return sid; @@ -571,6 +575,8 @@ export default function HomeView({ disabled={pending} draft={effectiveDraft} onDraftChange={setDraft} + advancedSlot={effectiveDraft.vendor === "claude" && effectiveHost === "local" && wireProtocol(effectiveDraft) === "stream-json" + ? : undefined} catalog={catalog} allowedVendors={hostVendors ?? undefined} onSend={launch} diff --git a/crates/ccteam-web/web/src/pages/SessionView.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index 5afdebb2a..c31993a51 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -863,6 +863,12 @@ export default function SessionView({ ) : null} {session?.role ? {session.role} : null} + {session?.claude_account ? + {session.claude_account.active ? (lang === "en" ? "Default login" : "默认登录") : `cswap ${session.claude_account.slot}`} + : null} {displayStatusModel ?? vendor} diff --git a/crates/ccteam-web/web/tests/v032-spa.spec.ts b/crates/ccteam-web/web/tests/v032-spa.spec.ts index 242b206ec..99f32ed6d 100644 --- a/crates/ccteam-web/web/tests/v032-spa.spec.ts +++ b/crates/ccteam-web/web/tests/v032-spa.spec.ts @@ -491,6 +491,41 @@ test("conversation posts a turn; sidebar hover-stop stops the session", async ({ ); }); +for (const installed of [false, true]) { + test(`optional cswap account picker: installed=${installed}`, async ({ page }) => { + const captured = await mockCcteamApi(page); + let discoveries = 0; + const account = "a".repeat(64); + await page.route("**/api/v1/vendors/claude/accounts", route => { + discoveries++; + return route.fulfill({ json: { available: installed, accounts: installed + ? [{ id: account, slot: "2", label: "work (t***@example.test)", active: false }] : [] } }); + }); + await page.goto("/app/"); + await expect(page.getByTestId("home-view")).toBeVisible(); + expect(discoveries).toBe(0); + await page.getByTestId("model-btn").click(); + await page.getByText("高级选项", { exact: true }).click(); + const picker = page.getByRole("combobox", { name: "Claude 账号" }); + await expect(picker).toBeVisible(); + await expect.poll(() => discoveries).toBe(1); + if (installed) { + await picker.selectOption(account); + } else { + await expect(page.getByText(/cswap 不可用/)).toBeVisible(); + await expect(picker).toHaveValue(""); + } + await page.getByTestId("model-btn").click(); + await page.getByTestId("composer-textarea").fill("Use the selected login"); + await page.getByTestId("home-send").click(); + await expect.poll(() => captured.some(row => row.url.endsWith("/projects/dev-team/sessions"))).toBe(true); + const create = captured.find(row => row.url.endsWith("/projects/dev-team/sessions")); + if (installed) expect(create?.body).toHaveProperty("claude_account", account); + else expect(create?.body).not.toHaveProperty("claude_account"); + await expect(page).toHaveURL(/\/app\/chat\/s\/s2$/); + }); +} + test("home lazy-create: first message POSTs session without a host override, then the turn", async ({ page, }) => { diff --git a/docs/orchestration-cn.md b/docs/orchestration-cn.md index 5cfa2206a..e1495e039 100644 --- a/docs/orchestration-cn.md +++ b/docs/orchestration-cn.md @@ -52,6 +52,8 @@ steer 有一处代价值得知道:在 Claude 上,这行纠正会以 turn 中预 经验法则:**长活 → 后台 + 完成通知**(合上笔记本没关系);**快问 → 内联等答案**。这些话在你日常会话里张口就说——什么都不用装(见 §8)。 +本机 Claude 子会话默认继承父会话的隔离 cswap 账号;默认登录父会话的子会话仍用默认登录。`agent` 新建时可用 `claude_account` 指定已授权的稳定账号 id;已有 sid 的跟进消息不能换账号。账号授权与项目权限同时校验,不能借委派扩大消费权限;远程、非 Claude 与 terminal 不支持账号选择。安装与默认号回退行为见 [使用手册](usage-cn.md)。 + ## 4. 让委派值回票价(最佳实践,人话) 这几条是把「能用」变成「好用」的关键。每条就一句话,揉进你的原话里说: diff --git a/docs/orchestration.md b/docs/orchestration.md index 095ef157b..ddf3fcb2b 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -52,6 +52,8 @@ One trade-off worth knowing about the steer: on Claude the steering line is show Rule of thumb: **long work → background + completion notification** (close the laptop); **quick questions → wait inline**. Wire your session once (§8), then these phrases work as-is in your everyday session. +Local Claude children inherit their parent's isolated cswap profile by default; children of a default-login session keep default login. A new `agent` call may pass an authorized stable `claude_account` id. Follow-ups to an existing sid cannot change accounts, and delegation grants no additional account permissions. Remote projects, other vendors and terminal sessions do not support this choice. See the [usage guide](usage.md) for installation and default-account fallback behavior. + ## 4. Making delegation pay (best practices, in plain language) These turn "it works" into "it's good". They're one sentence each — fold them into how you phrase the ask: diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 3b202658f..2df845979 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -166,6 +166,10 @@ DSH 菜单通过当前 Web 入口的 `/dsh/` 同源代理加载 HTML、资源、 ### 开会话、切换、对话 +本机 Claude stream-json 会话可在模型菜单的「高级选项 → Claude 账号」选择已安装 cswap 管理的账号。cswap 是可选依赖;没有安装时,默认登录、新建和恢复会话及其他 vendor 都照常使用。账号登录、凭据与刷新仍由 cswap / Claude 管理,ccteam 不复制 token。选中的账号恰好是当前默认号时,接受 cswap 的默认登录回退,头部会标记「默认登录」,该会话可继续跟随外部 `cswap auto`;其他账号使用独立 profile,恢复时保持原身份和目录,不能静默退回默认号。仅首选新会话可以回退,已隔离的会话遇到账号移除、slot 换绑或目录变化会报错并保留历史。 + +账号列表由 `GET /api/v1/vendors/claude/accounts` 返回,创建请求的 `claude_account` 使用其中的稳定 id。管理员/已点名的 operator 可使用执行端本机账号;其他身份需要在 `config.yaml` 的 `cswap_account_grants` 中按归属标签(例如 `user:<用户 id>`)显式列出允许使用的账号 id。项目访问权不包含订阅消费权。远程项目、其他 vendor 与 terminal 协议不支持这个选项。ccteam 不代装 cswap,也不会自动换号。 + - **新建会话**:选 vendor(Claude / Codex / Grok / OpenCode / Kimi / DSH / Pi)与协议(stream-json / terminal 仅 Claude 管理员 / ACP=Grok·OpenCode·Kimi·DSH / Pi 自己的 RPC)、模型与思考强度、spawn 前 HITL 开关。两个菜单都按**所选 vendor 自己最近一次握手自报的目录**渲染(`GET /api/v1/models`)——列的是它自己的模型 id 和它自己的档位;没有强度轴的 vendor 干脆不显示强度菜单,留在**默认**则什么都不发、由 vendor 自己定。**执行主机 = 项目绑定的主机**(会话跟项目走,不再按会话选);每行会话带厂商标记。角色列表来自项目 `.claude/agents/`,spawn 时可选,留空即 roleless;Grok / OpenCode / Kimi / DSH 当前只支持 roleless。建好回句柄 `s`。 - **每个会话**有 **Chat | 终端** 两个标签页。Chat 里助手消息按 Markdown 渲染(标题/列表/表格/代码块,代码块一键复制);输入框 **Enter 发送、Shift+Enter 换行**,发送中可一键停止。 - **独立会话页**:`/app/chat/s/`(`` 与各入口的 `s1`/`s2` 同一命名空间)是某个会话的干净视图 —— 自己的历史、按会话过滤的实时事件,不与别的会话混流。 diff --git a/docs/usage.md b/docs/usage.md index 45778e415..43616057b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -169,6 +169,10 @@ Managing one afterwards happens where the project lives: hover its workspace hea ### Start, Switch, and Drive Sessions +For a local Claude stream-json session, open **Advanced options → Claude account** in the model menu to select an account managed by a preinstalled cswap. cswap is optional: default-login creation and resume, daemon startup, and other vendors work without it. cswap and Claude retain credential and refresh ownership; ccteam never copies tokens. When the selected account is already the default login, cswap's native fallback is accepted and the header shows **Default login**; that session can follow external `cswap auto` switches. Other accounts use isolated profiles and retain their identity and directory across resumes. An existing isolated session fails explicitly if its account, slot assignment, or profile changes, preserving its history. + +`GET /api/v1/vendors/claude/accounts` returns authorized account references; pass the stable id as `claude_account` when creating a session. Operators can use local accounts; other identities need explicit account ids under their canonical owner tag (for example `user:`) in `config.yaml`'s `cswap_account_grants`. Project visibility alone does not grant subscription use. Remote projects, other vendors, and the terminal protocol do not support account selection. ccteam neither installs cswap nor runs an automatic account-switching loop. + - **New session:** choose a vendor (Claude / Codex / Grok / OpenCode / Kimi / DSH / Pi) and protocol (stream-json or terminal for Claude, ACP for Grok, OpenCode, Kimi and DSH, Pi's own RPC for Pi), the model and reasoning effort, and HITL at spawn time. Both menus are built per vendor from what that vendor declared at its last handshake (`GET /api/v1/models`), so you pick from its own ids and its own levels — a vendor that has no effort axis shows no effort menu, and leaving either on **default** sends nothing and lets the vendor decide. The execution host is the project's — sessions run wherever their project is bound, and every session row wears a vendor chip. Roles come from the project's `.claude/agents/` — pick one at spawn time or launch roleless; Grok, OpenCode, Kimi, and DSH are roleless-only today. The session gets a handle like `s1`. - **Each session** has **Chat | Terminal** tabs. Chat renders assistant output as Markdown, including headings, lists, tables, and code blocks with copy buttons. Press **Enter** to send, **Shift+Enter** for a newline, and stop an in-flight turn from the UI. - **Dedicated session page:** `/app/chat/s/` is a clean view for one session. It has that session's history and session-filtered live events, without mixing other sessions. From 1c0a2f2eee04967376820c22ab6c773bcac47f85 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 4 Nov 2026 11:11:57 +0000 Subject: [PATCH 2/2] fix(claude): protect SDK input and keep cswap discovery inference-free (#229) --- .../ccteam-cli/tests/cswap_accounts_test.rs | 4 ++ .../src/execution/claude_accounts.rs | 70 +++++++++++++++++-- .../ccteam-web/tests/claude_accounts_test.rs | 4 +- crates/ccteam-web/tests/openapi_test.rs | 2 + docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/ccteam-cli/tests/cswap_accounts_test.rs b/crates/ccteam-cli/tests/cswap_accounts_test.rs index 9d8de138d..694e01c13 100644 --- a/crates/ccteam-cli/tests/cswap_accounts_test.rs +++ b/crates/ccteam-cli/tests/cswap_accounts_test.rs @@ -16,6 +16,7 @@ from pathlib import Path profile = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(Path.home() / '.claude'))) email = os.environ.get('CSWAP_FAKE_DEFAULT_EMAIL', 'two@example.test') if 'CLAUDE_CONFIG_DIR' not in os.environ else 'two@example.test' if sys.argv[1:3] == ['auth', 'status']: + assert sys.stdin.read(1) == '', 'auth probe consumed stream-json input' print(json.dumps({'loggedIn':True,'authMethod':'claude.ai','email':email,'orgId':''})) sys.exit(0) args = sys.argv[1:] @@ -45,6 +46,9 @@ if sys.argv[1:]==['list','--json']: if sys.argv[1:]==['run','--help']: print('--no-share-history --require-session') sys.exit(0) +if sys.argv[1:]==['config','list','--json']: + print(json.dumps({'schemaVersion':1,'settings':[]})) + sys.exit(0) assert sys.argv[1:3]==['run','2'] assert '--no-share-history' in sys.argv active=os.environ.get('CSWAP_FAKE_ACTIVE')=='1' diff --git a/crates/ccteam-harness/src/execution/claude_accounts.rs b/crates/ccteam-harness/src/execution/claude_accounts.rs index b25048c5e..d08c577cd 100644 --- a/crates/ccteam-harness/src/execution/claude_accounts.rs +++ b/crates/ccteam-harness/src/execution/claude_accounts.rs @@ -142,9 +142,33 @@ pub async fn discover_accounts() -> Result> { if !help.contains("--no-share-history") || !help.contains("--require-session") { bail!("this cswap version lacks account-isolation options; upgrade cswap or use default login"); } + // Newer cswap versions optionally infer setup-token quota using a tiny + // model request. Discovery must not create inference outside a session. + verify_cswap_settings( + &cswap_output(&["config", "list", "--json"], Duration::from_secs(5)).await?, + )?; parse_accounts(&cswap_output(&["list", "--json"], Duration::from_secs(20)).await?) } +fn verify_cswap_settings(bytes: &[u8]) -> Result<()> { + let value: serde_json::Value = + serde_json::from_slice(bytes).context("cswap settings discovery returned invalid JSON")?; + if value["schemaVersion"].as_u64() != Some(1) { + bail!("unsupported cswap settings schema"); + } + let settings = value["settings"] + .as_array() + .context("cswap settings discovery has no settings")?; + for setting in settings { + if setting["key"].as_str() == Some("usage.headerProbe") + && setting["value"].as_bool() != Some(false) + { + bail!("disable cswap usage.headerProbe before account discovery: ccteam does not run quota inference probes"); + } + } + Ok(()) +} + async fn cswap_output(args: &[&str], timeout: Duration) -> Result> { let mut command = tokio::process::Command::new(cswap_bin()); command @@ -342,6 +366,9 @@ pub fn run_relay(args: &[OsString]) -> Result<()> { .env_remove(ORIGINAL_PATH_ENV); let is_stream_json = args.iter().any(|arg| arg == "--input-format"); if !is_stream_json { + // The SDK initialize request can already be queued in inherited + // stdin while cswap probes auth. Probes must never consume it. + command.stdin(std::process::Stdio::null()); return exec(command, args); } let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR") @@ -392,7 +419,9 @@ pub fn run_relay(args: &[OsString]) -> Result<()> { "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", ] { - if std::env::var_os(key).is_some_and(|v| !v.is_empty() && v != "0" && v != "false") { + if std::env::var_os(key) + .is_some_and(|value| override_is_active(key, &value.to_string_lossy())) + { bail!("selected cswap subscription conflicts with {key}; remove the provider override first"); } } @@ -482,12 +511,11 @@ fn verify_subscription_settings(config_dir: &Path, cwd: &Path) -> Result<()> { "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", ] { - if env.get(key).is_some_and(|value| { - !value.is_null() - && value.as_str() != Some("") - && value.as_str() != Some("0") - && value.as_str() != Some("false") - && value != false + if env.get(key).is_some_and(|value| match value { + serde_json::Value::Null => false, + serde_json::Value::String(value) => override_is_active(key, value), + serde_json::Value::Bool(false) => !is_cloud_switch(key), + _ => true, }) { bail!("selected subscription conflicts with {key} in Claude settings"); } @@ -497,6 +525,19 @@ fn verify_subscription_settings(config_dir: &Path, cwd: &Path) -> Result<()> { Ok(()) } +fn is_cloud_switch(key: &str) -> bool { + matches!( + key, + "CLAUDE_CODE_USE_BEDROCK" | "CLAUDE_CODE_USE_VERTEX" | "CLAUDE_CODE_USE_FOUNDRY" + ) +} + +fn override_is_active(key: &str, value: &str) -> bool { + // "0" disables a boolean cloud switch, but is a real stdin descriptor + // for FD authentication; an arbitrary URL/key is never a boolean flag. + !value.is_empty() && (!is_cloud_switch(key) || !matches!(value, "0" | "false")) +} + fn bounded_auth_probe(mut command: std::process::Command) -> Result { use std::io::Read; command @@ -588,6 +629,8 @@ mod tests { r#"{"apiKeyHelper":"return-a-key"}"#, r#"{"env":{"ANTHROPIC_API_KEY":"must-not-win"}}"#, r#"{"env":{"CLAUDE_CODE_USE_VERTEX":"1"}}"#, + r#"{"env":{"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR":"0"}}"#, + r#"{"env":{"ANTHROPIC_BASE_URL":"false"}}"#, ] { std::fs::write(profile.join("settings.json"), body).unwrap(); assert!(verify_subscription_settings(&profile, &project).is_err()); @@ -599,4 +642,17 @@ mod tests { .unwrap(); assert!(verify_subscription_settings(&profile, &project).is_ok()); } + + #[test] + fn discovery_never_enables_vendor_quota_inference() { + assert!(verify_cswap_settings(br#"{"schemaVersion":1,"settings":[]}"#).is_ok()); + assert!(verify_cswap_settings( + br#"{"schemaVersion":1,"settings":[{"key":"usage.headerProbe","value":false}]}"# + ) + .is_ok()); + assert!(verify_cswap_settings( + br#"{"schemaVersion":1,"settings":[{"key":"usage.headerProbe","value":true}]}"# + ) + .is_err()); + } } diff --git a/crates/ccteam-web/tests/claude_accounts_test.rs b/crates/ccteam-web/tests/claude_accounts_test.rs index ca9312438..9463efbae 100644 --- a/crates/ccteam-web/tests/claude_accounts_test.rs +++ b/crates/ccteam-web/tests/claude_accounts_test.rs @@ -31,7 +31,7 @@ async fn account_discovery_filters_grants_before_spawning_the_optional_tool() { ccteam_core::config::save(&paths.root, &config).unwrap(); let calls = tmp.path().join("calls"); let cswap = tmp.path().join("cswap"); - std::fs::write(&cswap, format!("#!/bin/sh\necho x >> '{}'\nif [ \"$1\" = run ]; then echo '--no-share-history --require-session'; exit 0; fi\nprintf '%s\\n' '{{\"schemaVersion\":1,\"accounts\":[{{\"number\":1,\"email\":\"one@example.test\"}},{{\"number\":2,\"email\":\"two@example.test\"}}]}}'\n", calls.display())).unwrap(); + std::fs::write(&cswap, format!("#!/bin/sh\necho x >> '{}'\nif [ \"$1\" = run ]; then echo '--no-share-history --require-session'; exit 0; fi\nif [ \"$1\" = config ]; then echo '{{\"schemaVersion\":1,\"settings\":[]}}'; exit 0; fi\nprintf '%s\\n' '{{\"schemaVersion\":1,\"accounts\":[{{\"number\":1,\"email\":\"one@example.test\"}},{{\"number\":2,\"email\":\"two@example.test\"}}]}}'\n", calls.display())).unwrap(); std::fs::set_permissions(&cswap, std::fs::Permissions::from_mode(0o700)).unwrap(); std::env::set_var("CCTEAM_CSWAP_BIN", &cswap); let mut gateway = Gateway::new( @@ -92,7 +92,7 @@ async fn account_discovery_filters_grants_before_spawning_the_optional_tool() { assert_eq!(operator["accounts"].as_array().unwrap().len(), 2); assert_eq!( std::fs::read_to_string(&calls).unwrap().lines().count(), - 2, + 3, "discovery is coalesced and cached" ); config.cswap_account_grants.clear(); diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 86884bb0f..f145b461c 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -71,6 +71,8 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { ), // VENDOR-QUOTA-1 — vendor subscription-quota snapshot (admin). ("GET", "/api/v1/vendors/quota"), + // #229 — optional, identity-filtered cswap discovery. + ("GET", "/api/v1/vendors/claude/accounts"), ("POST", "/api/v1/hosts/join-token"), ("GET", "/api/v1/hosts/join-token"), ("POST", "/api/v1/hosts/join"), diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 2df845979..8502109e6 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -168,7 +168,7 @@ DSH 菜单通过当前 Web 入口的 `/dsh/` 同源代理加载 HTML、资源、 本机 Claude stream-json 会话可在模型菜单的「高级选项 → Claude 账号」选择已安装 cswap 管理的账号。cswap 是可选依赖;没有安装时,默认登录、新建和恢复会话及其他 vendor 都照常使用。账号登录、凭据与刷新仍由 cswap / Claude 管理,ccteam 不复制 token。选中的账号恰好是当前默认号时,接受 cswap 的默认登录回退,头部会标记「默认登录」,该会话可继续跟随外部 `cswap auto`;其他账号使用独立 profile,恢复时保持原身份和目录,不能静默退回默认号。仅首选新会话可以回退,已隔离的会话遇到账号移除、slot 换绑或目录变化会报错并保留历史。 -账号列表由 `GET /api/v1/vendors/claude/accounts` 返回,创建请求的 `claude_account` 使用其中的稳定 id。管理员/已点名的 operator 可使用执行端本机账号;其他身份需要在 `config.yaml` 的 `cswap_account_grants` 中按归属标签(例如 `user:<用户 id>`)显式列出允许使用的账号 id。项目访问权不包含订阅消费权。远程项目、其他 vendor 与 terminal 协议不支持这个选项。ccteam 不代装 cswap,也不会自动换号。 +账号列表由 `GET /api/v1/vendors/claude/accounts` 返回,创建请求的 `claude_account` 使用其中的稳定 id。管理员/已点名的 operator 可使用执行端本机账号;其他身份需要在 `config.yaml` 的 `cswap_account_grants` 中按归属标签(例如 `user:<用户 id>`)显式列出允许使用的账号 id。项目访问权不包含订阅消费权。远程项目、其他 vendor 与 terminal 协议不支持这个选项。ccteam 不代装 cswap,也不会自动换号。若 cswap 提供 `usage.headerProbe`,必须保持关闭,避免账号发现触发额度推理探针。 - **新建会话**:选 vendor(Claude / Codex / Grok / OpenCode / Kimi / DSH / Pi)与协议(stream-json / terminal 仅 Claude 管理员 / ACP=Grok·OpenCode·Kimi·DSH / Pi 自己的 RPC)、模型与思考强度、spawn 前 HITL 开关。两个菜单都按**所选 vendor 自己最近一次握手自报的目录**渲染(`GET /api/v1/models`)——列的是它自己的模型 id 和它自己的档位;没有强度轴的 vendor 干脆不显示强度菜单,留在**默认**则什么都不发、由 vendor 自己定。**执行主机 = 项目绑定的主机**(会话跟项目走,不再按会话选);每行会话带厂商标记。角色列表来自项目 `.claude/agents/`,spawn 时可选,留空即 roleless;Grok / OpenCode / Kimi / DSH 当前只支持 roleless。建好回句柄 `s`。 - **每个会话**有 **Chat | 终端** 两个标签页。Chat 里助手消息按 Markdown 渲染(标题/列表/表格/代码块,代码块一键复制);输入框 **Enter 发送、Shift+Enter 换行**,发送中可一键停止。 diff --git a/docs/usage.md b/docs/usage.md index 43616057b..fddcad078 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -171,7 +171,7 @@ Managing one afterwards happens where the project lives: hover its workspace hea For a local Claude stream-json session, open **Advanced options → Claude account** in the model menu to select an account managed by a preinstalled cswap. cswap is optional: default-login creation and resume, daemon startup, and other vendors work without it. cswap and Claude retain credential and refresh ownership; ccteam never copies tokens. When the selected account is already the default login, cswap's native fallback is accepted and the header shows **Default login**; that session can follow external `cswap auto` switches. Other accounts use isolated profiles and retain their identity and directory across resumes. An existing isolated session fails explicitly if its account, slot assignment, or profile changes, preserving its history. -`GET /api/v1/vendors/claude/accounts` returns authorized account references; pass the stable id as `claude_account` when creating a session. Operators can use local accounts; other identities need explicit account ids under their canonical owner tag (for example `user:`) in `config.yaml`'s `cswap_account_grants`. Project visibility alone does not grant subscription use. Remote projects, other vendors, and the terminal protocol do not support account selection. ccteam neither installs cswap nor runs an automatic account-switching loop. +`GET /api/v1/vendors/claude/accounts` returns authorized account references; pass the stable id as `claude_account` when creating a session. Operators can use local accounts; other identities need explicit account ids under their canonical owner tag (for example `user:`) in `config.yaml`'s `cswap_account_grants`. Project visibility alone does not grant subscription use. Remote projects, other vendors, and the terminal protocol do not support account selection. ccteam neither installs cswap nor runs an automatic account-switching loop. Keep cswap `usage.headerProbe` disabled when that option exists; account discovery must not invoke a model quota probe. - **New session:** choose a vendor (Claude / Codex / Grok / OpenCode / Kimi / DSH / Pi) and protocol (stream-json or terminal for Claude, ACP for Grok, OpenCode, Kimi and DSH, Pi's own RPC for Pi), the model and reasoning effort, and HITL at spawn time. Both menus are built per vendor from what that vendor declared at its last handshake (`GET /api/v1/models`), so you pick from its own ids and its own levels — a vendor that has no effort axis shows no effort menu, and leaving either on **default** sends nothing and lets the vendor decide. The execution host is the project's — sessions run wherever their project is bound, and every session row wears a vendor chip. Roles come from the project's `.claude/agents/` — pick one at spawn time or launch roleless; Grok, OpenCode, Kimi, and DSH are roleless-only today. The session gets a handle like `s1`. - **Each session** has **Chat | Terminal** tabs. Chat renders assistant output as Markdown, including headings, lists, tables, and code blocks with copy buttons. Press **Enter** to send, **Shift+Enter** for a newline, and stop an in-flight turn from the UI.