Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
# The listener default is loopback, and the runtime refuses an off-loopback bind
# while it holds no TLS key. Reachability is the deployment's job — a tailscale
# sidecar sharing the network namespace — not this image's.
ENV HOME=/workspace \

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (copilot)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (codex)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (mimocode)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (devin)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (cursor)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (claude)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (native)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (gemini)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (kiro)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (antigravity)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (hermes)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (grok)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (opencode)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 95 in Dockerfile

View workflow job for this annotation

GitHub Actions / image (pi)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "PTY_TOKEN_TTL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
RUST_LOG=info \
PTY_LISTEN=127.0.0.1:8090 \
PTY_COMMAND=/usr/bin/bash \
Expand All @@ -111,6 +111,6 @@
# through GET /tools/attach/{session}; the CLI then finds them at the URL in
# OPENAB_TOOLS_MCP_URL. Off means: no listener, and /tools/attach refuses.
PTY_TOOLS_LISTEN="" \
PTY_TOOLS_ATTACH_TTL=1h
PTY_TOOLS_ATTACH_TTL=24h

ENTRYPOINT ["/usr/local/bin/openab-pty-entrypoint"]
2 changes: 1 addition & 1 deletion deploy/ecs/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ seed_dir = "${PTY_SEED_DIR:-}"
# Reverse-attached tools (a Mac lends its instance-mcp tools to one session).
# Empty disables the plane; the runtime refuses anything but loopback here.
tools_listen = "${PTY_TOOLS_LISTEN:-}"
tools_attach_ttl = "${PTY_TOOLS_ATTACH_TTL:-1h}"
tools_attach_ttl = "${PTY_TOOLS_ATTACH_TTL:-24h}"
EOF

# Fail before serving rather than after: the same validator the runtime applies
Expand Down
8 changes: 6 additions & 2 deletions docs/k8s-howto.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,14 @@ The pod initiates nothing and stores only a hash. Design:
wire contract: §9 of [`../runtime/CLIENT-CONTRACT.md`](../runtime/CLIENT-CONTRACT.md).

```bash
# Mint a one-hour attach secret for session "laptop" (admin credential required).
# Mint a four-hour attach secret for session "laptop" (admin credential required).
# Omit the body for the backwards-compatible one-hour default. The image allows
# up to 24h (`PTY_TOOLS_ATTACH_TTL` is the operator ceiling).
curl -s -X POST -H "Authorization: Bearer $CRED" \
-H "Content-Type: application/json" -d '{"ttl_secs":14400}' \
http://<pod-tailnet-ip>:8090/admin/sessions/laptop/tools-attach
# → {"secret":"…","verifier":"sha256:…","expires_in_secs":3600,"attach":"/tools/attach/laptop"}
# → {"secret":"…","verifier":"sha256:…","expires_in_secs":14400,
# "ttl_secs":14400,"attach":"/tools/attach/laptop"}
# Hand the secret to the Mac; it dials ws://<pod-tailnet-ip>:8090/tools/attach/laptop
# with `Authorization: Bearer <secret>`. Revoke any time:
curl -s -X DELETE -H "Authorization: Bearer $CRED" \
Expand Down
11 changes: 9 additions & 2 deletions runtime/CLIENT-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ every upgrade with `401`, and the mint endpoint returns `501`.

```
POST /admin/sessions/{session}/tools-attach Authorization: Bearer <admin-credential>
optional JSON: {"ttl_secs": 14400}
DELETE /admin/sessions/{session}/tools-attach Authorization: Bearer <admin-credential>
```

Expand All @@ -296,7 +297,8 @@ DELETE /admin/sessions/{session}/tools-attach Authorization: Bearer <admin
{ "session": "laptop",
"secret": "b1f0…64 lowercase hex…",
"verifier": "sha256:…",
"expires_in_secs": 3600,
"expires_in_secs": 14400,
"ttl_secs": 14400,
"attach": "/tools/attach/laptop" }
```

Expand All @@ -305,8 +307,13 @@ DELETE /admin/sessions/{session}/tools-attach Authorization: Bearer <admin
- Minting again **rotates** the secret and resets the TTL. A Mac already attached
stays attached; a Mac that redials must present the new secret. This is how a
grant is renewed: mint before expiry, hand over the new secret.
- Omit the body (or omit `ttl_secs`) for a **one-hour** grant. Connect/Remote
request one of 1/2/4/12/24 hours by sending seconds. `tools_attach_ttl` is the
operator's ceiling, default **24h** and itself hard-capped at 24h (env
`PTY_TOOLS_ATTACH_TTL`); it is not the default lease. `ttl_secs = 0` or a request above that ceiling is `400` with an
error naming the maximum — never a successful response with a silent shorter
lease. The session's own absolute TTL can still end it first.
- `404` if the session does not exist; `501` if the plane is off.
- TTL is `tools_attach_ttl` (default `1h`, env `PTY_TOOLS_ATTACH_TTL`).

`DELETE` → `204` always (once the name parses). It drops the grant and closes any
attached Mac with **`4010`**. Revoking nothing is not an error.
Expand Down
20 changes: 18 additions & 2 deletions runtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ pub struct PtyConfig {
/// listener, and `/tools/attach` refuses every upgrade. Must be loopback;
/// this surface is unauthenticated by design and its boundary is the pod.
pub tools_listen: String,
/// Lifetime of a tools-attach grant minted by the admin plane.
/// Upper bound on a tools-attach grant requested by the admin plane.
/// A request with no `ttl_secs` remains one hour; Connect/Remote may request
/// up to this operator-controlled ceiling.
pub tools_attach_ttl: Duration,
}

Expand Down Expand Up @@ -166,7 +168,7 @@ pub fn validate_projection(input: &str) -> Result<PtyConfig, Error> {
admin_credential_hash: raw_hash,
seed_dir: string_with_default(pty, "seed_dir", "")?.to_string(),
tools_listen: string_with_default(pty, "tools_listen", "")?.to_string(),
tools_attach_ttl: duration_with_default(pty, "tools_attach_ttl", "1h")?,
tools_attach_ttl: duration_with_default(pty, "tools_attach_ttl", "24h")?,
kill_domain_requirement: parse_kill_domain(string_with_default(
pty,
"kill_domain_tier",
Expand Down Expand Up @@ -384,6 +386,12 @@ fn validate_lifecycle(config: &PtyConfig) -> Result<(), Error> {
if config.tools_attach_ttl.is_zero() {
return Err(Error::Config("tools_attach_ttl must be non-zero".into()));
}
if config.tools_attach_ttl > crate::tools::DEFAULT_TOOLS_ATTACH_MAX_TTL {
return Err(Error::Config(format!(
"tools_attach_ttl must not exceed the hard 24h lease maximum ({:?})",
crate::tools::DEFAULT_TOOLS_ATTACH_MAX_TTL
)));
}
if !config.tools_listen.is_empty() && !crate::server::bind_is_loopback(&config.tools_listen) {
// Fail closed. The tools listener carries no credential of its own: its
// whole boundary is "only this pod can reach it", which a non-loopback
Expand Down Expand Up @@ -536,6 +544,14 @@ admin_credential_hash = "{HASH}"
),
"must not be shorter than detached_idle_ttl",
),
(
format!("{}tools_attach_ttl = \"0s\"\n", valid()),
"tools_attach_ttl must be greater than zero",
),
(
format!("{}tools_attach_ttl = \"25h\"\n", valid()),
"hard 24h lease maximum",
),
] {
let error =
validate_projection(&projection).expect_err("incompatible TTLs must fail closed");
Expand Down
32 changes: 29 additions & 3 deletions runtime/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,8 @@ pub struct ServerConfig {
pub drain_grace: Duration,
/// TTL maintenance interval.
pub tick_interval: Duration,
/// Lifetime of a tools-attach grant (`POST /admin/sessions/{s}/tools-attach`).
/// Upper bound on a tools-attach grant (`POST /admin/sessions/{s}/tools-attach`).
/// The endpoint defaults an omitted `ttl_secs` to one hour.
pub tools_attach_ttl: Duration,
}

Expand All @@ -472,7 +473,7 @@ impl Default for ServerConfig {
tls_terminated_upstream: true,
drain_grace: Duration::from_secs(3),
tick_interval: Duration::from_secs(1),
tools_attach_ttl: tools::DEFAULT_TOOLS_ATTACH_TTL,
tools_attach_ttl: tools::DEFAULT_TOOLS_ATTACH_MAX_TTL,
}
}
}
Expand Down Expand Up @@ -1244,6 +1245,12 @@ async fn tools_attach(
})
}

#[derive(Debug, Default, Deserialize)]
struct ToolsMintRequest {
/// Optional lease requested by Connect/Remote. Missing stays one hour;
/// zero or above the operator ceiling is a 400, never silently capped.
ttl_secs: Option<u64>,
}
/// `POST /admin/sessions/{session}/tools-attach` — mint the secret a Mac will
/// present on `/tools/attach/{session}`. Returned once; the runtime keeps the
/// hash. Minting again rotates the secret and leaves a live attach in place.
Expand All @@ -1252,6 +1259,7 @@ async fn admin_tools_mint(
ConnectInfo(Peer(peer)): ConnectInfo<Peer>,
Path(session): Path<String>,
headers: HeaderMap,
request: Option<Json<ToolsMintRequest>>,
) -> Response {
if let Some(response) = admin_gate(&state, &headers, &peer) {
return response;
Expand All @@ -1270,7 +1278,24 @@ async fn admin_tools_mint(
)
.into_response();
}
match state.tools.mint(&name) {
let max_ttl = state.tools.max_ttl();
let ttl = request
.and_then(|Json(body)| body.ttl_secs)
.map(Duration::from_secs)
.unwrap_or_else(|| tools::DEFAULT_TOOLS_ATTACH_TTL.min(max_ttl));
if ttl.is_zero() || ttl > max_ttl {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!(
"ttl_secs must be between 1 and {} (configured tools_attach_ttl maximum)",
max_ttl.as_secs()
)
})),
)
.into_response();
}
match state.tools.mint_with_ttl(&name, ttl) {
Ok(mut minted) => {
let secret = String::from_utf8_lossy(minted.plaintext.as_bytes()).into_owned();
minted.plaintext.zeroize();
Expand All @@ -1284,6 +1309,7 @@ async fn admin_tools_mint(
.expires_at
.saturating_duration_since(Instant::now())
.as_secs(),
"ttl_secs": ttl.as_secs(),
"attach": format!("/tools/attach/{}", name.as_str()),
})),
)
Expand Down
69 changes: 59 additions & 10 deletions runtime/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
use tokio::sync::{mpsc, oneshot};

/// Default lifetime of a tools-attach grant. One hour is the "lend my Mac to
/// this session" unit the Connect / Remote UI offers.
/// Default lifetime when the mint request omits `ttl_secs`.
pub const DEFAULT_TOOLS_ATTACH_TTL: Duration = Duration::from_secs(60 * 60);
/// Default configured upper bound. Connect/Remote offer up to 24 hours; the
/// operator can lower this with `[pty].tools_attach_ttl` / `PTY_TOOLS_ATTACH_TTL`.
pub const DEFAULT_TOOLS_ATTACH_MAX_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Bound on requests a session may have in flight toward its Mac. Past it the
/// loopback caller gets a JSON-RPC error immediately rather than queueing.
pub const MAX_INFLIGHT_PER_SESSION: usize = 64;
Expand Down Expand Up @@ -281,23 +283,25 @@ pub struct ToolsHub {
/// when a session's child is spawned, so it exists before the CLI can ask.
loopback_keys: Mutex<HashMap<SessionName, [u8; 32]>>,
attached: Mutex<HashMap<SessionName, Arc<Attached>>>,
ttl: Duration,
max_ttl: Duration,
audit: AuditLogger,
}

impl ToolsHub {
pub fn new(ttl: Duration, audit: AuditLogger) -> Self {
/// `max_ttl` is an operator ceiling, not the default lease. Requests that
/// omit `ttl_secs` remain one hour for backwards compatibility.
pub fn new(max_ttl: Duration, audit: AuditLogger) -> Self {
Self {
grants: Mutex::new(HashMap::new()),
loopback_keys: Mutex::new(HashMap::new()),
attached: Mutex::new(HashMap::new()),
ttl,
max_ttl,
audit,
}
}

pub fn ttl(&self) -> Duration {
self.ttl
pub fn max_ttl(&self) -> Duration {
self.max_ttl
}

// -- grants -------------------------------------------------------------
Expand All @@ -306,20 +310,37 @@ impl ToolsHub {
/// keeps its socket: renewal is "new secret, same connection", so a Mac that
/// redials after the old secret expires is not evicted mid-grant.
pub fn mint(&self, session: &SessionName) -> Result<MintedToolsAttach, Error> {
if self.ttl.is_zero() {
self.mint_with_ttl(session, DEFAULT_TOOLS_ATTACH_TTL.min(self.max_ttl))
}

/// Mint with the admin caller's requested lifetime. Refuse rather than cap:
/// a silent cap is exactly how a 12-hour Connect lease used to become one
/// hour while every layer reported success.
pub fn mint_with_ttl(
&self,
session: &SessionName,
ttl: Duration,
) -> Result<MintedToolsAttach, Error> {
if ttl.is_zero() {
return Err(Error::Other("tools attach TTL must be non-zero".into()));
}
if ttl > self.max_ttl {
return Err(Error::Other(format!(
"tools attach TTL exceeds configured maximum of {} seconds",
self.max_ttl.as_secs()
)));
}
let encoded = random_hex()?;
let hash = sha256(&encoded);
let expires_at = Instant::now() + self.ttl;
let expires_at = Instant::now() + ttl;
self.grants
.lock()
.insert(session.clone(), Grant { hash, expires_at });
self.audit.record(
AuditEvent::new(AuditKind::ToolsGrantMinted)
.session_name(session)
.fingerprint(hash_fingerprint(&hash))
.detail(format!("ttl_secs={}", self.ttl.as_secs())),
.detail(format!("ttl_secs={}", ttl.as_secs())),
);
Ok(MintedToolsAttach {
plaintext: SecretBytes::new(encoded),
Expand Down Expand Up @@ -859,12 +880,40 @@ mod tests {
hub.verify(&s, &mut again, "test").unwrap();
}

#[test]
fn mint_uses_one_hour_by_default_and_honors_a_requested_ttl() {
let hub = ToolsHub::new(DEFAULT_TOOLS_ATTACH_MAX_TTL, AuditLogger);
let before = Instant::now();
let defaulted = hub.mint(&session("defaulted")).unwrap();
let default_lifetime = defaulted.expires_at.duration_since(before);
assert!(default_lifetime >= Duration::from_secs(3599));
assert!(default_lifetime <= Duration::from_secs(3601));

let before = Instant::now();
let requested = hub
.mint_with_ttl(&session("requested"), Duration::from_secs(2 * 60 * 60))
.unwrap();
let requested_lifetime = requested.expires_at.duration_since(before);
assert!(requested_lifetime >= Duration::from_secs(7199));
assert!(requested_lifetime <= Duration::from_secs(7201));
}

#[test]
fn mint_refuses_zero_and_over_the_operator_maximum_instead_of_capping() {
let hub = ToolsHub::new(Duration::from_secs(24 * 60 * 60), AuditLogger);
assert!(hub.mint_with_ttl(&session("zero"), Duration::ZERO).is_err());
assert!(hub
.mint_with_ttl(&session("over"), Duration::from_secs(24 * 60 * 60 + 1),)
.is_err());
}

#[test]
fn a_secret_for_session_a_does_not_open_session_b() {
let hub = ToolsHub::new(Duration::from_secs(60), AuditLogger);
let a = session("a");
let b = session("b");
let minted = hub.mint(&a).unwrap();

hub.mint(&b).unwrap();
let mut presented = SecretBytes::new(minted.plaintext.as_bytes().to_vec());
assert!(hub.verify(&b, &mut presented, "test").is_err());
Expand Down
42 changes: 35 additions & 7 deletions runtime/tests/tools_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,18 @@ tools_attach_ttl = "1h"
body["token"].as_str().unwrap().to_owned()
}

async fn mint_tools_response(&self, name: &str, ttl_secs: Option<u64>) -> (u16, Value) {
let body = ttl_secs.map(|ttl| format!(r#"{{"ttl_secs":{ttl}}}"#));
self.admin(
"POST",
&format!("/admin/sessions/{name}/tools-attach"),
body.as_deref(),
)
.await
}

async fn mint_tools(&self, name: &str) -> String {
let (status, body) = self
.admin(
"POST",
&format!("/admin/sessions/{name}/tools-attach"),
None,
)
.await;
let (status, body) = self.mint_tools_response(name, None).await;
assert_eq!(status, 201, "tools mint failed: {body}");
assert!(body["verifier"].as_str().unwrap().starts_with("sha256:"));
body["secret"].as_str().unwrap().to_owned()
Expand Down Expand Up @@ -388,6 +392,30 @@ async fn fake_mac(
// Tests
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore = "binds sockets and spawns a PTY child"]
async fn tools_mint_honors_requested_ttl_and_rejects_invalid_values() {
let h = Harness::start(Duration::from_secs(24 * 60 * 60)).await;
h.create("lease").await;

let (status, defaulted) = h.mint_tools_response("lease", None).await;
assert_eq!(status, 201, "{defaulted}");
assert_eq!(defaulted["ttl_secs"], json!(3600));
assert!(defaulted["expires_in_secs"].as_u64().unwrap() >= 3599);

let (status, four_hours) = h.mint_tools_response("lease", Some(4 * 60 * 60)).await;
assert_eq!(status, 201, "{four_hours}");
assert_eq!(four_hours["ttl_secs"], json!(4 * 60 * 60));
assert!(four_hours["expires_in_secs"].as_u64().unwrap() >= 4 * 60 * 60 - 1);

for invalid in [0, 24 * 60 * 60 + 1] {
let (status, body) = h.mint_tools_response("lease", Some(invalid)).await;
assert_eq!(status, 400, "invalid ttl {invalid} was accepted: {body}");
assert!(body["error"].as_str().unwrap().contains("ttl_secs"));
}
h.shutdown().await;
}

#[tokio::test]
#[ignore = "binds sockets and spawns a PTY child"]
async fn the_shell_reaches_a_dialled_in_mac_through_its_loopback_url() {
Expand Down