Skip to content

Commit fb41ae3

Browse files
committed
handle sandbox sessions better
1 parent 32e4dfa commit fb41ae3

4 files changed

Lines changed: 140 additions & 61 deletions

File tree

src/main.rs

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,34 @@ unsafe extern "C" {
8989
fn atexit(callback: extern "C" fn()) -> i32;
9090
}
9191

92-
/// Runs once at process exit. If the CLI was authenticating through a
93-
/// sandbox session (env var from `sandbox run`, or on-disk session
94-
/// from `sandbox set <id>`), emit a footer line on stderr so the user
95-
/// can tell at a glance which sandbox served the call. Stderr keeps
96-
/// stdout clean for callers that parse JSON/YAML output.
92+
/// Runs once at process exit. Prints a sandbox footer on stderr when
93+
/// the CLI is running under an on-disk sandbox session (i.e. the user
94+
/// ran `hotdata sandbox set <id>` to enter it from this shell). Stays
95+
/// silent when the sandbox comes from `HOTDATA_SANDBOX_TOKEN` in the
96+
/// environment: that means we're inside a `sandbox run` child, and
97+
/// the parent already announced the sandbox once at spawn time.
98+
/// Stderr keeps stdout clean for callers parsing JSON/YAML output.
9799
extern "C" fn print_sandbox_footer() {
98100
use crossterm::style::Stylize;
99101

100-
let sandbox_id = sandbox_session::sandbox_token_in_use()
101-
.and_then(|(_, sid)| sid)
102-
.or_else(|| {
103-
sandbox_session::load()
104-
.map(|s| s.sandbox_id)
105-
.filter(|s| !s.is_empty())
106-
});
107-
if let Some(sid) = sandbox_id {
108-
eprintln!("{}", format!("sandbox: {sid}").dark_grey());
102+
// Inside a `sandbox run` child — parent printed the banner already.
103+
if sandbox_session::sandbox_token_in_use().is_some() {
104+
return;
109105
}
106+
let Some(session) = sandbox_session::load() else {
107+
return;
108+
};
109+
if session.sandbox_id.is_empty() {
110+
return;
111+
}
112+
eprintln!(
113+
"{}",
114+
format!(
115+
"current sandbox: {} use 'hotdata sandbox set' to change",
116+
session.sandbox_id
117+
)
118+
.dark_grey(),
119+
);
110120
}
111121

112122
fn main() {

src/sandbox.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -261,22 +261,23 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd
261261
let api = ApiClient::new(Some(workspace_id));
262262

263263
// Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT
264-
// by hitting the auth endpoint. The same call creates a sandbox
265-
// when no id is provided. Either way we end up with a fresh
266-
// bundle persisted to sandbox_session.json before we spawn.
267-
let resp: SandboxTokenResponse = match sandbox_id {
268-
Some(id) => {
269-
let path = format!("/auth/sandbox/{id}");
270-
api.post(&path, &serde_json::json!({}))
271-
}
264+
// by dispatching on grant_type at /auth/sandbox. Either way we
265+
// end up with a fresh bundle persisted to sandbox_session.json
266+
// before we spawn.
267+
let body = match sandbox_id {
268+
Some(id) => serde_json::json!({
269+
"grant_type": "existing_sandbox",
270+
"sandbox_id": id,
271+
}),
272272
None => {
273-
let mut body = serde_json::json!({});
273+
let mut b = serde_json::json!({});
274274
if let Some(n) = name {
275-
body["name"] = serde_json::json!(n);
275+
b["name"] = serde_json::json!(n);
276276
}
277-
api.post("/auth/sandbox", &body)
277+
b
278278
}
279279
};
280+
let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body);
280281

281282
let sid = resp.sandbox_id.clone();
282283
let sandbox_jwt = resp.token.clone();
@@ -360,12 +361,16 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) {
360361
check_sandbox_lock();
361362
match sandbox_id {
362363
Some(id) => {
363-
// Mint a sandbox-scoped JWT against this existing id. The
364-
// call doubles as an existence + access check (404/403 if
365-
// the user can't reach it).
364+
// Mint a sandbox-scoped JWT against this existing id via
365+
// the grant_type=existing_sandbox dispatch. The call
366+
// doubles as an existence + access check (404/403 if the
367+
// user can't reach it).
366368
let api = ApiClient::new(Some(workspace_id));
367-
let path = format!("/auth/sandbox/{id}");
368-
let resp: SandboxTokenResponse = api.post(&path, &serde_json::json!({}));
369+
let body = serde_json::json!({
370+
"grant_type": "existing_sandbox",
371+
"sandbox_id": id,
372+
});
373+
let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body);
369374
persist_sandbox_session(resp, workspace_id);
370375

371376
if let Err(e) = config::save_sandbox("default", id) {

src/sandbox_session.rs

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
//!
33
//! Distinct from the user-scoped session in [`crate::jwt`]:
44
//!
5-
//! * Minted by `/v1/auth/sandbox` (or `/v1/auth/sandbox/<id>`), not
6-
//! `/o/token/`.
5+
//! * Minted by `POST /v1/auth/sandbox` (with no body, or
6+
//! `grant_type=existing_sandbox` + `sandbox_id`), not `/o/token/`.
77
//! * Bound to a single sandbox + workspace; the JWT carries only
88
//! workspace-read + sandbox-read/write scope.
9-
//! * Refreshed via `/v1/auth/sandbox/refresh`, which rotates the
10-
//! refresh token (single-use). The user's own credentials are never
11-
//! involved — possession of the sandbox refresh token is enough.
9+
//! * Refreshed via `POST /v1/auth/sandbox` with
10+
//! `grant_type=refresh_token` — same endpoint as the new-mint path,
11+
//! dispatched by body field (mirrors `POST /o/token/`). The server
12+
//! does **not** rotate the refresh token. The user's own credentials
13+
//! are never involved — possession of the sandbox refresh token is
14+
//! enough.
1215
//!
1316
//! Stored at `~/.hotdata/sandbox_session.json` (mode 0600).
1417
@@ -91,14 +94,22 @@ fn redact(s: &str) -> String {
9194
util::mask_credential(s)
9295
}
9396

94-
/// Trade a refresh token for a fresh sandbox JWT (and a new refresh
95-
/// token). The server rotates: the old refresh token is dead after a
96-
/// successful call, so the new value must be persisted before the
97-
/// caller can recover from a crash.
97+
/// Trade a refresh token for a fresh sandbox JWT. The server does
98+
/// **not** rotate the refresh token (matches DOT's
99+
/// ``ROTATE_REFRESH_TOKEN=False``), so the same value is returned on
100+
/// every call. Same endpoint as the new-mint path —
101+
/// ``POST /v1/auth/sandbox`` with ``grant_type=refresh_token`` in the
102+
/// body, mirroring ``POST /o/token/``.
98103
pub fn refresh(api_url: &str, refresh_token: &str) -> Result<SandboxSession, String> {
99-
let url = format!("{}/auth/sandbox/refresh", api_url.trim_end_matches('/'));
100-
let body = serde_json::json!({"refresh_token": refresh_token});
101-
let body_log = serde_json::json!({"refresh_token": redact(refresh_token)});
104+
let url = format!("{}/auth/sandbox", api_url.trim_end_matches('/'));
105+
let body = serde_json::json!({
106+
"grant_type": "refresh_token",
107+
"refresh_token": refresh_token,
108+
});
109+
let body_log = serde_json::json!({
110+
"grant_type": "refresh_token",
111+
"refresh_token": redact(refresh_token),
112+
});
102113

103114
let client = reqwest::blocking::Client::new();
104115
let req = client.post(&url).json(&body);
@@ -303,29 +314,36 @@ mod tests {
303314
}
304315

305316
#[test]
306-
fn refresh_success_rotates_tokens() {
317+
fn refresh_posts_grant_type_to_sandbox_endpoint() {
307318
let mut server = mockito::Server::new();
308319
let m = server
309-
.mock("POST", "/auth/sandbox/refresh")
320+
.mock("POST", "/auth/sandbox")
321+
.match_body(mockito::Matcher::AllOf(vec![
322+
mockito::Matcher::JsonString(
323+
r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"#
324+
.to_string(),
325+
),
326+
]))
310327
.with_status(200)
311328
.with_header("content-type", "application/json")
312329
.with_body(
313-
r#"{"ok":true,"token":"new-jwt","refresh_token":"new-refresh","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#,
330+
// Server does not rotate — same refresh_token comes back.
331+
r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#,
314332
)
315333
.create();
316334

317-
let s = refresh(&server.url(), "old-refresh").unwrap();
335+
let s = refresh(&server.url(), "stable-refresh").unwrap();
318336
m.assert();
319337
assert_eq!(s.access_token, "new-jwt");
320-
assert_eq!(s.refresh_token, "new-refresh");
338+
assert_eq!(s.refresh_token, "stable-refresh");
321339
assert_eq!(s.sandbox_id, "s_abc12345");
322340
}
323341

324342
#[test]
325343
fn refresh_http_error() {
326344
let mut server = mockito::Server::new();
327345
let m = server
328-
.mock("POST", "/auth/sandbox/refresh")
346+
.mock("POST", "/auth/sandbox")
329347
.with_status(401)
330348
.create();
331349
let err = refresh(&server.url(), "x").unwrap_err();
@@ -343,19 +361,20 @@ mod tests {
343361

344362
let mut server = mockito::Server::new();
345363
let m = server
346-
.mock("POST", "/auth/sandbox/refresh")
364+
.mock("POST", "/auth/sandbox")
347365
.with_status(200)
348366
.with_header("content-type", "application/json")
349367
.with_body(
350-
r#"{"ok":true,"token":"refreshed","refresh_token":"rotated","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"#,
368+
r#"{"ok":true,"token":"refreshed","refresh_token":"cached-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#,
351369
)
352370
.create();
353371
let tok = ensure_access_token(&server.url());
354372
m.assert();
355373
assert_eq!(tok.as_deref(), Some("refreshed"));
356374
let after = load().unwrap();
357375
assert_eq!(after.access_token, "refreshed");
358-
assert_eq!(after.refresh_token, "rotated");
376+
// No rotation — same refresh_token as before.
377+
assert_eq!(after.refresh_token, "cached-refresh");
359378
assert_eq!(after.workspace_id, "work_xyz");
360379
}
361380
}

src/util.rs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -284,16 +284,34 @@ pub fn format_date(s: &str) -> String {
284284
}
285285

286286
pub fn api_error(body: String) -> String {
287-
serde_json::from_str::<serde_json::Value>(&body)
288-
.ok()
289-
.and_then(|v| v["error"]["message"].as_str().map(str::to_string))
290-
.unwrap_or_else(|| {
291-
if body.trim_start().starts_with('<') {
292-
"unexpected server error".to_string()
293-
} else {
294-
body
295-
}
296-
})
287+
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
288+
// Two shapes in the wild:
289+
// {"error": {"message": "..."}} — RuntimeDB-style
290+
// {"error": "snake_case_code"} — Django-style (e.g. sandbox endpoints)
291+
if let Some(m) = v["error"]["message"].as_str() {
292+
return m.to_string();
293+
}
294+
if let Some(code) = v["error"].as_str() {
295+
return humanize_error_code(code);
296+
}
297+
}
298+
if body.trim_start().starts_with('<') {
299+
return "unexpected server error".to_string();
300+
}
301+
body
302+
}
303+
304+
/// Turn a snake_case error code into a human-friendly sentence:
305+
/// ``sandbox_not_found`` → ``Sandbox not found``. Cheap heuristic — if
306+
/// a code reads badly after this, the server should be the one to fix
307+
/// it by returning a real message.
308+
fn humanize_error_code(code: &str) -> String {
309+
let spaced = code.replace('_', " ");
310+
let mut chars = spaced.chars();
311+
match chars.next() {
312+
Some(c) => c.to_uppercase().chain(chars).collect(),
313+
None => String::new(),
314+
}
297315
}
298316

299317
#[cfg(test)]
@@ -321,6 +339,33 @@ mod tests {
321339
assert_eq!(mask_credential(""), "***");
322340
}
323341

342+
#[test]
343+
fn api_error_humanizes_snake_case_code() {
344+
// Django-style flat shape — `sandbox_not_found` should render
345+
// as a readable sentence, not a raw JSON blob.
346+
let body = r#"{"error": "sandbox_not_found"}"#.to_string();
347+
assert_eq!(api_error(body), "Sandbox not found");
348+
}
349+
350+
#[test]
351+
fn api_error_prefers_nested_message_over_code() {
352+
// RuntimeDB-style nested shape — use the human message verbatim.
353+
let body = r#"{"error": {"message": "Query qrun_x not found"}}"#.to_string();
354+
assert_eq!(api_error(body), "Query qrun_x not found");
355+
}
356+
357+
#[test]
358+
fn api_error_falls_through_for_plain_body() {
359+
let body = "raw text body".to_string();
360+
assert_eq!(api_error(body), "raw text body");
361+
}
362+
363+
#[test]
364+
fn api_error_handles_html_body() {
365+
let body = "<html>500</html>".to_string();
366+
assert_eq!(api_error(body), "unexpected server error");
367+
}
368+
324369
#[test]
325370
fn redact_json_fields_top_level() {
326371
let mut v = json!({

0 commit comments

Comments
 (0)