Skip to content

Commit c118515

Browse files
committed
Hosted Claude Code / Codex sessions can run on the JuCode gateway
options.jucode_gateway runs that one process through the JuCode gateway on the user's JuCode login (Claude: private --settings file; Codex: -c overrides and a key env var), leaving their own config alone. The daemon remembers it per session, so a reopen from another device keeps it.
1 parent 0933497 commit c118515

7 files changed

Lines changed: 146 additions & 6 deletions

File tree

‎crates/agent-core/src/lib.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ pub use hunks::HunkView;
3939
pub use session::SessionSummary;
4040
pub use tools::{git_diff, terminate_tool_processes};
4141

42+
/// The JuCode gateway URL and an access token good for at least two more
43+
/// minutes (refreshed first when needed), for tools spawned to call the
44+
/// gateway on the user's JuCode login.
45+
pub fn jucode_gateway_credentials() -> Result<(String, String), String> {
46+
let config = config::Config::load_or_create().map_err(|error| error.to_string())?;
47+
let auth = oauth::ensure_session(&config.jucode_api_url, config.encrypt_secrets)?;
48+
let token = auth
49+
.jucode_access_token()
50+
.filter(|token| !token.is_empty())
51+
.ok_or("not logged in to JuCode. Run /login.")?
52+
.to_string();
53+
Ok((config.jucode_api_url, token))
54+
}
55+
4256
/// Sessions saved for `cwd`, most recently updated first (`updated_at` in
4357
/// seconds).
4458
pub fn saved_sessions(cwd: &std::path::Path) -> std::io::Result<Vec<SessionSummary>> {

‎crates/daemon/src/engines/claude.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,26 @@ pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
3535
const DEFAULT_EFFORT: &str = "medium";
3636
const SUMMARY_CAP: usize = 4000;
3737

38+
/// This session talks to the JuCode gateway: a settings file (owner-only)
39+
/// that overrides the endpoint and token for this process alone. An empty
40+
/// ANTHROPIC_API_KEY masks one the user's own settings set.
41+
pub fn use_gateway(command: &mut Command, api: &str, token: &str) -> Result<(), String> {
42+
let api = super::gateway_url(api)?;
43+
let settings = json!({ "env": {
44+
"ANTHROPIC_BASE_URL": api,
45+
"ANTHROPIC_AUTH_TOKEN": token,
46+
"ANTHROPIC_API_KEY": "",
47+
} });
48+
let path = home()
49+
.join(".jucode")
50+
.join("daemon")
51+
.join("claude-gateway.json");
52+
crate::store::write_private(&path, settings.to_string().as_bytes())
53+
.map_err(|error| format!("cannot write {}: {error}", path.display()))?;
54+
command.arg("--settings").arg(path);
55+
Ok(())
56+
}
57+
3858
pub fn command(id: &str, options: &Options) -> Command {
3959
let program = resolve(
4060
"claude",

‎crates/daemon/src/engines/codex.rs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ pub fn command() -> Command {
2929
command
3030
}
3131

32+
/// The env var a gateway session reads its key from.
33+
const GATEWAY_KEY_ENV: &str = "JUCODE_GATEWAY_TOKEN";
34+
35+
/// This session talks to the JuCode gateway: config overrides for this
36+
/// process alone, with the key in its environment (never in argv).
37+
pub fn use_gateway(command: &mut Command, api: &str, token: &str) -> Result<(), String> {
38+
let api = super::gateway_url(api)?;
39+
command
40+
.args(["-c", "model_provider=\"jucode_gateway\"", "-c"])
41+
.arg(format!(
42+
"model_providers.jucode_gateway={{name=\"JuCode\",base_url=\"{api}/v1\",env_key=\"{GATEWAY_KEY_ENV}\",wire_api=\"responses\"}}"
43+
))
44+
.env(GATEWAY_KEY_ENV, token);
45+
Ok(())
46+
}
47+
3248
fn text(value: &Value) -> &str {
3349
value.as_str().unwrap_or_default()
3450
}
@@ -1287,6 +1303,34 @@ mod tests {
12871303
c
12881304
}
12891305

1306+
#[test]
1307+
fn the_gateway_goes_to_this_process_only() {
1308+
let mut command = std::process::Command::new("codex");
1309+
use_gateway(&mut command, "https://api.jucode.net/", "tok").unwrap();
1310+
let args: Vec<String> = command
1311+
.get_args()
1312+
.map(|a| a.to_string_lossy().to_string())
1313+
.collect();
1314+
assert_eq!(args[1], "model_provider=\"jucode_gateway\"");
1315+
assert!(args[3].contains("base_url=\"https://api.jucode.net/v1\""));
1316+
assert!(!args.concat().contains("tok\""));
1317+
let env: Vec<_> = command.get_envs().collect();
1318+
assert_eq!(
1319+
env,
1320+
[(
1321+
std::ffi::OsStr::new("JUCODE_GATEWAY_TOKEN"),
1322+
Some(std::ffi::OsStr::new("tok"))
1323+
)]
1324+
);
1325+
assert!(use_gateway(&mut command, "http://api.jucode.net", "tok").is_err());
1326+
assert!(use_gateway(&mut command, "https://a\"b", "tok").is_err());
1327+
assert_eq!(
1328+
Options::from_json(&json!({ "jucode_gateway": true })).gateway,
1329+
Some(true)
1330+
);
1331+
assert_eq!(Options::from_json(&json!({})).gateway, None);
1332+
}
1333+
12901334
#[test]
12911335
fn skills_list_as_commands_and_review_and_skills_run() {
12921336
let mut c = opened();

‎crates/daemon/src/engines/mod.rs‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ pub struct Options {
7070
pub command: Option<String>,
7171
pub args: Vec<String>,
7272
pub env: Vec<(String, String)>,
73+
/// Claude / Codex: talk to the JuCode gateway on the user's JuCode login
74+
/// instead of the provider in their own config (which stays untouched).
75+
/// None: as the session last ran.
76+
pub gateway: Option<bool>,
7377
}
7478

7579
impl Options {
@@ -99,6 +103,7 @@ impl Options {
99103
.flatten()
100104
.filter_map(|(name, value)| Some((name.clone(), value.as_str()?.to_string())))
101105
.collect(),
106+
gateway: value["jucode_gateway"].as_bool(),
102107
}
103108
}
104109

@@ -163,12 +168,30 @@ fn adapter(kind: Kind, cwd: &Path, options: &Options) -> Box<dyn Adapter> {
163168
}
164169
}
165170

166-
fn command(kind: Kind, id: &str, options: &Options) -> Command {
167-
match kind {
171+
fn command(kind: Kind, id: &str, options: &Options) -> Result<Command, String> {
172+
let mut command = match kind {
168173
Kind::Claude => claude::command(id, options),
169174
Kind::Codex => codex::command(),
170175
Kind::Acp => acp::command(options),
176+
};
177+
if options.gateway == Some(true) {
178+
let (api, token) = jucode_agent_core::jucode_gateway_credentials()?;
179+
match kind {
180+
Kind::Claude => claude::use_gateway(&mut command, &api, &token)?,
181+
Kind::Codex => codex::use_gateway(&mut command, &api, &token)?,
182+
Kind::Acp => return Err("an ACP agent has no JuCode gateway mode".to_string()),
183+
}
184+
}
185+
Ok(command)
186+
}
187+
188+
/// The gateway URL as it may go into a spawned tool's config.
189+
fn gateway_url(api: &str) -> Result<&str, String> {
190+
let api = api.trim().trim_end_matches('/');
191+
if !api.starts_with("https://") || api.contains(['"', '\\', '\n']) {
192+
return Err(format!("invalid JuCode API URL: {api}"));
171193
}
194+
Ok(api)
172195
}
173196

174197
/// A running engine process: its stdin writer and merged output.
@@ -402,7 +425,7 @@ pub fn spawn(
402425
transcript: Vec<Value>,
403426
) -> Result<(String, Sender<Value>, u64), String> {
404427
let process = Process::spawn(
405-
command(kind, id.as_deref().unwrap_or_default(), &options),
428+
command(kind, id.as_deref().unwrap_or_default(), &options)?,
406429
&cwd,
407430
)?;
408431
let (ops_tx, ops) = mpsc::channel();
@@ -521,7 +544,9 @@ impl Session<'_> {
521544
next.resume = adapter.conversation().or(next.resume);
522545
process.stop();
523546
let id = self.id.clone().unwrap_or_default();
524-
match Process::spawn(command(self.kind, &id, &next), &self.cwd) {
547+
match command(self.kind, &id, &next)
548+
.and_then(|command| Process::spawn(command, &self.cwd))
549+
{
525550
Ok(started) => {
526551
process = started;
527552
adapter = self::adapter(self.kind, &self.cwd, &next);

‎crates/daemon/src/hub.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ impl Hub {
266266
return Err(format!("not a directory: {}", cwd.display()));
267267
}
268268
let agent = agent.map(str::to_string);
269+
let gateway = options.gateway == Some(true);
269270
let (id, ops, generation) = match engine {
270271
None => session::spawn(Arc::clone(self), cwd.clone(), None, agent.clone())?,
271272
Some(kind) => {
@@ -278,7 +279,13 @@ impl Hub {
278279
}
279280
};
280281
self.store
281-
.record_engine_session(&id, &cwd, agent.as_deref(), engine.map(engines::Kind::name))
282+
.record_engine_session(
283+
&id,
284+
&cwd,
285+
agent.as_deref(),
286+
engine.map(engines::Kind::name),
287+
gateway,
288+
)
282289
.map_err(|error| error.to_string())?;
283290
lock(&self.untitled).insert(id.clone());
284291
self.host(id.clone(), ops, cwd, generation);
@@ -342,11 +349,13 @@ impl Hub {
342349
archived: false,
343350
hidden: false,
344351
engine: engine.map(|kind| kind.name().to_string()),
352+
gateway: false,
345353
}
346354
}
347355
(None, None) => return Err(format!("unknown session {id}")),
348356
};
349357
let kind = engines::Kind::parse(record.engine.as_deref().unwrap_or_default())?;
358+
let mut gateway = false;
350359
let (ops, generation) = match kind {
351360
None => {
352361
let (_, ops, generation) = session::spawn(
@@ -373,10 +382,13 @@ impl Hub {
373382
engines::Kind::Claude => engines::claude::transcript(&record.cwd, id),
374383
engines::Kind::Codex | engines::Kind::Acp => Vec::new(),
375384
};
385+
// A client that does not say (a phone) keeps how it last ran.
376386
let options = engines::Options {
377387
resume: saved.then(|| id.to_string()),
388+
gateway: Some(options.gateway.unwrap_or(record.gateway)),
378389
..options
379390
};
391+
gateway = options.gateway == Some(true);
380392
// A new start keeps the recorded id; a resume opens it.
381393
let fresh = options.resume.is_none().then(|| id.to_string());
382394
let (_, ops, generation) = engines::spawn(
@@ -397,6 +409,7 @@ impl Hub {
397409
&record.cwd,
398410
record.agent.as_deref(),
399411
record.engine.as_deref(),
412+
gateway,
400413
)
401414
.map_err(|error| error.to_string())?;
402415
}

‎crates/daemon/src/store.rs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ pub struct SessionRecord {
4444
pub hidden: bool,
4545
/// The engine running it; None: jucode.
4646
pub engine: Option<String>,
47+
/// Claude / Codex: it last ran through the JuCode gateway.
48+
pub gateway: bool,
4749
}
4850

4951
/// A message for an agent: from the user, another agent or a timer.
@@ -170,7 +172,7 @@ impl Store {
170172
cwd: &std::path::Path,
171173
agent: Option<&str>,
172174
) -> io::Result<()> {
173-
self.record_engine_session(id, cwd, agent, None)
175+
self.record_engine_session(id, cwd, agent, None, false)
174176
}
175177

176178
/// Like `record_session`, for a session run by `engine` (None: jucode).
@@ -180,6 +182,7 @@ impl Store {
180182
cwd: &std::path::Path,
181183
agent: Option<&str>,
182184
engine: Option<&str>,
185+
gateway: bool,
183186
) -> io::Result<()> {
184187
let mut entry = json!({
185188
"kind": "open", "session": id, "cwd": cwd.display().to_string(),
@@ -188,6 +191,9 @@ impl Store {
188191
if let Some(engine) = engine {
189192
entry["engine"] = json!(engine);
190193
}
194+
if gateway {
195+
entry["gateway"] = json!(true);
196+
}
191197
self.append(SESSIONS, entry)
192198
}
193199

@@ -276,9 +282,11 @@ impl Store {
276282
archived: false,
277283
hidden: false,
278284
engine: entry["engine"].as_str().map(str::to_string),
285+
gateway: false,
279286
}
280287
});
281288
record.closed = false;
289+
record.gateway = entry["gateway"] == true;
282290
}
283291
Some("close") => {
284292
if let Some(record) = records.get_mut(id) {
@@ -761,6 +769,21 @@ mod tests {
761769
assert!(!store.sessions()[0].closed);
762770
}
763771

772+
#[test]
773+
fn a_session_remembers_whether_it_last_ran_through_the_gateway() {
774+
let store = store("gateway");
775+
let cwd = std::path::Path::new("/p");
776+
store
777+
.record_engine_session("c", cwd, None, Some("claude"), true)
778+
.unwrap();
779+
assert!(store.sessions()[0].gateway);
780+
store.record_session_closed("c").unwrap();
781+
store
782+
.record_engine_session("c", cwd, None, Some("claude"), false)
783+
.unwrap();
784+
assert!(!store.sessions()[0].gateway);
785+
}
786+
764787
#[test]
765788
fn decided_actions_are_not_open() {
766789
let store = store("actions");

‎docs/daemon-protocol.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ clients need nothing engine-specific. `options`:
200200
| `approval_mode` | `manual`/`read-only` (Claude's `default`), `plan`, `auto`, `auto-edit`, `full-access`/`full-auto` |
201201
| `model` | Model to start with |
202202
| `resume_at` | Claude Code: resume the conversation as it was at this assistant message uuid |
203+
| `jucode_gateway` | Claude Code / Codex: `true` runs this session through the JuCode gateway on the user's JuCode login; `false` on the provider in the user's own Claude Code / Codex config. The endpoint and key go to this process only (Claude: `--settings` file; Codex: `-c` overrides and an env var), never to the user's config files. Omitted on `session_open`: as the session last ran |
203204
| `command`, `args`, `env` | ACP: the agent's command line and extra environment variables (plain names; no `DYLD_*`/`LD_*`) |
204205

205206
`engine: "acp"` runs an Agent Client Protocol agent (`jucode acp`,

0 commit comments

Comments
 (0)