From b14115d196ea2f297e2130265199645f0b7edadd Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Mon, 26 Oct 2026 19:34:31 +0000 Subject: [PATCH 1/4] stack/app/instance exec -t: an interactive command on a pseudo-terminal `isb stack exec -it NAME SERVICE -- CMD` (and app exec, instance exec) run the command on a pseudo-terminal in the replica, wired to the local terminal: raw keys out, output back, SIGWINCH resizes, the command's exit code returned, the terminal restored. It rides the daemon's terminal websocket, which now takes stack=/service= targets and a command (argv=, user=, cwd=, env=), admitted as the exec tool that runs it without a terminal. Works over the daemon's unix socket and over $ISB_URL with $ISB_TOKEN; refused when stdin is not a terminal. Not on MCP. Claude-Session: https://claude.ai/code/session_016AFG7KfrwCTBETjGfyW75K --- Cargo.lock | 1 + Cargo.toml | 1 + SKILL.md | 3 +- crates/isb-core/src/exec.rs | 5 +- crates/isb-daemon/src/daemon/kube/exec.rs | 2 +- crates/isb-daemon/src/daemon/kube/mod.rs | 1 + crates/isb-daemon/src/daemon/terminal.rs | 96 +++-- crates/isb-server/src/server/mcp.rs | 46 ++- crates/isb-server/src/server/openapi.rs | 16 +- crates/isb-server/src/server/ssh.rs | 45 +- crates/isb-server/src/server/terminal.rs | 478 ++++++++++++++++++++-- docs/concepts/stacks.md | 10 +- docs/guides/kubectl.md | 38 +- docs/reference/cli.md | 6 +- docs/reference/http-api.md | 28 +- src/bin/isb.rs | 44 +- src/bin/isb/apps.rs | 5 +- src/bin/isb/kube.rs | 101 ++++- src/bin/isb/stack.rs | 5 +- web/openapi.json | 56 ++- 20 files changed, 815 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa646333..62076a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1345,6 +1345,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "signal-hook 0.4.4", "tempfile", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 107db17d..ac097cf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ serde_json.workspace = true serde_yaml_ng.workspace = true clap.workspace = true rustix.workspace = true +signal-hook.workspace = true time.workspace = true [dev-dependencies] diff --git a/SKILL.md b/SKILL.md index 2a6e030b..0c34df84 100644 --- a/SKILL.md +++ b/SKILL.md @@ -222,7 +222,8 @@ cannot exec or read secrets. `stdin`. No port-forward: `curl` through `app_exec`. CLI: `isb instance ls|get|exec|restart`, `isb app exec|logs|restart|scale|top|events`, `isb stack exec|logs` (`--failed`: the last replica that failed to start), - `isb cp`. + `isb cp`. `exec -it` is for a person at a terminal (refused without one); + agents run commands to completion. - **Data:** `database_create`, `database_get`, `backup_*`, `volume_*`, `job_*`. **Secrets:** `secret_create`, `secret_set` (values base64; a new version rolls the stacks using it), `secret_list` (no values). diff --git a/crates/isb-core/src/exec.rs b/crates/isb-core/src/exec.rs index c0823133..f6b61abd 100644 --- a/crates/isb-core/src/exec.rs +++ b/crates/isb-core/src/exec.rs @@ -874,12 +874,13 @@ fn has_input(fd: impl rustix::fd::AsFd, wait: Duration) -> bool { } /// Puts the local terminal in raw mode for the life of the guard. -struct RawMode { +pub struct RawMode { saved: rustix::termios::Termios, } impl RawMode { - fn enable() -> Option { + /// `None` when stdin is not a terminal (nothing to restore). + pub fn enable() -> Option { let stdin = std::io::stdin(); let saved = rustix::termios::tcgetattr(&stdin).ok()?; let mut raw = saved.clone(); diff --git a/crates/isb-daemon/src/daemon/kube/exec.rs b/crates/isb-daemon/src/daemon/kube/exec.rs index f8b8a107..5983cb9c 100644 --- a/crates/isb-daemon/src/daemon/kube/exec.rs +++ b/crates/isb-daemon/src/daemon/kube/exec.rs @@ -118,7 +118,7 @@ impl Run { /// Which replica of an app a call means: the one at `slot`, the one named /// `name`, else a running one, preferring those in rotation and healthy. -pub(super) fn pick_replica<'a>( +pub(in crate::daemon) fn pick_replica<'a>( app: &str, replicas: &'a [InstanceStatus], slot: Option, diff --git a/crates/isb-daemon/src/daemon/kube/mod.rs b/crates/isb-daemon/src/daemon/kube/mod.rs index 06603802..2a716505 100644 --- a/crates/isb-daemon/src/daemon/kube/mod.rs +++ b/crates/isb-daemon/src/daemon/kube/mod.rs @@ -26,6 +26,7 @@ use crate::stack::controller::ServiceStatus; #[cfg(test)] use self::{exec::*, files::*, look::*}; +pub(super) use exec::pick_replica; pub(super) use look::{SINCE_NOTE, read_lines, since_cutoff, window_logs}; /// Tools that only read. diff --git a/crates/isb-daemon/src/daemon/terminal.rs b/crates/isb-daemon/src/daemon/terminal.rs index 0598f97e..fffdfd46 100644 --- a/crates/isb-daemon/src/daemon/terminal.rs +++ b/crates/isb-daemon/src/daemon/terminal.rs @@ -1,7 +1,8 @@ -//! The web terminal's other end: a login shell in one of an app's replicas, -//! for [`crate::server::terminal`]; or a login shell in any instance of the -//! org the caller may exec into (a sandbox, a replica). Nothing outside the -//! org is reachable this way. +//! The web terminal's other end: a login shell, or a command (`isb ... +//! exec -t`), in one of an app's or a stack service's replicas, for +//! [`crate::server::terminal`]; or in any instance of the org the caller +//! may exec into (a sandbox, a replica). Nothing outside the org is +//! reachable this way. use std::sync::Arc; use std::time::Duration; @@ -24,6 +25,9 @@ struct ExecPty { ctl: ExecController, done: bool, instance: String, + /// What runs, for the message when it cannot: "the shell", or the + /// command's name. + what: String, /// Counts the session (a sandbox's activity: the reaper leaves it /// alone) until it closes. guard: Option, @@ -52,7 +56,8 @@ impl Pty for ExecPty { Err(Err(e)) => { self.done = true; PtyOutput::Failed(format!( - "the shell could not run: {e}. Images built FROM scratch have no /bin/sh" + "{} could not run: {e}. Images built FROM scratch have no /bin/sh", + self.what )) } } @@ -86,70 +91,79 @@ pub(super) fn terminal(d: Arc) -> Terminal { info.status.to_lowercase() ))); } - let mut pty = shell(&oc, name, t)?; + let mut pty = open(&oc, name, t)?; pty.guard = Some(d.sandboxes.session(&org.incus_project(), name)); return Ok(pty); } - let app = d.apps.get(org, &t.app)?; - let stack = crate::stack::qualified(org, &app.spec.stack()?); + // An app is the service of its own stack; a stack names its own. + let (stack, service) = match &t.stack { + Some(s) => (crate::stack::qualified(org, s), t.app.clone()), + None => { + let app = d.apps.get(org, &t.app)?; + ( + crate::stack::qualified(org, &app.spec.stack()?), + t.app.clone(), + ) + } + }; + let what = t.target(); let st = d .ctl .status(&stack) - .map_err(|_| Error::NotFound(format!("{} is not deployed", t.app)))?; + .map_err(|_| Error::NotFound(format!("{what} is not deployed")))?; let svc = st .services .iter() - .find(|s| s.service == t.app) - .ok_or_else(|| Error::NotFound(format!("{} is not deployed", t.app)))?; - let inst = match t.slot { - Some(n) => svc - .instances - .iter() - .find(|i| i.slot == n) - .ok_or_else(|| Error::NotFound(format!("{} has no replica {n}", t.app)))?, - None => svc - .instances - .iter() - .filter(|i| i.status == "Running") - .min_by_key(|i| (!i.in_rotation, i.slot)) - .ok_or_else(|| Error::NotFound(format!("{} has no running replica", t.app)))?, + .find(|s| s.service == service) + .ok_or_else(|| Error::NotFound(format!("{what} is not deployed")))?; + let inst = super::kube::pick_replica(&what, &svc.instances, t.slot, None)?; + let pty = open(&oc, &inst.name, t)?; + let opened = match &t.command { + Some(_) => "exec -t", + None => "terminal", }; - if inst.status != "Running" { - return Err(Error::invalid(format!( - "replica {} is {}, not running", - inst.slot, - inst.status.to_lowercase() - ))); - } - let pty = shell(&oc, &inst.name, t)?; d.ctl.service_event( "info", &stack, - &t.app, - format!("terminal opened on replica {} by {c}", inst.slot), + &service, + format!("{opened} opened on replica {} by {c}", inst.slot), ); Ok(pty) }, ) } -/// A login shell in `instance`, on a pseudo-terminal of the asked size. -fn shell(oc: &crate::client::Client, instance: &str, t: &TermRequest) -> Result> { +/// The terminal's program in `instance`, on a pseudo-terminal of the asked +/// size: its command (as the exec tools would run it), else a login shell. +fn open(oc: &crate::client::Client, instance: &str, t: &TermRequest) -> Result> { let sb = Sandbox::get(oc, instance)?; - let mut opts = ExecOptions::default() - .tty(true) - .stdin(Stdin::Piped) - .env("TERM", "xterm-256color"); + let mut opts = ExecOptions::default().tty(true).stdin(Stdin::Piped); opts.width = Some(t.cols); opts.height = Some(t.rows); + let (argv, what) = match &t.command { + Some(cmd) => { + opts.user = cmd.user.clone(); + opts.cwd = cmd.cwd.clone(); + opts.env = cmd.env.clone(); + (cmd.argv.clone(), format!("{:?}", cmd.argv[0])) + } + None => ( + ["/bin/sh", "-c", SHELL].map(String::from).to_vec(), + "the shell".to_string(), + ), + }; + opts.env + .entry("TERM".into()) + .or_insert_with(|| "xterm-256color".into()); let stream = sb - .exec_stream(["/bin/sh", "-c", SHELL], opts) - .map_err(|e| Error::invalid(format!("cannot start a shell in {instance}: {e}")))?; + .exec_stream(argv, opts) + .map_err(|e| Error::invalid(format!("cannot start {what} in {instance}: {e}")))?; Ok(Box::new(ExecPty { ctl: stream.controller(), stream, done: false, instance: instance.to_string(), + what, guard: None, })) } diff --git a/crates/isb-server/src/server/mcp.rs b/crates/isb-server/src/server/mcp.rs index 7e9208ec..8d88179f 100644 --- a/crates/isb-server/src/server/mcp.rs +++ b/crates/isb-server/src/server/mcp.rs @@ -700,16 +700,13 @@ impl Endpoint { Ok(t) => t, Err(m) => return rest_error(400, "invalid", &m), }; - let args = match &t.instance { - Some(i) => json!({"org": org.as_str(), "name": i}), - None => json!({"org": org.as_str(), "app": t.app}), - }; - let (open, o) = (open.clone(), org.clone()); + let args = t.audit_args(org); + let (open, o, tool) = (open.clone(), org.clone(), t.tool()); self.session( req, caller, org, - Session::Terminal, + (Session::Terminal, tool), args, &key, move |c: &Caller| open(c, &o, &t), @@ -751,22 +748,23 @@ impl Endpoint { req, caller, org, - Session::Ssh, + (Session::Ssh, "sandbox_exec"), args, &key, move |c: &Caller| open(c, &o, &s), ) } - /// Admit a websocket session as `sandbox_exec` in `org`, then upgrade - /// and open it, telling the audit hook when it opens and closes. + /// Admit a websocket session as `tool` (`sandbox_exec`, or the exec + /// tool a command's session stands for) in `org`, then upgrade and open + /// it, telling the audit hook when it opens and closes. #[expect(clippy::too_many_arguments)] fn session( &self, req: &Request, caller: Caller, org: &crate::org::OrgId, - kind: Session, + (kind, tool): (Session, &str), args: Value, key: &str, open: F, @@ -776,7 +774,7 @@ impl Endpoint { { let origin = origin(req, &caller, false); let audit = self.hooks.audit.clone(); - match self.admit("sandbox_exec", json!({}), &caller, Some(org)) { + match self.admit(tool, json!({}), &caller, Some(org)) { Ok(_) => {} Err(Admit::Unknown) => { return rest_error(404, "not_found", kind.not_offered()); @@ -798,6 +796,7 @@ impl Endpoint { let what = args .get("app") .or_else(|| args.get("name")) + .or_else(|| args.get("service")) .and_then(Value::as_str) .unwrap_or("") .to_string(); @@ -806,7 +805,8 @@ impl Endpoint { let r = open(&caller); let Some(a) = audit else { return r }; let mut args = args; - if let (Ok(p), Session::Terminal, Some(_)) = (&r, kind, args.get("app")) { + let replicated = args.get("app").or_else(|| args.get("stack")); + if let (Ok(p), Session::Terminal, Some(_)) = (&r, kind, replicated) { args["replica"] = json!(p.target()); } a(&Audited { @@ -1918,6 +1918,28 @@ mod tests { get(&ep, "/orgs/alpha/api/v1/terminal", "app=web", &ws).status, 404 ); + // A command (exec -t) is admitted as its exec tool, not as a shell. + let cmd = "stack=shop&service=web&argv=sh"; + assert_eq!( + get(&ep, "/orgs/alpha/api/v1/terminal", cmd, &ws).status, + 404, + "this registry has no stack_exec" + ); + let mut r = registry(); + r.register(Tool::new("stack_exec", "Exec", json!({}), |_, _| { + Ok(Value::Null) + })) + .unwrap(); + ep.registry = Arc::new(r); + assert_eq!( + get(&ep, "/orgs/alpha/api/v1/terminal", cmd, &ws).status, + 101 + ); + ep.policy = ToolPolicy::from_lists("", "stack_exec"); + assert_eq!( + get(&ep, "/orgs/alpha/api/v1/terminal", cmd, &ws).status, + 404 + ); } #[test] diff --git a/crates/isb-server/src/server/openapi.rs b/crates/isb-server/src/server/openapi.rs index dea95821..8ad7af13 100644 --- a/crates/isb-server/src/server/openapi.rs +++ b/crates/isb-server/src/server/openapi.rs @@ -129,10 +129,12 @@ pub const SURFACE: &[Surface] = &[ method: "get", path: "/orgs/{org}/api/v1/terminal", summary: "Web terminal (websocket)", - description: "Upgrades to a websocket bridged to a login shell in an app replica (?app, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. Admitted as `sandbox_exec` in the org. docs/reference/http-api.md#the-web-terminal.", + description: "Upgrades to a websocket bridged to a login shell, or to a command (?argv), in an app replica (?app, ?slot), a stack service's replica (?stack, ?service, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. A shell is admitted as `sandbox_exec` in the org; a command as `app_exec`, `stack_exec` or `instance_exec`. docs/reference/http-api.md#the-web-terminal.", answers: "websocket", query: &[ ("app", "string", "An app to open a shell in."), + ("stack", "string", "Or a compose stack (with service)."), + ("service", "string", "The stack's service."), ( "slot", "integer", @@ -145,6 +147,18 @@ pub const SURFACE: &[Surface] = &[ ), ("cols", "integer", "Initial columns."), ("rows", "integer", "Initial rows."), + ( + "argv", + "string", + "Run this command instead of a shell: one argument per argv=, in order, percent-encoded.", + ), + ( + "user", + "string", + "The command's guest user (name, uid or uid:gid).", + ), + ("cwd", "string", "The command's working directory."), + ("env", "string", "KEY=VALUE for the command (repeatable)."), ], signed_in: true, agents: Agents::BrowserOnly( diff --git a/crates/isb-server/src/server/ssh.rs b/crates/isb-server/src/server/ssh.rs index 263360a1..1134af6c 100644 --- a/crates/isb-server/src/server/ssh.rs +++ b/crates/isb-server/src/server/ssh.rs @@ -31,9 +31,9 @@ use std::time::Duration; use serde_json::Value; use tungstenite::{Message, WebSocket}; -use super::http::{Peer, Request}; +use super::http::Request; use super::mcp::Caller; -use super::terminal::{Limits, Pty, plain_name, query_param}; +use super::terminal::{Limits, Pty, decode, encode, plain_name, query_param}; use crate::error::{Error, Result}; /// What the client asked for. @@ -97,45 +97,7 @@ pub fn ssh_request(req: &Request) -> std::result::Result { /// naming this site, as for the terminal. An `Origin` that is there must /// name this site, whatever the transport. pub fn origin_allowed(req: &Request) -> bool { - match req.header("origin") { - Some(_) => super::terminal::origin_allowed(req), - None => matches!(req.peer, Peer::Unix { .. }) || super::terminal::origin_allowed(req), - } -} - -fn encode(s: &str) -> String { - s.bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' => { - (b as char).to_string() - } - _ => format!("%{b:02X}"), - }) - .collect() -} - -fn decode(s: &str) -> Option { - let b = s.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while i < b.len() { - match b[i] { - b'%' => { - let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?; - out.push(u8::from_str_radix(h, 16).ok()?); - i += 3; - } - b'+' => { - out.push(b' '); - i += 1; - } - c => { - out.push(c); - i += 1; - } - } - } - String::from_utf8(out).ok() + super::terminal::origin_allowed(req) } // ---- the client half ---- @@ -470,6 +432,7 @@ pub fn pump( #[cfg(test)] mod tests { use super::*; + use crate::server::http::Peer; fn req(query: &str, peer: Peer, headers: &[(&str, &str)]) -> Request { Request { diff --git a/crates/isb-server/src/server/terminal.rs b/crates/isb-server/src/server/terminal.rs index 0dd8d9e3..d4ebd3dd 100644 --- a/crates/isb-server/src/server/terminal.rs +++ b/crates/isb-server/src/server/terminal.rs @@ -1,11 +1,15 @@ //! A terminal over a websocket: `GET /orgs//api/v1/terminal?app=NAME` -//! (or `?instance=NAME`) upgrades to a websocket bridged to a -//! pseudo-terminal the embedder opens (`isb serve`: a shell in one of the -//! app's replicas, or in an instance of the org). +//! (or `?stack=NAME&service=SERVICE`, or `?instance=NAME`) upgrades to a +//! websocket bridged to a pseudo-terminal the embedder opens (`isb serve`: a +//! shell in one of the app's or service's replicas, or in an instance of the +//! org). With `argv=` (repeated, in order) it runs that command instead of a +//! shell, with `user=`, `cwd=` and `env=KEY=VALUE` (repeated): `isb stack +//! exec -t` and its siblings. //! //! The gate is the REST surface's: the caller authenticates as for any tool //! (session, API token, Access), and is admitted as if calling -//! `sandbox_exec` in the org, so the org rules and `--deny-tools` apply. +//! `sandbox_exec` in the org (a command: the exec tool that runs it without +//! a terminal), so the org rules and `--deny-tools` apply. //! A browser's cookie rides along on a cross-site websocket too, so a //! cookie-authenticated upgrade must come from this site (`Origin` naming //! the request's `Host`). @@ -18,8 +22,9 @@ //! Bounded: at most [`MAX_SESSIONS`] at once, [`IDLE`] without a byte either //! way, [`MAX_AGE`] in all, messages up to [`MAX_MESSAGE`]. +use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use serde_json::json; @@ -37,36 +42,106 @@ pub const MAX_MESSAGE: usize = 64 * 1024; /// terminal's output. const POLL: Duration = Duration::from_millis(15); -/// What the browser asked for. -#[derive(Debug, Clone, PartialEq, Eq)] +/// The most arguments and environment variables a command may have (as +/// the exec tools take). +const MAX_ARGV: usize = 1024; +const MAX_ENV: usize = 128; + +/// What the browser (or `isb ... exec -t`) asked for. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TermRequest { - /// The app, or empty when `instance` names the target. + /// The app; with `stack`, that stack's service; empty when `instance` + /// names the target. pub app: String, + /// The compose stack `app` is a service of. + pub stack: Option, /// A replica's slot; `None` picks one. pub slot: Option, /// An instance of the org (a sandbox, a replica) instead of an app. pub instance: Option, pub cols: u16, pub rows: u16, + /// Run this instead of a login shell. + pub command: Option, +} + +/// A command to run on the terminal, as the exec tools take one. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TermCommand { + pub argv: Vec, + pub user: Option, + pub cwd: Option, + pub env: BTreeMap, } impl TermRequest { - /// The query string that asks for this terminal again (forwarding). + /// The query string that asks for this terminal again (forwarding, and + /// the CLI's `exec -t`). pub fn query(&self) -> String { - let mut q = match &self.instance { - Some(i) => format!("instance={i}"), - None => format!("app={}", self.app), + let mut q = match (&self.instance, &self.stack) { + (Some(i), _) => format!("instance={i}"), + (None, Some(s)) => format!("stack={s}&service={}", encode(&self.app)), + (None, None) => format!("app={}", self.app), }; q.push_str(&format!("&cols={}&rows={}", self.cols, self.rows)); if let Some(n) = self.slot { q.push_str(&format!("&slot={n}")); } + if let Some(c) = &self.command { + for a in &c.argv { + q.push_str(&format!("&argv={}", encode(a))); + } + if let Some(u) = &c.user { + q.push_str(&format!("&user={}", encode(u))); + } + if let Some(w) = &c.cwd { + q.push_str(&format!("&cwd={}", encode(w))); + } + for (k, v) in &c.env { + q.push_str(&format!("&env={}", encode(&format!("{k}={v}")))); + } + } q } - /// What it opens on, for logs: the app or the instance. - pub fn target(&self) -> &str { - self.instance.as_deref().unwrap_or(&self.app) + /// What it opens on, for logs: the app, the stack's service or the + /// instance. + pub fn target(&self) -> String { + match (&self.instance, &self.stack) { + (Some(i), _) => i.clone(), + (None, Some(s)) => format!("{s}/{}", self.app), + (None, None) => self.app.clone(), + } + } + + /// What the audit log keeps of the request: the target, and a + /// command's argv, user and cwd, as an exec tool's call is kept, with + /// its environment's names only. + pub fn audit_args(&self, org: &crate::org::OrgId) -> serde_json::Value { + let mut args = match (&self.instance, &self.stack) { + (Some(i), _) => json!({"org": org.as_str(), "name": i}), + (None, Some(s)) => json!({"org": org.as_str(), "stack": s, "service": self.app}), + (None, None) => json!({"org": org.as_str(), "app": self.app}), + }; + if let Some(c) = &self.command { + args["argv"] = json!(c.argv); + args["user"] = json!(c.user); + args["cwd"] = json!(c.cwd); + args["env"] = json!(c.env.keys().collect::>()); + } + args + } + + /// The tool whose admission a session of this kind takes: a shell is + /// `sandbox_exec`'s; a command, that of the exec tool that would run it + /// without a terminal. + pub fn tool(&self) -> &'static str { + match (&self.command, &self.instance, &self.stack) { + (None, _, _) => "sandbox_exec", + (Some(_), Some(_), _) => "instance_exec", + (Some(_), None, Some(_)) => "stack_exec", + (Some(_), None, None) => "app_exec", + } } } @@ -136,10 +211,112 @@ impl Drop for Slot { } fn param(req: &Request, key: &str) -> Option { - req.query.as_deref()?.split('&').find_map(|kv| { - let (k, v) = kv.split_once('=').unwrap_or((kv, "")); - (k == key).then(|| v.to_string()) - }) + params(req, key).into_iter().next() +} + +/// Every raw value of a repeated query parameter, in order. +fn params(req: &Request, key: &str) -> Vec { + let Some(q) = req.query.as_deref() else { + return Vec::new(); + }; + q.split('&') + .filter_map(|kv| { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + (k == key).then(|| v.to_string()) + }) + .collect() +} + +/// Percent-encode a query value. +pub(crate) fn encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +/// Undo [`encode`] (and a form's `+`); `None` when it is not UTF-8 or a +/// `%` is not followed by two hex digits. +pub(crate) fn decode(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'%' => { + let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(h, 16).ok()?); + i += 3; + } + b'+' => { + out.push(b' '); + i += 1; + } + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).ok() +} + +/// A compose service's name: letters, digits, `.`, `_` and `-`, at most 64. +fn service_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// The command a request carries in `argv=` (with `user=`, `cwd=`, `env=`), +/// if any. +fn term_command(req: &Request) -> std::result::Result, String> { + let all = |k: &str| -> std::result::Result, String> { + params(req, k) + .iter() + .map(|v| decode(v).ok_or_else(|| format!("{k}= is not percent-encoded UTF-8"))) + .collect() + }; + let one = |k: &str| -> std::result::Result, String> { + Ok(all(k)?.into_iter().next().filter(|v| !v.is_empty())) + }; + let argv = all("argv")?; + if argv.is_empty() { + for k in ["user", "cwd", "env"] { + if param(req, k).is_some() { + return Err(format!("{k}= is for a command (argv=)")); + } + } + return Ok(None); + } + if argv[0].is_empty() || argv.len() > MAX_ARGV || argv.iter().any(|a| a.contains('\0')) { + return Err(format!( + "argv=: a command, at most {MAX_ARGV} arguments, none with a NUL" + )); + } + let mut env = BTreeMap::new(); + for e in all("env")? { + match e.split_once('=') { + Some((k, v)) if !k.is_empty() && !k.contains('\0') && !v.contains('\0') => { + env.insert(k.to_string(), v.to_string()); + } + _ => return Err(format!("env= wants KEY=VALUE (it was {e:?})")), + } + } + if env.len() > MAX_ENV { + return Err(format!("env=: at most {MAX_ENV} variables")); + } + Ok(Some(TermCommand { + argv, + user: one("user")?, + cwd: one("cwd")?, + env, + })) } /// A lower-case name of letters, digits and dashes, at most 64 long (an @@ -158,14 +335,35 @@ pub fn query_param(req: &Request, key: &str) -> Option { /// The request's terminal parameters, or what is wrong with them. pub fn term_request(req: &Request) -> std::result::Result { - let (app, instance) = match (param(req, "app"), param(req, "instance")) { - (Some(a), None) if plain_name(&a) => (a, None), - (Some(_), None) => return Err("app= is not an app name".into()), - (None, Some(i)) if plain_name(&i) => (String::new(), Some(i)), - (None, Some(_)) => return Err("instance= is not an instance name".into()), - (Some(_), Some(_)) => return Err("give app= or instance=, not both".into()), - (None, None) => return Err("app= (or instance=) is required".into()), + let service = match param(req, "service") { + Some(s) => Some( + decode(&s) + .filter(|s| service_name(s)) + .ok_or("service= is not a service name")?, + ), + None => None, }; + let (app, stack, instance) = match ( + param(req, "app"), + param(req, "stack"), + param(req, "instance"), + ) { + (Some(a), None, None) if plain_name(&a) => (a, None, None), + (Some(_), None, None) => return Err("app= is not an app name".into()), + (None, Some(s), None) if plain_name(&s) => ( + service.clone().ok_or("stack= needs service=")?, + Some(s), + None, + ), + (None, Some(_), None) => return Err("stack= is not a stack name".into()), + (None, None, Some(i)) if plain_name(&i) => (String::new(), None, Some(i)), + (None, None, Some(_)) => return Err("instance= is not an instance name".into()), + (None, None, None) => return Err("app= (or stack= or instance=) is required".into()), + _ => return Err("give one of app=, stack= or instance=".into()), + }; + if service.is_some() && stack.is_none() { + return Err("service= is for stack=".into()); + } let num = |k: &str, lo: u32, hi: u32, def: u32| -> std::result::Result { match param(req, k) { None => Ok(def), @@ -178,15 +376,17 @@ pub fn term_request(req: &Request) -> std::result::Result { }; let slot = match param(req, "slot") { None => None, - Some(_) if instance.is_some() => return Err("slot= is for apps".into()), + Some(_) if instance.is_some() => return Err("slot= is for apps and stacks".into()), Some(_) => Some(num("slot", 1, 1000, 1)?), }; Ok(TermRequest { app, + stack, slot, instance, cols: num("cols", 2, 1000, 80)? as u16, rows: num("rows", 2, 1000, 24)? as u16, + command: term_command(req)?, }) } @@ -199,11 +399,13 @@ fn origin_authority(origin: &str) -> Option<&str> { } /// A cookie-authenticated upgrade must come from a page on this site. A -/// bearer token is not sent by browsers on their own, so it needs no Origin. +/// bearer token is not sent by browsers on their own, nor is the unix +/// socket reached by one (`isb ... exec -t` on the daemon's host), so +/// neither needs an Origin; an Origin that is there must name this site. pub fn origin_allowed(req: &Request) -> bool { let bearer = req.header("authorization").is_some(); match req.header("origin") { - None => bearer, + None => bearer || matches!(req.peer, super::http::Peer::Unix { .. }), Some(o) => match (origin_authority(o), req.header("host")) { (Some(a), Some(h)) => a.eq_ignore_ascii_case(h.trim()), _ => false, @@ -404,6 +606,89 @@ pub fn bridge( } } +/// The client's end of a terminal (`isb ... exec -t`): what `input` gives +/// goes out as terminal bytes and what comes back goes to `output`; the +/// window size, `size()`, is sent whenever `resized` is set (on SIGWINCH); +/// `stop` (a signal to this process) ends the session, which kills the +/// program. Returns the program's exit code, `None` when it ended without +/// one. +pub fn attach( + ws: &mut WebSocket>, + input: impl std::io::Read + Send + 'static, + mut output: impl std::io::Write, + size: impl Fn() -> Option<(u16, u16)>, + resized: &AtomicBool, + stop: &AtomicBool, +) -> crate::Result> { + use crate::error::Error; + let (tx, rx) = std::sync::mpsc::sync_channel::>(64); + // A thread of its own: a read of a terminal blocks. It is left behind, + // blocked, when the session ends, and goes with the process. + std::thread::spawn(move || { + let mut input = input; + let mut buf = vec![0u8; 32 * 1024]; + while let Ok(n) = input.read(&mut buf) { + if n == 0 || tx.send(buf[..n].to_vec()).is_err() { + return; + } + } + }); + let sent = |ws: &mut WebSocket>, m: Message| match ws.send(m) { + Err(e) if !would_block(&e) => Err(Error::WebSocket(e.to_string())), + _ => Ok(()), + }; + ws.get_ref().set_timeout(Some(POLL))?; + loop { + if stop.load(Ordering::SeqCst) { + let _ = ws.close(None); + let _ = ws.flush(); + return Ok(None); + } + if resized.swap(false, Ordering::SeqCst) { + if let Some((cols, rows)) = size() { + sent( + ws, + control(json!({"type": "resize", "cols": cols, "rows": rows})), + )?; + } + } + match ws.read() { + Ok(Message::Binary(b)) => { + output.write_all(&b)?; + output.flush()?; + } + Ok(Message::Text(t)) => { + let v: serde_json::Value = serde_json::from_str(t.as_str()).unwrap_or_default(); + match v["type"].as_str() { + Some("exit") => return Ok(v["code"].as_i64().map(|c| c as i32)), + Some("error") => { + return Err(Error::Remote { + code: "terminal".into(), + message: v["message"].as_str().unwrap_or("refused").to_string(), + data: serde_json::Value::Null, + }); + } + _ => {} + } + } + Ok(Message::Close(_)) => return Ok(None), + Ok(_) => {} + Err(e) if would_block(&e) => {} + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Ok(None); + } + Err(e) => return Err(Error::WebSocket(e.to_string())), + } + while let Ok(d) = rx.try_recv() { + sent(ws, Message::binary(d))?; + } + match ws.flush() { + Err(e) if !would_block(&e) => return Err(Error::WebSocket(e.to_string())), + _ => {} + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -422,7 +707,7 @@ mod tests { .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), body: vec![], - peer: Peer::Unix { uid: None }, + peer: Peer::Tcp("127.0.0.1:5000".parse().unwrap()), } } @@ -434,9 +719,9 @@ mod tests { TermRequest { app: "web".into(), slot: Some(2), - instance: None, cols: 120, - rows: 40 + rows: 40, + ..Default::default() } ); let t = term_request(&req("instance=box-1", &[])).unwrap(); @@ -456,11 +741,70 @@ mod tests { "instance=Box", "instance=a&app=b", "instance=a&slot=1", + "stack=shop", + "stack=Shop&service=web", + "stack=shop&service=a%2Fb", + "app=web&service=db", + "app=web&stack=shop&service=web", + "app=web&user=root", + "app=web&argv=", + "app=web&argv=sh&env=NOEQUALS", + "app=web&argv=%zz", + "app=web&argv=a%00b", ] { assert!(term_request(&req(bad, &[])).is_err(), "{bad}"); } } + #[test] + fn a_stack_service_and_a_command_go_through_the_query() { + let t = TermRequest { + app: "my_svc.1".into(), + stack: Some("shop".into()), + slot: Some(2), + cols: 100, + rows: 30, + command: Some(TermCommand { + argv: vec![ + "stalwart-migrate".into(), + "prepare".into(), + "--to=a b&c".into(), + "ünï".into(), + ], + user: Some("1000:1000".into()), + cwd: Some("/var/lib/x y".into()), + env: [("TERM".into(), "xterm".into()), ("A".into(), "1=2".into())].into(), + }), + ..Default::default() + }; + let back = term_request(&req(&t.query(), &[])).unwrap(); + assert_eq!(back, t); + assert_eq!(t.target(), "shop/my_svc.1"); + assert_eq!(t.tool(), "stack_exec"); + // A shell is sandbox_exec's, whatever it opens in; a command, its + // exec tool's. + let shell = TermRequest { + command: None, + ..t.clone() + }; + assert_eq!(shell.tool(), "sandbox_exec"); + let app = TermRequest { + stack: None, + app: "web".into(), + ..t.clone() + }; + assert_eq!(app.tool(), "app_exec"); + let inst = TermRequest { + instance: Some("box-1".into()), + app: String::new(), + stack: None, + slot: None, + ..t + }; + assert_eq!(inst.tool(), "instance_exec"); + assert_eq!(term_request(&req(&inst.query(), &[])).unwrap(), inst); + } + #[test] fn origin_must_be_this_site_for_cookies() { let host = ("Host", "isb.example.com"); @@ -493,6 +837,14 @@ mod tests { "", &[host, ("Authorization", "Bearer isb_tok_x")] ))); + // The unix socket: no browser reaches it, so no Origin is needed; + // one that is there must still name this site. + let mut unix = req("", &[host]); + unix.peer = Peer::Unix { uid: None }; + assert!(origin_allowed(&unix)); + unix.headers + .push(("Origin".into(), "https://evil.example".into())); + assert!(!origin_allowed(&unix)); } #[test] @@ -604,6 +956,68 @@ mod tests { assert!(*closed.lock().unwrap()); } + /// `exec -t`'s end against the server's: keys out, output back, a + /// resize on SIGWINCH, and the program's exit code. + #[test] + fn the_client_end_relays_keys_resizes_and_the_exit_code() { + let (server, client) = UnixStream::pair().unwrap(); + let (pty, resized, closed) = echo(); + let t = std::thread::spawn(move || { + let mut s = server; + let d: &mut dyn Duplex = &mut s; + let mut ws = WebSocket::from_raw_socket(d, Role::Server, None); + bridge(&mut ws, pty, IDLE, MAX_AGE); + }); + let mut ws = WebSocket::from_raw_socket(Box::new(client), Role::Client, None); + // The keys: "ls", then "exit" a moment later (one frame each). + let (mut keys, input) = UnixStream::pair().unwrap(); + std::thread::spawn(move || { + use std::io::Write; + keys.write_all(b"ls").unwrap(); + std::thread::sleep(Duration::from_millis(300)); + keys.write_all(b"exit").unwrap(); + std::thread::sleep(Duration::from_secs(5)); + }); + let winch = AtomicBool::new(true); + let stop = AtomicBool::new(false); + let mut out = Vec::new(); + let code = attach(&mut ws, input, &mut out, || Some((132, 43)), &winch, &stop).unwrap(); + assert_eq!(code, Some(3)); + assert_eq!(out, b"LS"); + t.join().unwrap(); + assert_eq!(*resized.lock().unwrap(), Some((132, 43))); + assert!(*closed.lock().unwrap()); + } + + #[test] + fn a_stopped_client_closes_the_session_and_a_refusal_is_an_error() { + let (server, client) = UnixStream::pair().unwrap(); + let (pty, _, closed) = echo(); + let t = std::thread::spawn(move || { + let mut s = server; + let d: &mut dyn Duplex = &mut s; + let mut ws = WebSocket::from_raw_socket(d, Role::Server, None); + bridge(&mut ws, pty, IDLE, MAX_AGE); + }); + let mut ws = WebSocket::from_raw_socket(Box::new(client), Role::Client, None); + let (_keys, input) = UnixStream::pair().unwrap(); + let (no, yes) = (AtomicBool::new(false), AtomicBool::new(true)); + let code = attach(&mut ws, input, Vec::new(), || None, &no, &yes).unwrap(); + assert_eq!(code, None); + t.join().unwrap(); + assert!(*closed.lock().unwrap(), "the program goes with the session"); + + let (server, client) = UnixStream::pair().unwrap(); + std::thread::spawn(move || { + let mut ws = WebSocket::from_raw_socket(server, Role::Server, None); + refuse(&mut ws, "stack_exec is not allowed"); + }); + let mut ws = WebSocket::from_raw_socket(Box::new(client), Role::Client, None); + let (_keys, input) = UnixStream::pair().unwrap(); + let e = attach(&mut ws, input, Vec::new(), || None, &no, &no).unwrap_err(); + assert!(e.to_string().contains("stack_exec is not allowed"), "{e}"); + } + #[test] fn idle_sessions_are_closed() { let (server, mut client) = pair(); diff --git a/docs/concepts/stacks.md b/docs/concepts/stacks.md index a24b3d02..fbb54111 100644 --- a/docs/concepts/stacks.md +++ b/docs/concepts/stacks.md @@ -333,9 +333,11 @@ outlives a restart of the daemon. `isb stack exec STACK SERVICE -- CMD...` runs a command in one of the service's replicas, as `isb app exec` does for an app: a running replica, healthy and in rotation first, or the one `--replica N` names. It prints the -output and exits with the command's status; it is not interactive (`-i` feeds -stdin), and it works over the socket and, against a remote daemon, over -`$ISB_URL`. +output and exits with the command's status (`-i` feeds it stdin), and it +works over the socket and, against a remote daemon, over `$ISB_URL`. With +`-it` it runs interactively, on a terminal, as `docker exec -it` does: `isb +stack exec -it shop web -- sh` (see [Interactive, on a +terminal](../guides/kubectl.md#interactive-on-a-terminal)). Members and up. ## Editing in the web UI @@ -434,7 +436,7 @@ isb stack deploy [NAME] [-f FILE...] [-d] [--timeout 10m] deploy or update; wa isb stack ls [--json] isb stack ps NAME [--json] isb stack logs NAME SERVICE [--slot N] [-n|--tail 100] [--since 10m|TIME] [--failed] -isb stack exec NAME SERVICE [--replica N] [-u USER] [-w DIR] [-e K=V]... [-i] -- CMD... +isb stack exec NAME SERVICE [--replica N] [-u USER] [-w DIR] [-e K=V]... [-i | -it] -- CMD... isb stack scale NAME SERVICE=N... isb stack redeploy NAME SERVICE isb stack rollback NAME [--to ID] diff --git a/docs/guides/kubectl.md b/docs/guides/kubectl.md index 712fd8b2..8b76482f 100644 --- a/docs/guides/kubectl.md +++ b/docs/guides/kubectl.md @@ -136,7 +136,8 @@ is not interactive and not a shell: argv is a list, so use `["sh", "-c", | output | each stream keeps its **last 1 MiB**; `stdout_truncated`, `stderr_truncated` and `truncated` say so, and `stdout_bytes`/`stderr_bytes` how much there was | `isb app exec` prints the output, exits with the command's status (124 on a -timeout), and says on stderr when the output was cut. An image built `FROM +timeout), and says on stderr when the output was cut. `-it` runs the +command [on a terminal](#interactive-on-a-terminal) instead. An image built `FROM scratch` (traefik/whoami, say) has no shell or tools: run its own binary (`isb app exec whoami -- /whoami --help`), or [`isb cp`](#copy-small-files). @@ -148,6 +149,41 @@ audit log with the **argv** (up to 64 arguments), the names of the `env` variables and the size of `stdin`, never their values. Treat argv as logged: pass a password in `env` or on `stdin`, not in the command line. +### Interactive, on a terminal + +```sh +isb stack exec -it shop web -- sh +isb --org ocai stack exec -it email stalwart -- stalwart-migrate prepare +isb app exec -it web --replica 2 -u app -- bash +isb instance exec -it scratch1 -- htop +``` + +`-t` (`--tty`, almost always written `-it`, as for `docker exec` and +`kubectl exec`) gives the command a pseudo-terminal and wires it to yours: +your keys go to it as you type them (Ctrl-C included: it reaches the +command, not isb), its output comes back as it is written, a resize of your +window resizes its terminal, and `isb` exits with the command's status once it +ends, your terminal restored. `-i` is implied; stdout and stderr arrive +merged, as on any terminal. `$TERM` is passed along unless `-e TERM=...` +sets it. + +- **stdin must be a terminal.** In a script or a pipe, `-t` is refused: use + `-i` to feed piped input to a command that is not interactive. +- **No time limit but the terminal's**: `--timeout` does not apply; the + session closes after 30 minutes without a byte either way and after 8 + hours in all. A signal to `isb` itself (SIGTERM, SIGHUP) ends the session + and kills the command. +- **The same replica choice and options** as without `-t` (`--replica`, `-u`, + `-w`, `-e`), and the same reach: over the daemon's socket on its host, or + anywhere with `$ISB_URL` and `$ISB_TOKEN`. +- **Who may:** whoever may run the command without `-t`: `-it` is admitted as + `app_exec`, `stack_exec` or `instance_exec`, so `--deny-tools` and token + scopes apply the same way. It rides [the web terminal's + websocket](../reference/http-api.md#the-web-terminal), and the audit log + records its opening and closing as a terminal's. +- **Not for agents over MCP**: the `*_exec` tools run a command to completion + and return its output; a terminal needs a person at a keyboard. + ## Copy small files ```sh diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8e73fc61..7cd5832d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -250,7 +250,7 @@ isb stack deploy [NAME] [-f FILE]... [-d] [--timeout 10m] [--project P [--env E] isb stack ls [--json] every org you see (an ORG column), or --org ORG's isb stack ps NAME [--json] isb stack logs NAME SERVICE [--slot N] [-n|--tail 100] [--since 10m|TIME] [--failed] -isb stack exec NAME SERVICE [--replica|--slot N] [-u USER] [-w DIR] [-e K=V]... [-i] [--timeout 60s] -- CMD... +isb stack exec NAME SERVICE [--replica|--slot N] [-u USER] [-w DIR] [-e K=V]... [-i | -it] [--timeout 60s] -- CMD... -it: interactive, on a terminal isb stack scale NAME SERVICE=N... isb stack redeploy NAME SERVICE isb stack rollback NAME [--to ID] ID: a kept deployment (isb stack deployments) @@ -373,7 +373,7 @@ isb app rollback NAME [ID] [-d] a previous deployment's image and settin isb app deployments NAME [--json] isb app deploy-log NAME [ID] [-f] a deployment's build and rollout log isb app logs NAME [--replica N] [-n 200] [--since 10m] the replicas' output, by app name -isb app exec NAME [--replica N] [-u USER] [-w DIR] [-e K=V]... [-i] [--timeout D] -- ARGV... +isb app exec NAME [--replica N] [-u USER] [-w DIR] [-e K=V]... [-i | -it] [--timeout D] -- ARGV... isb app restart NAME [--wait] rolling replace, same settings isb app scale NAME N also saved as the app's replicas isb app top NAME [--json] per replica CPU and memory @@ -404,7 +404,7 @@ What runs in an org, like `kubectl get pods`, `describe`, `exec` and `cp` ```text isb instance ls [--app A] [--stack S] [--service V] [--kind K] [--status S] [--json] aliases list, ps isb instance get NAME [--json] alias describe -isb instance exec NAME [-u USER] [-w DIR] [-e K=V]... [-i] [--timeout D] -- ARGV... +isb instance exec NAME [-u USER] [-w DIR] [-e K=V]... [-i | -it] [--timeout D] -- ARGV... isb instance restart NAME [--wait] a replica is replaced, a sandbox restarts isb cp INSTANCE:/path LOCAL a small file out (at most 4 MiB) isb cp LOCAL INSTANCE:/path and in (at most 2 MiB) diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 1df104e8..746afc6a 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -31,7 +31,7 @@ calling REST, something following the event stream. | `GET /api/v1/events` | server-sent events: deploys, rollouts, health and restarts in the caller's orgs | | `GET /api/v1/audit/stream` | server-sent events: new audit entries the caller may read | | `GET /api/v1/history/stream` | server-sent events: new history items the caller may read | -| `GET /orgs//api/v1/terminal?app=NAME` (or `?instance=NAME`) | a websocket to a shell ([below](#the-web-terminal)) | +| `GET /orgs//api/v1/terminal?app=NAME` (or `?stack=NAME&service=SERVICE`, or `?instance=NAME`) | a websocket to a shell or a command ([below](#the-web-terminal)) | | `GET /orgs//api/v1/ssh?instance=NAME` | a websocket carrying SSH, for `isb ssh-proxy` ([below](#the-ssh-websocket)) | | `POST /api/v1/webhooks//` | an app's push and pull request webhooks ([Deploy apps](../guides/deploy-apps.md#webhooks)) | | `GET /api/v1/templates///logo` | a template's logo, from isb's cached copy ([Templates](../guides/templates.md#logos)) | @@ -180,23 +180,31 @@ every 15 s. See [The audit log](../operations/audit.md) and `GET /orgs//api/v1/terminal?app=NAME[&slot=N][&cols=C&rows=R]` upgrades to a websocket bridged to a login shell (bash, else sh) in one of the app's running replicas (`slot`, or one in rotation), with a -pseudo-terminal. With `?instance=NAME` instead of `app`, the shell is in -that instance of the org (a sandbox, a replica), as root. The web UI's +pseudo-terminal. With `?stack=NAME&service=SERVICE` instead of `app`, the +replica is one of that stack service's; with `?instance=NAME`, the shell is +in that instance of the org (a sandbox, a replica), as root. The web UI's terminals use it. +With `argv=` it runs a command instead of a shell, as the exec tools do +(`isb stack exec -it` and its siblings use it): one `argv=` per argument, in +order, each percent-encoded, with `user=`, `cwd=` and `env=KEY=VALUE` +(repeatable) as `app_exec` takes them. At most 1024 arguments and 128 +variables; `TERM` is `xterm-256color` unless `env=` sets it. + - **Who**: the caller signs in as for any tool and is admitted as if calling - `sandbox_exec` in the org: its members, admins and owners (not viewers, - nor tokens whose scopes leave out `sandbox_exec`), platform admins, and - nobody when `--deny-tools` covers `sandbox_exec`. Only the org's own apps - and instances are reachable. + `sandbox_exec` in the org (a command: `app_exec`, `stack_exec` or + `instance_exec`, the tool that runs it without a terminal): its members, + admins and owners (not viewers, nor tokens whose scopes leave out that + tool), platform admins, and nobody when `--deny-tools` covers it. Only the + org's own apps, stacks and instances are reachable. - **Cross-site**: a session cookie rides along on a websocket from any site, so a cookie-authenticated upgrade must carry an `Origin` naming the request's `Host`. A bearer token needs no `Origin` (a browser never adds - one by itself). + one by itself), nor does the daemon's unix socket. - **Wire**: binary frames are terminal bytes both ways. Text frames are JSON: `{"type": "resize", "cols", "rows"}` from the client; - `{"type": "exit", "code"}` and `{"type": "error", "message"}` from the - server before it closes. What fails after the upgrade (no such app, a + `{"type": "exit", "code"}` (the program's exit status) and `{"type": + "error", "message"}` from the server before it closes. What fails after the upgrade (no such app, a replica that is not running, an image without a shell) arrives as an error frame. - **Bounds**: 16 terminals at once per daemon, closed after 30 minutes diff --git a/src/bin/isb.rs b/src/bin/isb.rs index 9ed81a71..9f3019e6 100644 --- a/src/bin/isb.rs +++ b/src/bin/isb.rs @@ -752,25 +752,35 @@ fn volume_local(ctx: &Ctx, v: VolumeCmd) -> Result { Ok(0) } -/// Call a tool on the local daemon. +/// isb serve where there is no daemon socket (an org's instance, another +/// machine): its URL (`$ISB_URL`, such as the org API listener on the org's +/// bridge) with an API token (`$ISB_TOKEN`), as `isb ssh-config` and `isb +/// key` use them. `None`: the local daemon's socket. +fn serve_url() -> Result> { + if isb::server::default_socket_path().exists() { + return Ok(None); + } + let remote = ssh::RemoteArgs::default().or_env(); + match remote.url { + Some(_) => remote.remote().map(Some), + None => Ok(None), + } +} + +/// The org a call is for: `--org`, else `$ISB_ORG`, else the default org. +fn org_or_env(org: Option<&str>) -> String { + org.map(String::from) + .or_else(|| std::env::var("ISB_ORG").ok().filter(|o| !o.is_empty())) + .unwrap_or_else(|| "default".into()) +} + +/// Call a tool on the local daemon (or the one at `$ISB_URL`). fn call(tool: &str, args: serde_json::Value, timeout: Duration) -> Result { - let socket = isb::server::default_socket_path(); - // Where there is no daemon socket (an org's instance, another - // machine): isb serve's URL (`$ISB_URL`, such as the org API listener on - // the org's bridge) with an API token (`$ISB_TOKEN`), as `isb - // ssh-config` and `isb key` use them. - if !socket.exists() { - let remote = ssh::RemoteArgs::default().or_env(); - if remote.url.is_some() { - let org = args - .get("org") - .and_then(serde_json::Value::as_str) - .map(String::from) - .or_else(|| std::env::var("ISB_ORG").ok().filter(|o| !o.is_empty())) - .unwrap_or_else(|| "default".into()); - return remote.remote()?.call_tool(&org, tool, args); - } + if let Some(remote) = serve_url()? { + let org = org_or_env(args.get("org").and_then(serde_json::Value::as_str)); + return remote.call_tool(&org, tool, args); } + let socket = isb::server::default_socket_path(); // The error already says "cannot connect to isb serve at : ". isb::server::client::call_tool(&socket, tool, args, timeout).map_err(|e| match e { Error::Io(_) if cfg!(target_os = "macos") => Error::Invalid(format!( diff --git a/src/bin/isb/apps.rs b/src/bin/isb/apps.rs index f96b7b84..cd8629fe 100644 --- a/src/bin/isb/apps.rs +++ b/src/bin/isb/apps.rs @@ -217,8 +217,9 @@ pub enum AppCmd { /// The app's replicas' recent output (kubectl logs): all of them, or /// one with --replica; --tail N, --since 10m. Logs(kube::AppLogs), - /// Run a command in one of the app's replicas, as kubectl exec does - /// (not interactive): `isb app exec web -- ls -l`; -i feeds stdin. + /// Run a command in one of the app's replicas, as kubectl exec does: + /// `isb app exec web -- ls -l`; -i feeds stdin; -it runs it + /// interactively, on a terminal (`isb app exec -it web -- sh`). Exec { name: String, #[command(flatten)] diff --git a/src/bin/isb/kube.rs b/src/bin/isb/kube.rs index 6aac9d4a..0f0f2d86 100644 --- a/src/bin/isb/kube.rs +++ b/src/bin/isb/kube.rs @@ -9,6 +9,7 @@ use std::time::Duration; use clap::{Args, Subcommand}; use serde_json::{Value, json}; +use isb::server::terminal::{TermCommand, TermRequest}; use isb::{Error, Result}; use super::{SHORT, call, print_json, table}; @@ -41,6 +42,11 @@ pub struct ExecArgs { /// Feed this process's stdin to the command (at most 1 MiB). #[arg(short = 'i', long, conflicts_with = "no_stdin")] pub stdin: bool, + /// Interactive, on a pseudo-terminal: keys, output and window size go + /// to and fro live until the command exits (`-it`, as docker and + /// kubectl take it; -i is implied). Needs stdin to be a terminal. + #[arg(short = 't', long, conflicts_with_all = ["no_stdin", "timeout"])] + pub tty: bool, /// Never feed stdin, and say nothing about piped input left unread. #[arg(short = 'n', long)] pub no_stdin: bool, @@ -115,7 +121,7 @@ fn dur_s(t: &Option) -> Duration { .unwrap_or(Duration::from_secs(60)) } -fn exec_args(org: &Option, name: &str, a: &ExecArgs) -> Result { +fn env_of(a: &ExecArgs) -> Result> { let mut env = BTreeMap::new(); for e in &a.env { let (k, v) = e @@ -123,6 +129,11 @@ fn exec_args(org: &Option, name: &str, a: &ExecArgs) -> Result { .ok_or_else(|| Error::Invalid(format!("--env {e}: want KEY=VALUE")))?; env.insert(k.to_string(), v.to_string()); } + Ok(env) +} + +fn exec_args(org: &Option, name: &str, a: &ExecArgs) -> Result { + let env = env_of(a)?; let mut args = json!({"name": name, "argv": a.argv}); if let Some(r) = a.replica { args["replica"] = json!(r); @@ -157,6 +168,19 @@ fn exec_args(org: &Option, name: &str, a: &ExecArgs) -> Result { /// Run `tool` (app_exec or instance_exec) and relay its output and exit code. pub fn exec(org: &Option, tool: &str, name: &str, a: &ExecArgs) -> Result { + if a.tty { + let t = match tool { + "instance_exec" => TermRequest { + instance: Some(name.to_string()), + ..Default::default() + }, + _ => TermRequest { + app: name.to_string(), + ..Default::default() + }, + }; + return tty_exec(org, t, a); + } let args = exec_args(org, name, a)?; let timeout = dur_s(&a.timeout) + SHORT; relay(call(tool, args, timeout)?) @@ -179,11 +203,86 @@ fn relay(r: Value) -> Result { /// `isb stack exec`: run argv in one of a stack service's replicas. pub fn stack_exec(org: &Option, stack: &str, service: &str, a: &ExecArgs) -> Result { + if a.tty { + let t = TermRequest { + app: service.to_string(), + stack: Some(stack.to_string()), + ..Default::default() + }; + return tty_exec(org, t, a); + } let mut args = exec_args(org, stack, a)?; args["service"] = json!(service); relay(call("stack_exec", args, dur_s(&a.timeout) + SHORT)?) } +/// `exec -t`: the command on a pseudo-terminal, through isb serve's +/// terminal websocket (docs/reference/http-api.md#the-web-terminal), with +/// this terminal raw until it ends. Returns the command's exit code. +fn tty_exec(org: &Option, mut t: TermRequest, a: &ExecArgs) -> Result { + use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM, SIGWINCH}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + if !rustix::termios::isatty(std::io::stdin()) { + return Err(Error::Invalid( + "-t needs a terminal, and stdin is not one: drop -t (-i feeds piped input)".into(), + )); + } + if a.instance.is_some() { + return Err(Error::Invalid( + "--instance with -t: use `isb instance exec -t NAME`".into(), + )); + } + let mut env = env_of(a)?; + if let Ok(term) = std::env::var("TERM") { + env.entry("TERM".into()).or_insert(term); + } + (t.cols, t.rows) = isb::exec::terminal_size().unwrap_or((80, 24)); + t.slot = a.replica; + t.command = Some(TermCommand { + argv: a.argv.clone(), + user: a.user.clone(), + cwd: a.cwd.clone(), + env, + }); + let remote = match super::serve_url()? { + Some(r) => r, + None => isb::server::ssh::Remote::Socket(isb::server::default_socket_path()), + }; + let o = super::org_or_env(org.as_deref()); + let mut ws = remote.websocket(&format!("/orgs/{o}/api/v1/terminal?{}", t.query()))?; + // Raw mode turns ^C into a byte for the command; a signal from + // elsewhere ends the session, with the terminal restored. + let resized = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(AtomicBool::new(false)); + signal_hook::flag::register(SIGWINCH, resized.clone())?; + for sig in [SIGINT, SIGTERM, SIGHUP] { + signal_hook::flag::register(sig, stop.clone())?; + } + let code = { + let _raw = isb::exec::RawMode::enable(); + isb::server::terminal::attach( + &mut ws, + std::io::stdin(), + std::io::stdout(), + isb::exec::terminal_size, + &resized, + &stop, + ) + }?; + match code { + Some(c) => Ok(c.clamp(0, 255) as u8), + None if stop.load(Ordering::SeqCst) => { + eprintln!("isb: interrupted; the command was killed"); + Ok(130) + } + None => { + eprintln!("isb: the session ended without the command's exit code"); + Ok(1) + } + } +} + pub fn logs(org: &Option, a: AppLogs) -> Result { let mut args = json!({"name": a.name}); if let Some(r) = a.replica { diff --git a/src/bin/isb/stack.rs b/src/bin/isb/stack.rs index ff9c2a32..566b329a 100644 --- a/src/bin/isb/stack.rs +++ b/src/bin/isb/stack.rs @@ -100,8 +100,9 @@ pub(crate) enum StackCmd { #[arg(long)] failed: bool, }, - /// Run a command in one of a service's replicas (not interactive): - /// `isb stack exec shop web -- ls -l`; -i feeds stdin. + /// Run a command in one of a service's replicas: `isb stack exec shop + /// web -- ls -l`; -i feeds stdin; -it runs it interactively, on a + /// terminal (`isb stack exec -it shop web -- sh`). Exec { name: String, service: String, diff --git a/web/openapi.json b/web/openapi.json index a03d8064..c398d19e 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -17150,7 +17150,7 @@ }, "/orgs/{org}/api/v1/terminal": { "get": { - "description": "Upgrades to a websocket bridged to a login shell in an app replica (?app, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. Admitted as `sandbox_exec` in the org. docs/reference/http-api.md#the-web-terminal.", + "description": "Upgrades to a websocket bridged to a login shell, or to a command (?argv), in an app replica (?app, ?slot), a stack service's replica (?stack, ?service, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. A shell is admitted as `sandbox_exec` in the org; a command as `app_exec`, `stack_exec` or `instance_exec`. docs/reference/http-api.md#the-web-terminal.", "operationId": "get_orgs_org_api_v1_terminal", "parameters": [ { @@ -17170,6 +17170,24 @@ "type": "string" } }, + { + "description": "Or a compose stack (with service).", + "in": "query", + "name": "stack", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The stack's service.", + "in": "query", + "name": "service", + "required": false, + "schema": { + "type": "string" + } + }, { "description": "Which replica (default: one in rotation).", "in": "query", @@ -17205,6 +17223,42 @@ "schema": { "type": "integer" } + }, + { + "description": "Run this command instead of a shell: one argument per argv=, in order, percent-encoded.", + "in": "query", + "name": "argv", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The command's guest user (name, uid or uid:gid).", + "in": "query", + "name": "user", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The command's working directory.", + "in": "query", + "name": "cwd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "KEY=VALUE for the command (repeatable).", + "in": "query", + "name": "env", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { From f6eb8b59490a61b1f1847e3d31ad84298b054a9a Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Mon, 26 Oct 2026 19:34:55 +0000 Subject: [PATCH 2/4] web: regenerate the API types for the terminal's new parameters Claude-Session: https://claude.ai/code/session_016AFG7KfrwCTBETjGfyW75K --- web/src/api/openapi.gen.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/api/openapi.gen.ts b/web/src/api/openapi.gen.ts index 9ca8cccb..680f2d2f 100644 --- a/web/src/api/openapi.gen.ts +++ b/web/src/api/openapi.gen.ts @@ -4701,7 +4701,7 @@ export interface paths { }; /** * Web terminal (websocket) - * @description Upgrades to a websocket bridged to a login shell in an app replica (?app, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. Admitted as `sandbox_exec` in the org. docs/reference/http-api.md#the-web-terminal. + * @description Upgrades to a websocket bridged to a login shell, or to a command (?argv), in an app replica (?app, ?slot), a stack service's replica (?stack, ?service, ?slot) or an instance (?instance), with a pseudo-terminal. Binary frames are terminal bytes; text frames are JSON resize/exit/error messages. A shell is admitted as `sandbox_exec` in the org; a command as `app_exec`, `stack_exec` or `instance_exec`. docs/reference/http-api.md#the-web-terminal. */ get: operations["get_orgs_org_api_v1_terminal"]; put?: never; @@ -14822,6 +14822,10 @@ export interface operations { query?: { /** @description An app to open a shell in. */ app?: string; + /** @description Or a compose stack (with service). */ + stack?: string; + /** @description The stack's service. */ + service?: string; /** @description Which replica (default: one in rotation). */ slot?: number; /** @description Or an instance of the org (a sandbox, a replica). */ @@ -14830,6 +14834,14 @@ export interface operations { cols?: number; /** @description Initial rows. */ rows?: number; + /** @description Run this command instead of a shell: one argument per argv=, in order, percent-encoded. */ + argv?: string; + /** @description The command's guest user (name, uid or uid:gid). */ + user?: string; + /** @description The command's working directory. */ + cwd?: string; + /** @description KEY=VALUE for the command (repeatable). */ + env?: string; }; header?: never; path: { From 5b3152478b626c097772fd7167bccef1eb1908d0 Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Mon, 26 Oct 2026 19:35:17 +0000 Subject: [PATCH 3/4] isb 3.4.0 Claude-Session: https://claude.ai/code/session_016AFG7KfrwCTBETjGfyW75K --- Cargo.lock | 14 +++++++------- Cargo.toml | 14 +++++++------- docs/reference/rpc.md | 4 ++-- sdk/python/pyproject.toml | 2 +- sdk/python/src/isb/__init__.py | 2 +- sdk/python/tests/test_unit.py | 6 +++--- sdk/python/uv.lock | 2 +- sdk/typescript/bun.lock | 4 ++-- sdk/typescript/npm/linux-arm64/package.json | 2 +- sdk/typescript/npm/linux-x64/package.json | 2 +- sdk/typescript/package.json | 6 +++--- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62076a0a..bd6d6a79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,7 +1329,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "isb" -version = "3.3.0" +version = "3.4.0" dependencies = [ "age", "clap", @@ -1352,7 +1352,7 @@ dependencies = [ [[package]] name = "isb-apps" -version = "3.3.0" +version = "3.4.0" dependencies = [ "age", "base64 0.23.1", @@ -1376,7 +1376,7 @@ dependencies = [ [[package]] name = "isb-core" -version = "3.3.0" +version = "3.4.0" dependencies = [ "age", "base64 0.23.1", @@ -1403,7 +1403,7 @@ dependencies = [ [[package]] name = "isb-daemon" -version = "3.3.0" +version = "3.4.0" dependencies = [ "age", "httparse", @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "isb-egress" -version = "3.3.0" +version = "3.4.0" dependencies = [ "base64 0.23.1", "httparse", @@ -1436,7 +1436,7 @@ dependencies = [ [[package]] name = "isb-server" -version = "3.3.0" +version = "3.4.0" dependencies = [ "argon2", "base64 0.23.1", @@ -1459,7 +1459,7 @@ dependencies = [ [[package]] name = "isb-tui" -version = "3.3.0" +version = "3.4.0" dependencies = [ "isb-core", "libc", diff --git a/Cargo.toml b/Cargo.toml index ac097cf9..236f4fd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ members = ["crates/*"] default-members = [".", "crates/*"] [workspace.package] -version = "3.3.0" +version = "3.4.0" edition = "2024" rust-version = "1.87" license = "MIT" @@ -63,12 +63,12 @@ repository = "https://github.com/execution-associates/isb" homepage = "https://github.com/execution-associates/isb" [workspace.dependencies] -isb-core = { path = "crates/isb-core", version = "=3.3.0" } -isb-server = { path = "crates/isb-server", version = "=3.3.0" } -isb-apps = { path = "crates/isb-apps", version = "=3.3.0" } -isb-daemon = { path = "crates/isb-daemon", version = "=3.3.0" } -isb-tui = { path = "crates/isb-tui", version = "=3.3.0" } -isb-egress = { path = "crates/isb-egress", version = "=3.3.0" } +isb-core = { path = "crates/isb-core", version = "=3.4.0" } +isb-server = { path = "crates/isb-server", version = "=3.4.0" } +isb-apps = { path = "crates/isb-apps", version = "=3.4.0" } +isb-daemon = { path = "crates/isb-daemon", version = "=3.4.0" } +isb-tui = { path = "crates/isb-tui", version = "=3.4.0" } +isb-egress = { path = "crates/isb-egress", version = "=3.4.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/reference/rpc.md b/docs/reference/rpc.md index f38eca22..f4ad75ce 100644 --- a/docs/reference/rpc.md +++ b/docs/reference/rpc.md @@ -19,7 +19,7 @@ it; relative paths resolve against that directory. When it starts, the server writes one hello line: ```json -{"isb": "3.3.0", "protocol": 1} +{"isb": "3.4.0", "protocol": 1} ``` A client checks `protocol` and refuses versions it does not know. @@ -205,7 +205,7 @@ All take `files` (default `./isb.yaml` plus `./isb.override.yaml` if present), ## Example session ```text -<- {"isb":"3.3.0","protocol":1} +<- {"isb":"3.4.0","protocol":1} -> {"id":1,"method":"sandbox.ensure","params":{"spec":{"name":"web","image":"dev-base","cpus":2}}} <- {"id":1,"event":"progress","data":"web: creating from dev-base"} <- {"id":1,"event":"progress","data":"web: starting"} diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 4f82a717..bee68d94 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "isb-sdk" -version = "3.3.0" +version = "3.4.0" description = "Python SDK for isb: declarative incus sandboxes (containers and VMs)" readme = "README.md" license = "MIT" diff --git a/sdk/python/src/isb/__init__.py b/sdk/python/src/isb/__init__.py index 25218985..71860488 100644 --- a/sdk/python/src/isb/__init__.py +++ b/sdk/python/src/isb/__init__.py @@ -62,7 +62,7 @@ async def main(): from ._util import Duration from .volumes import Volumes -__version__ = "3.3.0" +__version__ = "3.4.0" __all__ = [ "PROTOCOL", diff --git a/sdk/python/tests/test_unit.py b/sdk/python/tests/test_unit.py index b70a9c1c..7c24a7c4 100644 --- a/sdk/python/tests/test_unit.py +++ b/sdk/python/tests/test_unit.py @@ -222,7 +222,7 @@ async def test_death_fails_pending_requests(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.3.0","protocol":1}' + echo '{"isb":"3.4.0","protocol":1}' read line echo "boom" >&2 exit 3 @@ -243,7 +243,7 @@ async def test_close_fails_pending(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.3.0","protocol":1}' + echo '{"isb":"3.4.0","protocol":1}' cat >/dev/null """, ) @@ -261,7 +261,7 @@ async def test_events_and_out_of_order_replies(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.3.0","protocol":1}' + echo '{"isb":"3.4.0","protocol":1}' read a read b echo 'not json' diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index f4ef4796..fb93b779 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -72,7 +72,7 @@ wheels = [ [[package]] name = "isb-sdk" -version = "3.3.0" +version = "3.4.0" source = { editable = "." } [package.dev-dependencies] diff --git a/sdk/typescript/bun.lock b/sdk/typescript/bun.lock index bc6ebefb..05fe890f 100644 --- a/sdk/typescript/bun.lock +++ b/sdk/typescript/bun.lock @@ -12,8 +12,8 @@ "typescript": "^5.9.0", }, "optionalDependencies": { - "@execution-associates/isb-linux-arm64": "3.3.0", - "@execution-associates/isb-linux-x64": "3.3.0", + "@execution-associates/isb-linux-arm64": "3.4.0", + "@execution-associates/isb-linux-x64": "3.4.0", }, }, }, diff --git a/sdk/typescript/npm/linux-arm64/package.json b/sdk/typescript/npm/linux-arm64/package.json index c3383efa..a4a1d090 100644 --- a/sdk/typescript/npm/linux-arm64/package.json +++ b/sdk/typescript/npm/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb-linux-arm64", - "version": "3.3.0", + "version": "3.4.0", "description": "The static isb binary for Linux arm64 (aarch64-unknown-linux-musl), used by @execution-associates/isb", "license": "MIT", "repository": { diff --git a/sdk/typescript/npm/linux-x64/package.json b/sdk/typescript/npm/linux-x64/package.json index 2a5ee8d4..1f358325 100644 --- a/sdk/typescript/npm/linux-x64/package.json +++ b/sdk/typescript/npm/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb-linux-x64", - "version": "3.3.0", + "version": "3.4.0", "description": "The static isb binary for Linux x64 (x86_64-unknown-linux-musl), used by @execution-associates/isb", "license": "MIT", "repository": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 4aebff59..edbfc487 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb", - "version": "3.3.0", + "version": "3.4.0", "description": "TypeScript SDK for isb: declarative incus sandboxes (containers and VMs)", "license": "MIT", "type": "module", @@ -51,8 +51,8 @@ "prepack": "bun run build" }, "optionalDependencies": { - "@execution-associates/isb-linux-arm64": "3.3.0", - "@execution-associates/isb-linux-x64": "3.3.0" + "@execution-associates/isb-linux-arm64": "3.4.0", + "@execution-associates/isb-linux-x64": "3.4.0" }, "devDependencies": { "@biomejs/biome": "^2.2.0", From dbb833e48fa3cd7bb837b1f314ba1b0c4c8b14e9 Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Mon, 26 Oct 2026 19:53:07 +0000 Subject: [PATCH 4/4] exec -t: a read cut short by SIGWINCH is retried, not fatal Found end to end: resizing the window interrupted the websocket read (EINTR) and ended the session. Claude-Session: https://claude.ai/code/session_016AFG7KfrwCTBETjGfyW75K --- crates/isb-server/src/server/terminal.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/isb-server/src/server/terminal.rs b/crates/isb-server/src/server/terminal.rs index d4ebd3dd..26cfa980 100644 --- a/crates/isb-server/src/server/terminal.rs +++ b/crates/isb-server/src/server/terminal.rs @@ -485,8 +485,11 @@ fn refuse(ws: &mut WebSocket, message: &st let _ = ws.flush(); } +/// Nothing to read yet, or a read cut short by a signal (SIGWINCH in `exec +/// -t`): try again on the next turn. fn would_block(e: &tungstenite::Error) -> bool { - matches!(e, tungstenite::Error::Io(io) if matches!(io.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut)) + use std::io::ErrorKind::{Interrupted, TimedOut, WouldBlock}; + matches!(e, tungstenite::Error::Io(io) if matches!(io.kind(), WouldBlock | TimedOut | Interrupted)) } /// Shuttle bytes between the websocket and the terminal until either ends,