feat(runtime-api): bind Engine terminals to their thread and an issued identity

CW-7669. Engine terminal routes addressed a live PTY by a reusable name
looked up against the server's root workspace, so a worktree thread's pane
reached the root's same-named shell, and two conversations using `term-1`
in one folder shared one shell (cwd, running command and output crossed
between them). Reconnect and restart semantics were undefined.

Engine owner (crates/engine/src/tools/terminal_session.rs):
- The registry key is (canonical workspace, owner, name); the owner is the
  tool context's state_namespace, which a runtime thread's engine sets to
  the thread id (runtime_threads/manager.rs session_id: thread.id), so each
  conversation gets its own shells; sub-agents share their parent's.
- The issued session_id is stored beside the handle in the registry, so an
  identity resolves without taking a session lock a blocked write holds.
  lookup(workspace, owner, id) replaces lookup(name, workspace).
- get_or_create no longer holds the registry mutex while it waits on an
  existing session's lock, so one wedged shell cannot stall every thread.
- write_human_input is the one human-input path for the TUI dock and HTTP:
  identity, sandbox-reuse guard, exited shell, and the confirmed ETX
  interrupt the dock already used. HumanInputError keeps the dock's strings
  and lets HTTP map statuses.
- terminal/reset metadata reports terminal_session_id and
  previous_session_id. Durable summaries hash the owner too.

Runtime API (crates/app-server):
- GET /v1/threads/{id}/terminals lists the thread's live shells with the
  registry instance; /v1/threads/{id}/terminals/{terminal_id}/
  output|input|resize|kill drive one by identity, mirroring the thread jobs
  routes. Every call re-resolves the thread's recorded workspace
  (thread_workspace split out of scoped_workspace). A reset, foreign-thread,
  foreign-workspace or pre-restart identity is 404 terminal_not_found;
  nothing substitutes a same-named shell. Input needs the thread's shell
  consent and loaded shell policy plus the sandbox-reuse guard against
  thread_job_sandbox_policy (the derivation a turn uses). Kill reports
  killed:false for an exited shell; output adds gap. Lookups and PTY work
  run on the blocking pool (previously open_session locked the registry
  inline on a runtime worker).
- /v1/terminal/{name}/* answers 410 terminal_route_retired on every
  platform. runtime/info advertises thread_scoped_terminals; the four
  terminal_stream/input/resize/kill flags leave the protocol
  RuntimeCapabilities (absent reads as false, so an older client hides
  Engine terminals instead of reaching the retired routes). A compatibility
  window was rejected: the old routes carry no thread, so answering them
  means choosing a shell by name, which is the defect. JSON schema, the
  TypeScript declaration and the SDK declaration are regenerated.
- docs/RUNTIME_API.md (and zh_hans) define reconnect, reset, exit, Engine
  restart, unconfirmed interrupt and uncertain-input semantics and the
  known limits, including that deleting a thread does not end its shells.

TUI dock: human terminals are created under the current session's owner;
resize resolves by identity through resize_dock_session.

Behavior change: in the TUI, a new conversation starts its own term-1
instead of reusing the previous conversation's shell (the dock still lists
both). The community VS Code extension's client calls no terminal route.

Ratchets: blocking-calls budget tightened for terminal_session.rs only
(path_canonicalize 3 -> 1, from the canonical() dedup); dead-code budget
unchanged at 137/137.

Evidence (local, -j6, hermetic test home; source at commit time):
- codewhale-app-server-schema --write: exit 0; the JSON schema and both
  TypeScript declarations change only by the capability swap.
- cargo test --locked --offline -j6 -p codewhale-app-server-protocol
  --features schema: lib 10 passed; tests/schema.rs 2 passed (includes
  committed_runtime_schema_and_sdk_exports_match_one_rust_contract);
  tests/wire.rs 3 passed; 0 failed.
- cargo test --locked --offline -j6 -p codewhale-engine -p
  codewhale-app-server --lib -- terminal: app-server 21 passed, 0 failed
  (the 4 terminal.rs unit tests, terminal_routes_serve_a_live_engine_
  session_over_http, terminal_output_for_an_unknown_session_is_not_found_
  and_creates_nothing, runtime_info_advertises_terminal_capabilities);
  engine 146 passed, 0 failed (all 25 tools::terminal_session tests,
  including session_names_are_scoped_to_owner_and_workspace and
  dock_never_attaches_to_a_reset_name_without_selecting_its_new_identity).
- cargo check --locked --offline -j6 -p codewhale-tui --lib: the engine
  compiled; the TUI crate stopped on one error outside this change
  (commands/groups/config/status.rs:325, another session's in-progress
  Accept Edits work), so the dock edit is type-checked but not
  borrow-checked. Its only borrow-relevant line was then reduced to the
  call shape that compiled before (owner computed first).
- rustfmt --check on each changed Rust file; check-blocking-calls-budget
  and check-dead-code-budget run.
Tests ran on the shared working tree, which also held other sessions'
uncommitted edits; this commit carries only this slice.

Not verified: no packaged build, no live desktop pane (needs the App
adoption and the 0.10.3 repin), Windows/ohos stubs not compiled here.

Refs CW-7669

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-11-07 13:58:32 -08:00
1 parent 13c91056ac
commit 9805a009e8
14 files changed
+865 -361

No files matched your search

@@ -299,6 +299,11 @@
"description": "`GET /v1/snapshots` and `POST /v1/snapshots/{id}/restore` accept\n`?thread_id=` and act on that thread's workspace.",
"default": false
},
"thread_scoped_terminals": {
"type": "boolean",
"description": "`GET /v1/threads/{id}/terminals` lists the thread's live shells, and\n`/v1/threads/{id}/terminals/{terminal_id}/output|input|resize|kill`\ndrive one by the identity the Engine issued it.",
"default": false
},
"worktrees": {
"type": "boolean",
"description": "`GET`/`POST /v1/worktrees` and `POST /v1/worktrees/remove`.",
@@ -437,26 +442,6 @@
"description": "Durable, workspace-scoped cross-task Agent Mail endpoints and events.",
"default": false
},
"terminal_stream": {
"type": "boolean",
"description": "`GET /v1/terminal/{name}/output` — the resumable byte stream over a\npersistent Engine-owned terminal session, with absolute cursors.",
"default": false
},
"terminal_input": {
"type": "boolean",
"description": "`POST /v1/terminal/{name}/input` — bytes into the live session.",
"default": false
},
"terminal_resize": {
"type": "boolean",
"description": "`POST /v1/terminal/{name}/resize` — the window the child draws for.",
"default": false
},
"terminal_kill": {
"type": "boolean",
"description": "`POST /v1/terminal/{name}/kill` — end the live session.",
"default": false
},
"event_stream_resume": {
"type": "boolean",
"description": "`GET /v1/threads/{id}/events` puts the durable `seq` on every journal\nframe as the SSE `id:` and resumes from a `Last-Event-ID` header, so a\nbrowser `EventSource` reconnects without a cursor in the query string.",
@@ -475,6 +460,7 @@
"thread_scoped_reads",
"thread_scoped_git_writes",
"thread_scoped_snapshots",
"thread_scoped_terminals",
"worktrees",
"job_operations",
"thread_shell_consent",
@@ -507,10 +493,6 @@
"skill_detail",
"plugin_management",
"agent_mail",
"terminal_stream",
"terminal_input",
"terminal_resize",
"terminal_kill",
"event_stream_resume"
],
"description": "Fixed capability map advertised by `GET /v1/runtime/info`.\n\nAll fields are required on serialization so clients can rely on the shape."
+1 -4
View File
@@ -64,6 +64,7 @@ export type RuntimeCapabilities = {
thread_scoped_reads: boolean
thread_scoped_git_writes: boolean
thread_scoped_snapshots: boolean
thread_scoped_terminals: boolean
worktrees: boolean
job_operations: boolean
thread_shell_consent: boolean
@@ -96,10 +97,6 @@ export type RuntimeCapabilities = {
skill_detail: boolean
plugin_management: boolean
agent_mail: boolean
terminal_stream: boolean
terminal_input: boolean
terminal_resize: boolean
terminal_kill: boolean
event_stream_resume: boolean
[key: string]: Json | undefined
}
+6 -17
View File
@@ -120,6 +120,11 @@ pub struct RuntimeCapabilities {
/// `?thread_id=` and act on that thread's workspace.
#[serde(default)]
pub thread_scoped_snapshots: bool,
/// `GET /v1/threads/{id}/terminals` lists the thread's live shells, and
/// `/v1/threads/{id}/terminals/{terminal_id}/output|input|resize|kill`
/// drive one by the identity the Engine issued it.
#[serde(default)]
pub thread_scoped_terminals: bool,
/// `GET`/`POST /v1/worktrees` and `POST /v1/worktrees/remove`.
#[serde(default)]
pub worktrees: bool,
@@ -210,19 +215,6 @@ pub struct RuntimeCapabilities {
/// Durable, workspace-scoped cross-task Agent Mail endpoints and events.
#[serde(default)]
pub agent_mail: bool,
/// `GET /v1/terminal/{name}/output` — the resumable byte stream over a
/// persistent Engine-owned terminal session, with absolute cursors.
#[serde(default)]
pub terminal_stream: bool,
/// `POST /v1/terminal/{name}/input` — bytes into the live session.
#[serde(default)]
pub terminal_input: bool,
/// `POST /v1/terminal/{name}/resize` — the window the child draws for.
#[serde(default)]
pub terminal_resize: bool,
/// `POST /v1/terminal/{name}/kill` — end the live session.
#[serde(default)]
pub terminal_kill: bool,
/// `GET /v1/threads/{id}/events` puts the durable `seq` on every journal
/// frame as the SSE `id:` and resumes from a `Last-Event-ID` header, so a
/// browser `EventSource` reconnects without a cursor in the query string.
@@ -538,6 +530,7 @@ mod tests {
thread_scoped_reads: true,
thread_scoped_git_writes: true,
thread_scoped_snapshots: true,
thread_scoped_terminals: true,
worktrees: true,
job_operations: true,
thread_shell_consent: true,
@@ -568,10 +561,6 @@ mod tests {
skill_detail: false,
plugin_management: false,
agent_mail: true,
terminal_stream: false,
terminal_input: false,
terminal_resize: false,
terminal_kill: false,
event_stream_resume: true,
};
let value = serde_json::to_value(&caps).unwrap();
+31 -16
View File
@@ -19,7 +19,7 @@ use axum::middleware;
use axum::response::Html;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::routing::{any, delete, get, post, put};
use axum::{Json, Router};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -1578,6 +1578,11 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
thread_scoped_reads: true,
thread_scoped_git_writes: true,
thread_scoped_snapshots: true,
// The terminal family follows the routes' own gate: the owner is
// `#[cfg(unix)]` end to end, and the Windows and OpenHarmony builds
// answer 501. A client must be able to feature-detect that before it
// offers a pane, so the flag must never outrun the handler.
thread_scoped_terminals: cfg!(all(unix, not(target_env = "ohos"))),
worktrees: true,
job_operations: true,
thread_shell_consent: true,
@@ -1613,14 +1618,6 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
// SSE journal frames carry their durable `seq` as the event id, and the
// thread event stream resumes from `Last-Event-ID`.
event_stream_resume: true,
// The terminal family follows the routes' own gate: the owner is
// `#[cfg(unix)]` end to end, and the Windows and OpenHarmony builds
// answer 501. A client must be able to feature-detect that before it
// offers a pane, so the flag must never outrun the handler.
terminal_stream: cfg!(all(unix, not(target_env = "ohos"))),
terminal_input: cfg!(all(unix, not(target_env = "ohos"))),
terminal_resize: cfg!(all(unix, not(target_env = "ohos"))),
terminal_kill: cfg!(all(unix, not(target_env = "ohos"))),
}
}
@@ -2906,15 +2903,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
// The only writer a GUI has for workspace trust. The engine owns
// `config.toml`; a client asks here instead of editing the file.
.route("/v1/workspace/trust", post(update_workspace_trust))
// The Engine's terminal byte stream (#34). Auth is the route layer's,
// not this module's; these never create a session — see terminal.rs.
.route("/v1/terminal/{name}/output", get(terminal::terminal_output))
.route("/v1/terminal/{name}/input", post(terminal::terminal_input))
.route(
"/v1/terminal/{name}/resize",
post(terminal::terminal_resize),
"/v1/terminal/{*rest}",
any(terminal::retired_terminal_route),
)
.route("/v1/terminal/{name}/kill", post(terminal::terminal_kill))
.route("/v1/workspace/files/search", get(workspace_file_search))
.route(
"/v1/workspace/files",
@@ -3069,6 +3061,29 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/threads/{id}/jobs/{job_id}/resize",
post(jobs::resize_thread_job),
)
// The Engine's terminal byte stream (#34), addressed by owning thread
// and issued identity. Auth is the route layer's, not this module's;
// these never create a session — see terminal.rs.
.route(
"/v1/threads/{id}/terminals",
get(terminal::list_thread_terminals),
)
.route(
"/v1/threads/{id}/terminals/{terminal_id}/output",
get(terminal::terminal_output),
)
.route(
"/v1/threads/{id}/terminals/{terminal_id}/input",
post(terminal::terminal_input),
)
.route(
"/v1/threads/{id}/terminals/{terminal_id}/resize",
post(terminal::terminal_resize),
)
.route(
"/v1/threads/{id}/terminals/{terminal_id}/kill",
post(terminal::terminal_kill),
)
.route("/v1/threads/{id}/context", get(context::get_thread_context))
.route(
"/v1/threads/{id}/members",
+230 -69
View File
@@ -1,11 +1,18 @@
//! `/v1/terminal/{name}` — the Engine's terminal byte stream.
//! `/v1/threads/{id}/terminals` — the Engine's terminal byte stream.
//!
//! The owner is [`codewhale_engine::tools::terminal_session`]: the same PTY-backed shell
//! the agent's terminal tools drive, so a client attaching here sees the
//! session the model is already working in rather than a second shell beside
//! it. These routes never create a session — a name with no live session is a
//! 404, because conjuring a shell from an HTTP request would give the app a
//! terminal the Engine does not know about.
//! it. These routes never create a session — an identity with no live session
//! is a 404, because conjuring a shell from an HTTP request would give the app
//! a terminal the Engine does not know about.
//!
//! A terminal is addressed by the thread that owns it and the identity the
//! Engine issued when the shell started (`terminal_id`). Every call resolves
//! the thread's recorded workspace again and answers only that thread's shells
//! in it, so another thread's or workspace's shell with the same name is never
//! reached. Reset and restart issue new identities; an old identity stays a
//! 404 instead of attaching to its replacement.
//!
//! Authentication is the `/v1` route layer's bearer token (see
//! [`super::auth`]); nothing here re-implements or bypasses it.
@@ -24,11 +31,17 @@
//! - **No scrollback recovery.** `dropped` reports what the 512 KiB ring
//! discarded; those bytes are gone with the process, not on disk.
//! - **Live sessions only.** Persistence is identity and lifecycle, never
//! output, so a restarted Engine reports no session rather than pretending to
//! reattach (#34 acceptance: "Restart truthfully reports lost live PTYs").
//! output, so a restarted Engine reports a new `instance` and none of the
//! old identities rather than pretending to reattach (#34 acceptance:
//! "Restart truthfully reports lost live PTYs").
//! - **Thread-owned shells only.** A shell started outside a runtime thread
//! (the TUI dock, a headless run) has no route here.
//! - **Unix only.** The owner is `#[cfg(all(unix, not(target_env = "ohos")))]` end to end; on Windows these
//! routes do not exist yet. ConPTY qualification is its own slice.
#[cfg(all(unix, not(target_env = "ohos")))]
use std::path::PathBuf;
use axum::Json;
use axum::extract::{Path, Query, State};
#[cfg(all(unix, not(target_env = "ohos")))]
@@ -40,14 +53,16 @@ use serde::{Deserialize, Serialize};
#[cfg(all(unix, not(target_env = "ohos")))]
use codewhale_engine::tools::terminal_session;
#[cfg(all(unix, not(target_env = "ohos")))]
use super::workspace::thread_workspace;
use super::{ApiError, RuntimeApiState};
/// Default per-response ceiling; the owner clamps to its own `READ_LIMIT`.
#[cfg(all(unix, not(target_env = "ohos")))]
const TERMINAL_CHUNK_DEFAULT: usize = 64 * 1024;
/// Session names come from the agent's tools; this only bounds the echo.
/// Identities are issued UUIDs; this only bounds the lookup.
#[cfg(all(unix, not(target_env = "ohos")))]
const TERMINAL_NAME_MAX_BYTES: usize = 128;
const TERMINAL_ID_MAX_BYTES: usize = 64;
/// One input frame. Interactive typing is bytes, not uploads.
#[cfg(all(unix, not(target_env = "ohos")))]
const TERMINAL_INPUT_MAX_BYTES: usize = 64 * 1024;
@@ -72,8 +87,26 @@ pub(super) struct TerminalOutputQuery {
}
#[derive(Debug, Serialize)]
pub(super) struct TerminalOutputResponse {
pub(super) struct TerminalView {
terminal_id: String,
name: String,
state: String,
running: bool,
exit_code: Option<u32>,
updated_at: String,
}
#[derive(Debug, Serialize)]
pub(super) struct TerminalListResponse {
thread_id: String,
workspace: std::path::PathBuf,
instance: String,
terminals: Vec<TerminalView>,
}
#[derive(Debug, Serialize)]
pub(super) struct TerminalOutputResponse {
terminal_id: String,
/// Absolute offset of `data[0]`; exceeds `cursor` when the ring already
/// discarded that prefix (`dropped` reports the cutoff).
offset: u64,
@@ -83,6 +116,7 @@ pub(super) struct TerminalOutputResponse {
total: u64,
/// Leading bytes the bounded ring permanently discarded.
dropped: u64,
gap: bool,
encoding: &'static str,
data: String,
/// False once the shell has exited and no bytes remain past `next_cursor`.
@@ -102,7 +136,7 @@ pub(super) struct TerminalInputRequest {
#[derive(Debug, Serialize)]
pub(super) struct TerminalWriteResponse {
name: String,
terminal_id: String,
written: usize,
}
@@ -116,14 +150,14 @@ pub(super) struct TerminalResizeRequest {
#[derive(Debug, Serialize)]
pub(super) struct TerminalResizeResponse {
name: String,
terminal_id: String,
rows: u16,
cols: u16,
}
#[derive(Debug, Serialize)]
pub(super) struct TerminalKillResponse {
name: String,
terminal_id: String,
killed: bool,
}
@@ -185,28 +219,55 @@ fn bounded_dimension(value: u16, field: &str) -> Result<u16, ApiError> {
Ok(value)
}
/// Resolve a live session or 404. Never creates one — see the module docs.
#[cfg(all(unix, not(target_env = "ohos")))]
fn open_session(
state: &RuntimeApiState,
name: &str,
) -> Result<terminal_session::SharedSession, ApiError> {
if name.is_empty() || name.len() > TERMINAL_NAME_MAX_BYTES {
return Err(ApiError::not_found("terminal session not found"));
fn terminal_not_found() -> ApiError {
ApiError::not_found("no live terminal with this identity in this thread")
.with_code("terminal_not_found")
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn lock(
session: &terminal_session::SharedSession,
) -> Result<std::sync::MutexGuard<'_, terminal_session::TerminalSession>, ApiError> {
session
.lock()
.map_err(|_| ApiError::internal("terminal session lock poisoned"))
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn state_label(state: terminal_session::DurableTerminalState) -> String {
serde_json::to_value(state)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_default()
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn input_error(error: terminal_session::HumanInputError) -> ApiError {
use terminal_session::HumanInputError;
match &error {
HumanInputError::Gone => terminal_not_found(),
HumanInputError::Posture => {
ApiError::forbidden(error.to_string()).with_code("terminal_posture_changed")
}
HumanInputError::Exited => {
ApiError::conflict(error.to_string()).with_code("terminal_exited")
}
HumanInputError::Rejected(message) => ApiError::bad_request(message.clone()),
HumanInputError::Failed(message) => ApiError::internal(message.clone()),
}
terminal_session::lookup(name, &state.workspace)
.ok_or_else(|| ApiError::not_found(format!("no live terminal session named '{name}'")))
}
/// How many terminal route calls may occupy blocking threads at once. The
/// rest wait in `with_session` asynchronously, so a client that disconnects
/// rest wait in `with_terminal` asynchronously, so a client that disconnects
/// while queued simply disappears instead of holding a pool thread, and one
/// stuck write (a child that stopped reading, lock held) can stall terminal
/// routes but never the runtime's other blocking work.
#[cfg(all(unix, not(target_env = "ohos")))]
static ROUTE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(8);
/// Run one operation against the locked session on the blocking pool.
/// Resolve one of the thread's live shells by identity and run one operation
/// against it on the blocking pool.
///
/// The session mutex and the PTY behind it are synchronous: the agent's own
/// tool holds the lock across a whole command, and a write to a child that
@@ -214,58 +275,102 @@ static ROUTE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(8)
/// runtime worker (#6149), so a route never touches the session inline, and
/// `ROUTE_GATE` bounds how many such touches can be in flight.
#[cfg(all(unix, not(target_env = "ohos")))]
async fn with_session<T>(
session: terminal_session::SharedSession,
operation: impl FnOnce(&mut terminal_session::TerminalSession) -> Result<T, ApiError>
+ Send
+ 'static,
async fn with_terminal<T>(
workspace: PathBuf,
thread_id: String,
terminal_id: String,
operation: impl FnOnce(&terminal_session::SharedSession) -> Result<T, ApiError> + Send + 'static,
) -> Result<T, ApiError>
where
T: Send + 'static,
{
if terminal_id.is_empty() || terminal_id.len() > TERMINAL_ID_MAX_BYTES {
return Err(terminal_not_found());
}
let permit = ROUTE_GATE
.acquire()
.await
.map_err(|_| ApiError::internal("terminal route gate closed"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
let mut guard = session
.lock()
.map_err(|_| ApiError::internal("terminal session lock poisoned"))?;
operation(&mut guard)
let session = terminal_session::lookup(&workspace, Some(&thread_id), &terminal_id)
.ok_or_else(terminal_not_found)?;
operation(&session)
})
.await
.map_err(|error| ApiError::internal(error.to_string()))?
}
/// `GET /v1/terminal/{name}/output` — the resumable byte stream.
/// `GET /v1/threads/{id}/terminals` — the thread's live shells, newest
/// activity first, with the registry `instance` they belong to.
#[cfg(all(unix, not(target_env = "ohos")))]
pub(super) async fn list_thread_terminals(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
) -> Result<Json<TerminalListResponse>, ApiError> {
let (_, workspace) = thread_workspace(&state, &thread_id).await?;
let permit = ROUTE_GATE
.acquire()
.await
.map_err(|_| ApiError::internal("terminal route gate closed"))?;
let scope = workspace.clone();
let owner = thread_id.clone();
let sessions = tokio::task::spawn_blocking(move || {
let _permit = permit;
terminal_session::owned_sessions(&scope, &owner).map_err(ApiError::internal)
})
.await
.map_err(|error| ApiError::internal(error.to_string()))??;
Ok(Json(TerminalListResponse {
thread_id,
workspace,
instance: terminal_session::instance_id().to_string(),
terminals: sessions
.into_iter()
.map(|info| TerminalView {
running: info.exit_code.is_none(),
terminal_id: info.id,
name: info.name,
state: state_label(info.state),
exit_code: info.exit_code,
updated_at: info.updated_at,
})
.collect(),
}))
}
/// `GET /v1/threads/{id}/terminals/{terminal_id}/output` — the resumable byte
/// stream.
///
/// Reads are non-consuming: several clients may hold independent cursors, and
/// polling here never steals output from the agent's own consuming read.
#[cfg(all(unix, not(target_env = "ohos")))]
pub(super) async fn terminal_output(
State(state): State<RuntimeApiState>,
Path(name): Path<String>,
Path((thread_id, terminal_id)): Path<(String, String)>,
Query(query): Query<TerminalOutputQuery>,
) -> Result<Json<TerminalOutputResponse>, ApiError> {
let session = open_session(&state, &name)?;
let encoding = chunk_encoding(query.format.as_deref().unwrap_or("base64"))?;
let max_bytes = bounded_max_bytes(query.max_bytes)?;
let cursor = query.cursor.unwrap_or(0);
let (chunk, exit) = with_session(session, move |session| {
let chunk = terminal_session::read_session_since(session, cursor, max_bytes)
let (_, workspace) = thread_workspace(&state, &thread_id).await?;
let (chunk, exit) = with_terminal(workspace, thread_id, terminal_id.clone(), move |session| {
let mut session = lock(session)?;
let chunk = terminal_session::read_session_since(&session, cursor, max_bytes)
.map_err(ApiError::internal)?;
let exit = terminal_session::session_exit_status(session).map_err(ApiError::internal)?;
let exit =
terminal_session::session_exit_status(&mut session).map_err(ApiError::internal)?;
Ok((chunk, exit))
})
.await?;
let running = exit.is_none();
Ok(Json(TerminalOutputResponse {
name,
terminal_id,
offset: chunk.offset,
next_cursor: chunk.next_cursor,
total: chunk.total,
dropped: chunk.dropped,
gap: chunk.gap,
encoding,
data: encode_bytes(&chunk.bytes, encoding),
// A gap means bytes were lost; `running` alone must not imply there is
@@ -275,7 +380,9 @@ pub(super) async fn terminal_output(
}))
}
/// `POST /v1/terminal/{name}/input` — bytes into the live shell.
/// `POST /v1/threads/{id}/terminals/{terminal_id}/input` — bytes into the live
/// shell. The thread's shell consent and shell policy apply, and so does the
/// sandbox-reuse guard the TUI dock applies to a person's input.
///
/// Input attribution is the caller's: this route is the client's writer, and
/// the agent's writer is `terminal_send`. Nothing here re-labels one as the
@@ -283,61 +390,106 @@ pub(super) async fn terminal_output(
#[cfg(all(unix, not(target_env = "ohos")))]
pub(super) async fn terminal_input(
State(state): State<RuntimeApiState>,
Path(name): Path<String>,
Path((thread_id, terminal_id)): Path<(String, String)>,
Json(request): Json<TerminalInputRequest>,
) -> Result<Json<TerminalWriteResponse>, ApiError> {
let session = open_session(&state, &name)?;
let bytes = decode_bytes(
&request.data,
request.encoding.as_deref().unwrap_or("base64"),
)?;
let (thread, workspace) = thread_workspace(&state, &thread_id).await?;
if !thread.allow_shell {
return Err(
ApiError::forbidden("this thread does not allow shell commands")
.with_code("thread_shell_disabled"),
);
}
state
.runtime_threads
.validate_shell_access_policy(
&workspace,
state.config_path.as_deref(),
state.config_profile.as_deref(),
)
.await
.map_err(|error| ApiError::forbidden(error.to_string()))?;
let policy = state
.runtime_threads
.thread_job_sandbox_policy(&thread)
.await;
let written = bytes.len();
with_session(session, move |session| {
terminal_session::write_raw_input(session, &bytes).map_err(|error| {
if error.kind() == std::io::ErrorKind::InvalidInput {
ApiError::bad_request(error.to_string())
} else {
ApiError::internal(error.to_string())
}
})
with_terminal(workspace, thread_id, terminal_id.clone(), move |session| {
terminal_session::write_human_input(session, &bytes, &policy).map_err(input_error)
})
.await?;
Ok(Json(TerminalWriteResponse { name, written }))
Ok(Json(TerminalWriteResponse {
terminal_id,
written,
}))
}
/// `POST /v1/terminal/{name}/resize` — the window the child should draw for.
/// `POST /v1/threads/{id}/terminals/{terminal_id}/resize` — the window the
/// child should draw for.
#[cfg(all(unix, not(target_env = "ohos")))]
pub(super) async fn terminal_resize(
State(state): State<RuntimeApiState>,
Path(name): Path<String>,
Path((thread_id, terminal_id)): Path<(String, String)>,
Json(request): Json<TerminalResizeRequest>,
) -> Result<Json<TerminalResizeResponse>, ApiError> {
let session = open_session(&state, &name)?;
let rows = bounded_dimension(request.rows, "rows")?;
let cols = bounded_dimension(request.cols, "cols")?;
with_session(session, move |session| {
terminal_session::resize_session(session, rows, cols).map_err(ApiError::internal)
let (_, workspace) = thread_workspace(&state, &thread_id).await?;
with_terminal(workspace, thread_id, terminal_id.clone(), move |session| {
let session = lock(session)?;
terminal_session::resize_session(&session, rows, cols).map_err(ApiError::internal)
})
.await?;
Ok(Json(TerminalResizeResponse { name, rows, cols }))
Ok(Json(TerminalResizeResponse {
terminal_id,
rows,
cols,
}))
}
/// `POST /v1/terminal/{name}/kill` — end the shell.
/// `POST /v1/threads/{id}/terminals/{terminal_id}/kill` — end the shell.
///
/// The exit itself is observed through `output` (`running` / `exit_code`),
/// so a client that kills and then polls learns the truth instead of an
/// optimistic acknowledgement.
/// optimistic acknowledgement. `killed` is false when the shell had already
/// exited.
#[cfg(all(unix, not(target_env = "ohos")))]
pub(super) async fn terminal_kill(
State(state): State<RuntimeApiState>,
Path(name): Path<String>,
Path((thread_id, terminal_id)): Path<(String, String)>,
) -> Result<Json<TerminalKillResponse>, ApiError> {
let session = open_session(&state, &name)?;
with_session(session, |session| {
terminal_session::kill_session(session).map_err(ApiError::internal)
let (_, workspace) = thread_workspace(&state, &thread_id).await?;
let killed = with_terminal(workspace, thread_id, terminal_id.clone(), |session| {
let mut session = lock(session)?;
if terminal_session::session_exit_status(&mut session)
.map_err(ApiError::internal)?
.is_some()
{
return Ok(false);
}
terminal_session::kill_session(&mut session).map_err(ApiError::internal)?;
Ok(true)
})
.await?;
Ok(Json(TerminalKillResponse { name, killed: true }))
Ok(Json(TerminalKillResponse {
terminal_id,
killed,
}))
}
/// `/v1/terminal/{name}/*` addressed a shell by a reusable name at the
/// server's workspace, which could reach another thread's or workspace's
/// shell. It answers 410 on every platform so a client built for it fails
/// loudly instead of attaching to the wrong shell.
pub(super) async fn retired_terminal_route() -> ApiError {
ApiError::gone(
"name-addressed terminal routes were retired; list GET /v1/threads/{id}/terminals and address a terminal by its terminal_id",
)
.with_code("terminal_route_retired")
}
/// Windows build: the owner is `#[cfg(all(unix, not(target_env = "ohos")))]` end to end, so the contract
@@ -354,9 +506,16 @@ mod platform {
)
}
pub(crate) async fn terminal_output(
pub(crate) async fn list_thread_terminals(
State(_): State<RuntimeApiState>,
Path(_): Path<String>,
) -> Result<Json<TerminalListResponse>, ApiError> {
Err(unsupported())
}
pub(crate) async fn terminal_output(
State(_): State<RuntimeApiState>,
Path(_): Path<(String, String)>,
Query(_): Query<TerminalOutputQuery>,
) -> Result<Json<TerminalOutputResponse>, ApiError> {
Err(unsupported())
@@ -364,7 +523,7 @@ mod platform {
pub(crate) async fn terminal_input(
State(_): State<RuntimeApiState>,
Path(_): Path<String>,
Path(_): Path<(String, String)>,
Json(_): Json<TerminalInputRequest>,
) -> Result<Json<TerminalWriteResponse>, ApiError> {
Err(unsupported())
@@ -372,7 +531,7 @@ mod platform {
pub(crate) async fn terminal_resize(
State(_): State<RuntimeApiState>,
Path(_): Path<String>,
Path(_): Path<(String, String)>,
Json(_): Json<TerminalResizeRequest>,
) -> Result<Json<TerminalResizeResponse>, ApiError> {
Err(unsupported())
@@ -380,14 +539,16 @@ mod platform {
pub(crate) async fn terminal_kill(
State(_): State<RuntimeApiState>,
Path(_): Path<String>,
Path(_): Path<(String, String)>,
) -> Result<Json<TerminalKillResponse>, ApiError> {
Err(unsupported())
}
}
#[cfg(any(not(unix), target_env = "ohos"))]
pub(super) use platform::{terminal_input, terminal_kill, terminal_output, terminal_resize};
pub(super) use platform::{
list_thread_terminals, terminal_input, terminal_kill, terminal_output, terminal_resize,
};
#[cfg(all(test, unix, not(target_env = "ohos")))]
mod tests {
+108 -33
View File
@@ -18928,16 +18928,19 @@ async fn runtime_info_advertises_terminal_capabilities() -> Result<()> {
// — the flag must not claim a capability the build cannot serve, so assert
// the routes' own gate rather than `true`.
let expected = cfg!(all(unix, not(target_env = "ohos")));
for capability in [
assert_eq!(
info["capabilities"]["thread_scoped_terminals"], expected,
"runtime/info must advertise thread_scoped_terminals={expected}"
);
// The name-addressed family is retired; a client gating on its flags
// must see no terminal rather than reach the retired routes.
for retired in [
"terminal_stream",
"terminal_input",
"terminal_resize",
"terminal_kill",
] {
assert_eq!(
info["capabilities"][capability], expected,
"runtime/info must advertise {capability}={expected}"
);
assert!(info["capabilities"].get(retired).is_none(), "{retired}");
}
handle.abort();
@@ -18952,31 +18955,77 @@ async fn terminal_routes_serve_a_live_engine_session_over_http() -> Result<()> {
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
fs::create_dir_all(&workspace)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
)
.await?
let Some((addr, runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
)
.await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/terminal/pane");
let mut threads = Vec::new();
for _ in 0..2 {
threads.push(
runtime_threads
.create_thread(
codewhale_engine::runtime_threads::manager::CreateThreadRequest {
workspace: Some(workspace.clone()),
permission_posture: Some("full_access".to_string()),
allow_shell: Some(true),
..Default::default()
},
)
.await?,
);
}
let (thread, other) = (&threads[0], &threads[1]);
// The agent's terminal tools own session creation; stand in for that
// producer on the same workspace the server was started with, which is
// what makes this the Engine's own shell rather than a second one.
let _session = codewhale_engine::tools::terminal_session::get_or_create(
"pane",
&workspace,
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&codewhale_engine::tools::shell::new_shared_shell_manager(workspace.clone()),
)
.map_err(anyhow::Error::msg)?;
// producer under each thread's own id, workspace and turn policy, which is
// what makes these the Engine's own shells rather than second ones. Both
// conversations say `pane` in one folder and still get separate shells.
let shell_manager = codewhale_engine::tools::shell::new_shared_shell_manager(workspace.clone());
for owner in [thread, other] {
codewhale_engine::tools::terminal_session::get_or_create(
&owner.id,
"pane",
&workspace,
runtime_threads.thread_job_sandbox_policy(owner).await,
&shell_manager,
)
.map_err(anyhow::Error::msg)?;
}
let listed: serde_json::Value = client
.get(format!("http://{addr}/v1/threads/{}/terminals", thread.id))
.send()
.await?
.error_for_status()?
.json()
.await?;
let terminals = listed["terminals"].as_array().expect("terminal list");
assert_eq!(terminals.len(), 1, "{listed}");
assert_eq!(terminals[0]["name"], "pane");
assert_eq!(terminals[0]["running"], true);
let terminal_id = terminals[0]["terminal_id"]
.as_str()
.expect("terminal identity")
.to_string();
let base = format!(
"http://{addr}/v1/threads/{}/terminals/{terminal_id}",
thread.id
);
let foreign = client
.get(format!(
"http://{addr}/v1/threads/{}/terminals/{terminal_id}/output",
other.id
))
.send()
.await?;
assert_eq!(foreign.status(), reqwest::StatusCode::NOT_FOUND);
// Input and command output through the route, without direct session
// access. Start output on its own line even if the shell paints a prompt.
@@ -19093,29 +19142,55 @@ async fn terminal_routes_serve_a_live_engine_session_over_http() -> Result<()> {
#[tokio::test]
#[cfg(unix)]
async fn terminal_output_for_an_unknown_session_is_not_found_and_creates_nothing() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/terminal");
let workspace = tempfile::tempdir()?;
let thread = runtime_threads
.create_thread(
codewhale_engine::runtime_threads::manager::CreateThreadRequest {
workspace: Some(workspace.path().to_path_buf()),
..Default::default()
},
)
.await?;
let base = format!("http://{addr}/v1/threads/{}/terminals", thread.id);
// The route exists and answers for a name that has no live session: the
// Engine attaches to shells it owns, it does not conjure one per request.
// The route exists and answers for an identity that has no live session:
// the Engine attaches to shells it owns, it does not conjure one per request.
let missing = client
.get(format!("{base}/no-such-session/output"))
.get(format!("{base}/{}/output", Uuid::new_v4()))
.send()
.await?;
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
let body: serde_json::Value = missing.json().await?;
assert_eq!(body["error"]["code"], "terminal_not_found");
// An over-long name is rejected as a miss too, so the registry is never
// asked to allocate for it.
let long_name = "n".repeat(200);
// An over-long identity is rejected as a miss too, so the registry is
// never searched for it.
let long_id = "n".repeat(200);
let oversized = client
.get(format!("{base}/{long_name}/output"))
.get(format!("{base}/{long_id}/output"))
.send()
.await?;
assert_eq!(oversized.status(), reqwest::StatusCode::NOT_FOUND);
let unknown_thread = client
.get(format!("http://{addr}/v1/threads/no-such-thread/terminals"))
.send()
.await?;
assert_eq!(unknown_thread.status(), reqwest::StatusCode::NOT_FOUND);
// A client built for the retired name-addressed routes fails loudly.
let retired = client
.get(format!("http://{addr}/v1/terminal/term-1/output"))
.send()
.await?;
assert_eq!(retired.status(), reqwest::StatusCode::GONE);
let body: serde_json::Value = retired.json().await?;
assert_eq!(body["error"]["code"], "terminal_route_retired");
handle.abort();
Ok(())
}
+13 -3
View File
@@ -26,6 +26,15 @@ pub(super) async fn scoped_workspace(
let Some(thread_id) = thread_id else {
return Ok(state.workspace.clone());
};
thread_workspace(state, thread_id)
.await
.map(|(_, workspace)| workspace)
}
pub(super) async fn thread_workspace(
state: &RuntimeApiState,
thread_id: &str,
) -> Result<(codewhale_engine::runtime_threads::ThreadRecord, PathBuf), ApiError> {
if thread_id.trim().is_empty() {
return Err(ApiError::bad_request("thread_id must not be empty"));
}
@@ -34,14 +43,14 @@ pub(super) async fn scoped_workspace(
.get_thread(thread_id)
.await
.map_err(map_thread_err)?;
let recorded = thread.workspace;
let recorded = thread.workspace.clone();
if !recorded.is_absolute() {
return Err(
ApiError::conflict("thread workspace is not an absolute path")
.with_code("thread_workspace_unavailable"),
);
}
tokio::task::spawn_blocking(move || {
let workspace = tokio::task::spawn_blocking(move || {
codewhale_util::workspace_dir::open_workspace_directory(&recorded)
.map(|(canonical, _directory)| canonical)
.map_err(|_| {
@@ -50,7 +59,8 @@ pub(super) async fn scoped_workspace(
})
})
.await
.map_err(|_| ApiError::internal("thread workspace resolution failed"))?
.map_err(|_| ApiError::internal("thread workspace resolution failed"))??;
Ok((thread, workspace))
}
#[derive(Deserialize)]
+304 -127
View File
@@ -4,6 +4,17 @@
//! durable summary records identity, last-known cwd, lifecycle state, and
//! replacement history. A later process reports the shell as stale/lost and
//! starts a new identity; it never claims to reattach from a reused PID.
//!
//! A session belongs to one owner (the tool context's `state_namespace`: a
//! runtime thread's id, or the TUI session's) in one canonical workspace, so
//! two conversations that both say `term-1` get two shells. Each live shell
//! carries an identity (`session_id`) issued at creation and never reused;
//! reset and restart issue a new one.
//!
//! Known limitations: the dock lists every owner's shells in its workspace;
//! deleting a thread does not end its shells, which live until the process
//! exits; and a workspace directory replaced at the same path does not retire
//! shells already running there.
#[cfg(unix)]
use std::collections::{HashMap, VecDeque};
@@ -129,9 +140,9 @@ struct OutputBuffer {
/// lets a client resume from a cursor it stored earlier, and what lets this
/// type tell it the truth when the retained window has moved past it.
///
/// Field names match the `/v1/terminal/{name}/output` wire so the payload is a
/// projection, not a translation (the same vocabulary the jobs byte stream
/// uses).
/// Field names match the `/v1/threads/{id}/terminals/{terminal_id}/output`
/// wire so the payload is a projection, not a translation (the same
/// vocabulary the jobs byte stream uses).
#[cfg(unix)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputChunk {
@@ -203,11 +214,34 @@ pub type SharedSession = Arc<Mutex<TerminalSession>>;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct SessionKey {
workspace: PathBuf,
owner: String,
name: String,
}
#[cfg(unix)]
static SESSIONS: OnceLock<Mutex<HashMap<SessionKey, SharedSession>>> = OnceLock::new();
struct Registered {
id: String,
session: SharedSession,
}
#[cfg(unix)]
impl Registered {
fn new(session: &SharedSession) -> Result<Self, String> {
let id = session
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?
.durable
.session_id
.clone();
Ok(Self {
id,
session: Arc::clone(session),
})
}
}
#[cfg(unix)]
static SESSIONS: OnceLock<Mutex<HashMap<SessionKey, Registered>>> = OnceLock::new();
#[cfg(unix)]
static RUNTIME_NONCE: OnceLock<String> = OnceLock::new();
@@ -218,28 +252,39 @@ fn runtime_nonce() -> &'static str {
}
#[cfg(unix)]
fn sessions() -> &'static Mutex<HashMap<SessionKey, SharedSession>> {
pub fn instance_id() -> &'static str {
runtime_nonce()
}
#[cfg(unix)]
fn sessions() -> &'static Mutex<HashMap<SessionKey, Registered>> {
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(unix)]
fn session_key(name: &str, workspace: &Path) -> SessionKey {
fn canonical(workspace: &Path) -> PathBuf {
workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf())
}
#[cfg(unix)]
fn session_key(owner: &str, name: &str, workspace: &Path) -> SessionKey {
SessionKey {
workspace: workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf()),
workspace: canonical(workspace),
owner: owner.to_string(),
name: name.to_string(),
}
}
#[cfg(unix)]
fn durable_path(name: &str, workspace: &Path) -> Result<PathBuf, String> {
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
fn durable_path(owner: &str, name: &str, workspace: &Path) -> Result<PathBuf, String> {
let workspace = canonical(workspace);
let mut hasher = Sha256::new();
hasher.update(workspace.to_string_lossy().as_bytes());
hasher.update(b"\0");
hasher.update(owner.as_bytes());
hasher.update(b"\0");
hasher.update(name.as_bytes());
let digest = hasher
.finalize()
@@ -322,6 +367,7 @@ fn prepare_pty_shell(
#[cfg(unix)]
fn create_session(
owner: &str,
name: &str,
workspace: &std::path::Path,
policy: codewhale_sandbox::SandboxPolicy,
@@ -334,10 +380,8 @@ fn create_session(
let shell = "/bin/sh".to_string();
#[cfg(not(any(test, feature = "test-support")))]
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let durable_path = durable_path(name, &workspace)?;
let workspace = canonical(workspace);
let durable_path = durable_path(owner, name, &workspace)?;
let previous = load_durable(&durable_path).map(|mut record| {
// A persisted shell is historical evidence only. A random per-process
// nonce makes PID reuse irrelevant and deliberately forbids reattach.
@@ -425,32 +469,49 @@ fn create_session(
#[cfg(unix)]
pub fn get_or_create(
owner: &str,
name: &str,
workspace: &std::path::Path,
policy: codewhale_sandbox::SandboxPolicy,
shell_manager: &crate::tools::shell::SharedShellManager,
) -> Result<SharedSession, String> {
let key = session_key(name, workspace);
let key = session_key(owner, name, workspace);
let mut registry = sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?;
if let Some(session) = registry.get(&key) {
let session_guard = session
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
if !can_reuse_under_policy(&session_guard, &policy) {
let existing = registry.get(&key).map(|entry| Arc::clone(&entry.session));
if let Some(session) = existing {
drop(registry);
let reusable = {
let guard = session
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
can_reuse_under_policy(&guard, &policy)
};
if !reusable {
return Err(
"existing PTY session does not enforce the current sandbox policy; start a new session name under the current posture or use Full Access"
.to_string(),
);
}
return Ok(Arc::clone(session));
return Ok(session);
}
let session = create_session(name, workspace, policy, shell_manager)?;
registry.insert(key, Arc::clone(&session));
let session = create_session(owner, name, workspace, policy, shell_manager)?;
registry.insert(key, Registered::new(&session)?);
Ok(session)
}
#[cfg(unix)]
fn replace_registered(key: SessionKey, session: &SharedSession) -> Result<String, String> {
let entry = Registered::new(session)?;
let id = entry.id.clone();
sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?
.insert(key, entry);
Ok(id)
}
/// Restricted reuse requires the policy that actually created the process.
/// Exact comparison is deliberately conservative: widening a restricted policy
/// may require a new name too, but never weakens a running process's boundary.
@@ -463,16 +524,16 @@ fn can_reuse_under_policy(
}
#[cfg(unix)]
fn find(name: &str, workspace: &Path) -> Result<SharedSession, String> {
fn find(owner: &str, name: &str, workspace: &Path) -> Result<SharedSession, String> {
let live = sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?
.get(&session_key(name, workspace))
.cloned();
.get(&session_key(owner, name, workspace))
.map(|entry| Arc::clone(&entry.session));
if let Some(live) = live {
return Ok(live);
}
if let Ok(path) = durable_path(name, workspace)
if let Ok(path) = durable_path(owner, name, workspace)
&& let Some(mut record) = load_durable(&path)
{
record.state = DurableTerminalState::StaleLost;
@@ -594,22 +655,39 @@ pub fn kill_session(session: &mut TerminalSession) -> Result<(), String> {
.map_err(|e| format!("PTY kill failed: {e}"))
}
/// Resolve a live session without creating one.
/// Resolve a live session by the identity it was issued, never creating one.
///
/// `/v1/terminal` attaches to shells the Engine already owns (the agent's
/// terminal tools create them). A request for a name that has no live session
/// is a 404, never a new process: an HTTP client must not be able to conjure a
/// shell the Engine does not know about.
/// HTTP clients attach to shells the Engine already owns (the agent's
/// terminal tools create them): an identity with no live session is a miss,
/// never a new process. `owner` narrows the match to one thread's shells; the
/// dock passes `None` because it shows every shell in its workspace.
#[cfg(unix)]
pub fn lookup(name: &str, workspace: &Path) -> Option<SharedSession> {
let key = session_key(name, workspace);
sessions()
.lock()
.ok()
.and_then(|registry| registry.get(&key).map(Arc::clone))
pub fn lookup(workspace: &Path, owner: Option<&str>, id: &str) -> Option<SharedSession> {
registered(workspace, owner, None, id)
}
/// A dock projection of the existing registry, never another session owner.
#[cfg(unix)]
fn registered(
workspace: &Path,
owner: Option<&str>,
name: Option<&str>,
id: &str,
) -> Option<SharedSession> {
let workspace = canonical(workspace);
sessions()
.lock()
.ok()?
.iter()
.find(|(key, entry)| {
entry.id == id
&& key.workspace == workspace
&& owner.is_none_or(|owner| key.owner == owner)
&& name.is_none_or(|name| key.name == name)
})
.map(|(_, entry)| Arc::clone(&entry.session))
}
/// A projection of the existing registry, never another session owner.
#[cfg(unix)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalSessionInfo {
@@ -621,20 +699,20 @@ pub struct TerminalSessionInfo {
pub last_activity: Option<Instant>,
}
/// Called on the dock's blocking worker. Reads never consume tool output.
/// A selected identity that disappeared is not replaced by a reused name.
#[cfg(unix)]
pub fn inspect_workspace(
fn live_entries(
workspace: &Path,
selected: Option<&str>,
) -> Result<(Vec<TerminalSessionInfo>, Option<OutputChunk>), String> {
let workspace = session_key("", workspace).workspace;
owner: Option<&str>,
) -> Result<Vec<(TerminalSessionInfo, SharedSession)>, String> {
let workspace = canonical(workspace);
let live = sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?
.iter()
.filter(|(key, _)| key.workspace == workspace)
.map(|(_, session)| Arc::clone(session))
.filter(|(key, _)| {
key.workspace == workspace && owner.is_none_or(|owner| key.owner == owner)
})
.map(|(_, entry)| Arc::clone(&entry.session))
.collect::<Vec<_>>();
let mut entries = Vec::with_capacity(live.len());
for handle in live {
@@ -670,6 +748,17 @@ pub fn inspect_workspace(
.then(b.updated_at.cmp(&a.updated_at))
.then(a.name.cmp(&b.name))
});
Ok(entries)
}
/// Called on the dock's blocking worker. Reads never consume tool output.
/// A selected identity that disappeared is not replaced by a reused name.
#[cfg(unix)]
pub fn inspect_workspace(
workspace: &Path,
selected: Option<&str>,
) -> Result<(Vec<TerminalSessionInfo>, Option<OutputChunk>), String> {
let entries = live_entries(workspace, None)?;
let selected = match selected {
Some(id) => entries.iter().find(|(info, _)| info.id == id),
None => entries.first(),
@@ -689,8 +778,78 @@ pub fn inspect_workspace(
Ok((entries.into_iter().map(|(info, _)| info).collect(), output))
}
#[cfg(unix)]
pub fn owned_sessions(workspace: &Path, owner: &str) -> Result<Vec<TerminalSessionInfo>, String> {
Ok(live_entries(workspace, Some(owner))?
.into_iter()
.map(|(info, _)| info)
.collect())
}
#[cfg(unix)]
#[derive(Debug)]
pub enum HumanInputError {
Gone,
Posture,
Exited,
Rejected(String),
Failed(String),
}
#[cfg(unix)]
impl std::fmt::Display for HumanInputError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Gone => {
"selected terminal is gone or was reset; select its replacement explicitly"
}
Self::Posture => {
"selected terminal does not enforce the current sandbox policy; start a fresh terminal"
}
Self::Exited => "selected terminal has exited; start a fresh terminal",
Self::Rejected(message) | Self::Failed(message) => message.as_str(),
})
}
}
/// Human input is bound to the displayed incarnation, including across reset.
/// It follows the same sandbox-reuse guard as the model's terminal/run path.
#[cfg(unix)]
pub fn write_human_input(
handle: &SharedSession,
bytes: &[u8],
policy: &codewhale_sandbox::SandboxPolicy,
) -> Result<(), HumanInputError> {
let mut session = handle
.lock()
.map_err(|_| HumanInputError::Failed("terminal session lock poisoned".into()))?;
if !can_reuse_under_policy(&session, policy) {
return Err(HumanInputError::Posture);
}
if session_exit_status(&mut session)
.map_err(HumanInputError::Failed)?
.is_some()
{
return Err(HumanInputError::Exited);
}
if bytes == [3] && session.command.is_some() && completion(&session).is_none() {
// SIGINT can discard the grouped command's completion suffix. Reuse
// the confirmed interrupt path so model waiters and later runs see
// the persistent shell become ready again.
drop(session);
return cancel_shared_session(handle)
.map(|_| ())
.map_err(|error| HumanInputError::Failed(error.to_string()));
}
write_raw_input(&session, bytes).map_err(|error| {
if error.kind() == io::ErrorKind::InvalidInput {
HumanInputError::Rejected(error.to_string())
} else {
HumanInputError::Failed(error.to_string())
}
})
}
#[cfg(unix)]
pub fn write_dock_input(
workspace: &Path,
@@ -699,47 +858,25 @@ pub fn write_dock_input(
bytes: &[u8],
policy: &codewhale_sandbox::SandboxPolicy,
) -> Result<(), String> {
let handle = lookup(name, workspace).ok_or("selected terminal is gone or was reset")?;
let mut session = handle
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
if session.durable.session_id != id {
return Err(
"selected terminal is gone or was reset; select its replacement explicitly".into(),
);
}
if !can_reuse_under_policy(&session, policy) {
return Err(
"selected terminal does not enforce the current sandbox policy; start a fresh terminal"
.into(),
);
}
if session_exit_status(&mut session)?.is_some() {
return Err("selected terminal has exited; start a fresh terminal".into());
}
if bytes == [3] && session.command.is_some() && completion(&session).is_none() {
// SIGINT can discard the grouped command's completion suffix. Reuse
// the confirmed interrupt path so model waiters and later runs see
// the persistent shell become ready again.
drop(session);
return cancel_shared_session(&handle)
.map(|_| ())
.map_err(|error| error.to_string());
}
write_raw_input(&session, bytes).map_err(|error| error.to_string())
let handle = registered(workspace, None, Some(name), id)
.ok_or_else(|| HumanInputError::Gone.to_string())?;
write_human_input(&handle, bytes, policy).map_err(|error| error.to_string())
}
#[cfg(unix)]
pub fn resize_dock_session(
session: &TerminalSession,
workspace: &Path,
name: &str,
id: &str,
rows: u16,
cols: u16,
) -> Result<(), String> {
if session.durable.session_id != id {
return Err("selected terminal is gone or was reset".into());
}
resize_session(session, rows, cols)
let handle = registered(workspace, None, Some(name), id)
.ok_or("selected terminal is gone or was reset")?;
let session = handle
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
resize_session(&session, rows, cols)
}
#[cfg(unix)]
@@ -1045,6 +1182,7 @@ impl ToolSpec for TerminalRunTool {
let command = required_str(&input, "command")?.to_string();
let name = session_name(&input, false)?.to_string();
let session = get_or_create(
&context.state_namespace,
&name,
&context.workspace,
pty_sandbox_policy(context)?,
@@ -1096,7 +1234,8 @@ impl ToolSpec for TerminalSendTool {
{
let name = session_name(&input, true)?.to_string();
let text = required_str(&input, "text")?.as_bytes().to_vec();
let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
let session = find(&context.state_namespace, &name, &context.workspace)
.map_err(ToolError::execution_failed)?;
let wait = Duration::from_millis(optional_u64(&input, "wait_ms", 250)?.min(60_000));
return tokio::task::spawn_blocking(move || {
{
@@ -1143,7 +1282,8 @@ impl ToolSpec for TerminalWaitTool {
#[cfg(unix)]
{
let name = session_name(&input, true)?.to_string();
let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
let session = find(&context.state_namespace, &name, &context.workspace)
.map_err(ToolError::execution_failed)?;
let timeout = timeout_secs(&input, "timeout_secs")?;
return tokio::task::spawn_blocking(move || {
let (done, timed_out) = wait_shared_session(&session, timeout)?;
@@ -1182,7 +1322,8 @@ impl ToolSpec for TerminalCancelTool {
#[cfg(unix)]
{
let name = session_name(&input, true)?.to_string();
let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
let session = find(&context.state_namespace, &name, &context.workspace)
.map_err(ToolError::execution_failed)?;
return tokio::task::spawn_blocking(move || {
let done = cancel_shared_session(&session)?;
let mut session = session
@@ -1226,22 +1367,41 @@ impl ToolSpec for TerminalResetTool {
#[cfg(unix)]
{
let name = session_name(&input, true)?.to_string();
let old = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
let owner = context.state_namespace.clone();
let old =
find(&owner, &name, &context.workspace).map_err(ToolError::execution_failed)?;
let workspace = context.workspace.clone();
let policy = pty_sandbox_policy(context)?;
let shell_manager = context.shell_manager.clone();
return tokio::task::spawn_blocking(move || {
if let Ok(mut old) = old.lock() { let _ = old.child.kill(); }
let mut previous = None;
if let Ok(mut old) = old.lock() {
let _ = old.child.kill();
old.durable.state = DurableTerminalState::Reset;
old.durable.updated_at = chrono::Utc::now().to_rfc3339();
let _ = persist_durable(&old.durable_path, &old.durable);
previous = Some(old.durable.session_id.clone());
}
let fresh = create_session(&name, &workspace, policy, &shell_manager)
let fresh = create_session(&owner, &name, &workspace, policy, &shell_manager)
.map_err(ToolError::execution_failed)?;
sessions().lock().map_err(|_| ToolError::execution_failed("terminal session registry lock poisoned"))?.insert(session_key(&name, &workspace), fresh);
Ok(ToolResult { content: format!("Reset terminal session '{name}'. Lost shell state and any running command."), success: true, metadata: Some(json!({"session":name,"reset":true,"lost_state":["cwd","environment","functions","activated environments","running command"]})) })
}).await.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let id = replace_registered(session_key(&owner, &name, &workspace), &fresh)
.map_err(ToolError::execution_failed)?;
Ok(ToolResult {
content: format!(
"Reset terminal session '{name}'. Lost shell state and any running command."
),
success: true,
metadata: Some(json!({
"session": name,
"reset": true,
"terminal_session_id": id,
"previous_session_id": previous,
"lost_state": ["cwd", "environment", "functions", "activated environments", "running command"],
})),
})
})
.await
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
}
#[cfg(not(unix))]
{
@@ -1259,6 +1419,8 @@ mod tests {
use super::*;
const OWNER: &str = "workspace";
/// Dock tests own a workspace so parallel tests cannot share their registry
/// entries or durable summaries. Kill and reap every owned shell on drop.
struct DockWorkspace(tempfile::TempDir);
@@ -1274,6 +1436,7 @@ mod tests {
fn session(&self, name: &str) -> SharedSession {
get_or_create(
OWNER,
name,
self.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
@@ -1285,12 +1448,12 @@ mod tests {
impl Drop for DockWorkspace {
fn drop(&mut self) {
let workspace = session_key("", self.path()).workspace;
let workspace = canonical(self.path());
let mut owned = Vec::new();
if let Ok(mut registry) = sessions().lock() {
registry.retain(|key, session| {
registry.retain(|key, entry| {
if key.workspace == workspace {
owned.push(Arc::clone(session));
owned.push(Arc::clone(&entry.session));
false
} else {
true
@@ -1309,6 +1472,7 @@ mod tests {
fn fresh(name: &str) -> SharedSession {
let session = get_or_create(
OWNER,
name,
std::path::Path::new("/tmp"),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
@@ -1319,16 +1483,14 @@ mod tests {
let _ = session_guard.child.kill();
drop(session_guard);
let replacement = create_session(
OWNER,
name,
std::path::Path::new("/tmp"),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
.unwrap();
sessions().lock().unwrap().insert(
session_key(name, Path::new("/tmp")),
Arc::clone(&replacement),
);
replace_registered(session_key(OWNER, name, Path::new("/tmp")), &replacement).unwrap();
replacement
}
@@ -1443,16 +1605,18 @@ mod tests {
);
let _ = session.lock().unwrap().child.kill();
let replacement = create_session(
OWNER,
"test-reset",
std::path::Path::new("/tmp"),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
.unwrap();
sessions().lock().unwrap().insert(
session_key("test-reset", Path::new("/tmp")),
Arc::clone(&replacement),
);
replace_registered(
session_key(OWNER, "test-reset", Path::new("/tmp")),
&replacement,
)
.unwrap();
assert!(
!run(
&replacement,
@@ -1561,27 +1725,28 @@ mod tests {
#[test]
#[cfg(unix)]
fn session_names_are_scoped_to_workspace() {
let first_workspace = tempfile::tempdir().unwrap();
let second_workspace = tempfile::tempdir().unwrap();
let first = get_or_create(
fn session_names_are_scoped_to_owner_and_workspace() {
let first_workspace = DockWorkspace::new();
let second_workspace = DockWorkspace::new();
let first = first_workspace.session("shared-name");
let second = second_workspace.session("shared-name");
let other_owner = get_or_create(
"another-thread",
"shared-name",
first_workspace.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
.unwrap();
let second = get_or_create(
"shared-name",
second_workspace.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
.unwrap();
assert!(!Arc::ptr_eq(&first, &second));
assert!(find("shared-name", first_workspace.path()).is_ok());
assert!(find("shared-name", second_workspace.path()).is_ok());
assert!(find("shared-name", Path::new("/tmp")).is_err());
assert!(!Arc::ptr_eq(&first, &other_owner));
assert!(find(OWNER, "shared-name", first_workspace.path()).is_ok());
assert!(find(OWNER, "shared-name", second_workspace.path()).is_ok());
assert!(find(OWNER, "shared-name", Path::new("/tmp")).is_err());
let other_id = other_owner.lock().unwrap().durable.session_id.clone();
assert!(lookup(first_workspace.path(), Some("another-thread"), &other_id).is_some());
assert!(lookup(first_workspace.path(), Some(OWNER), &other_id).is_none());
assert!(lookup(second_workspace.path(), None, &other_id).is_none());
}
#[test]
@@ -1642,17 +1807,18 @@ mod tests {
old.child.wait().unwrap();
}
let replacement = create_session(
OWNER,
"replaceable",
workspace.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
.unwrap();
let new_id = replacement.lock().unwrap().durable.session_id.clone();
sessions().lock().unwrap().insert(
session_key("replaceable", workspace.path()),
Arc::clone(&replacement),
);
let new_id = replace_registered(
session_key(OWNER, "replaceable", workspace.path()),
&replacement,
)
.unwrap();
let (entries, output) = inspect_workspace(workspace.path(), Some(&old_id)).unwrap();
assert_eq!(entries[0].id, new_id);
assert!(output.is_none());
@@ -1668,12 +1834,12 @@ mod tests {
error.contains("select its replacement explicitly"),
"{error}"
);
assert!(resize_dock_session(&replacement.lock().unwrap(), &old_id, 40, 80).is_err());
assert!(resize_dock_session(workspace.path(), "replaceable", &old_id, 40, 80).is_err());
assert!(!workspace.path().join("stale-input-must-not-run").exists());
// Refusal preserves the replacement and its environment instead of
// silently resetting it to recover from the stale selection.
assert_eq!(
lookup("replaceable", workspace.path())
find(OWNER, "replaceable", workspace.path())
.unwrap()
.lock()
.unwrap()
@@ -1692,6 +1858,7 @@ mod tests {
let narrowed = SandboxPolicy::read_only();
assert!(
get_or_create(
OWNER,
"posture",
workspace.path(),
narrowed.clone(),
@@ -1724,6 +1891,7 @@ mod tests {
for policy in changed {
assert!(
get_or_create(
OWNER,
"posture",
workspace.path(),
policy.clone(),
@@ -1734,7 +1902,14 @@ mod tests {
assert!(write_dock_input(workspace.path(), "posture", &id, b"x", &policy).is_err());
}
assert!(Arc::ptr_eq(
&get_or_create("posture", workspace.path(), original, &test_shell_manager()).unwrap(),
&get_or_create(
OWNER,
"posture",
workspace.path(),
original,
&test_shell_manager()
)
.unwrap(),
&shell
));
assert_eq!(shell.lock().unwrap().durable.session_id, id);
@@ -1971,6 +2146,7 @@ mod tests {
let canonical_workspace = workspace.path().canonicalize().unwrap();
let name = format!("restart-proof-{}", Uuid::new_v4());
let first = create_session(
OWNER,
&name,
workspace.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
@@ -1987,8 +2163,8 @@ mod tests {
sessions()
.lock()
.unwrap()
.remove(&session_key(&name, workspace.path()));
let stale = match find(&name, workspace.path()) {
.remove(&session_key(OWNER, &name, workspace.path()));
let stale = match find(OWNER, &name, workspace.path()) {
Ok(_) => panic!("persisted session must not be reattached"),
Err(error) => error,
};
@@ -1996,6 +2172,7 @@ mod tests {
assert!(stale.contains("start a replacement"), "{stale}");
let replacement = create_session(
OWNER,
&name,
workspace.path(),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
+17 -11
View File
@@ -85,7 +85,10 @@ enum Request {
selected: Option<String>,
session: String,
},
Create(codewhale_sandbox::SandboxPolicy),
Create {
policy: codewhale_sandbox::SandboxPolicy,
owner: String,
},
Input {
name: String,
id: String,
@@ -146,10 +149,11 @@ impl Worker {
Err(error) => Reply::Error(error),
}
}
Request::Create(policy) => {
Request::Create { policy, owner } => {
let name =
format!("user-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
match terminal_session::get_or_create(
&owner,
&name,
&workspace,
policy,
@@ -186,14 +190,9 @@ impl Worker {
rows,
cols,
} => {
let result = terminal_session::lookup(&name, &workspace)
.ok_or_else(|| "selected terminal is gone or was reset".to_string())
.and_then(|handle| {
let session = handle.lock().map_err(|_| {
"terminal session lock poisoned".to_string()
})?;
terminal_session::resize_dock_session(&session, &id, rows, cols)
});
let result = terminal_session::resize_dock_session(
&workspace, &name, &id, rows, cols,
);
match result {
Ok(()) => continue,
Err(error) => Reply::Error(error),
@@ -473,7 +472,14 @@ pub(super) fn handle_key(app: &mut App, key: KeyEvent) -> bool {
dock.scroll = 0;
dock.size = None;
dock.error = None;
let sent = send(app, Request::Create(policy(app)));
let owner = app.current_session_id.clone().unwrap_or_default();
let sent = send(
app,
Request::Create {
policy: policy(app),
owner,
},
);
app.work_surface.terminal.creating = sent;
return true;
}
+81 -27
View File
@@ -2199,35 +2199,89 @@ scheduler, or credential store.
The jobs family above runs one command per job. A terminal pane needs the
*other* authority: the stateful PTY-backed shell the agent's own terminal
tools drive, which keeps cwd and environment across inputs. These routes
attach to that session and never create one — a name with no live session is
`404`, because conjuring a shell from an HTTP request would give the client a
terminal the Engine does not know about. Input is attributable by route:
`input` is the client's writer, `terminal_send` is the agent's.
tools drive, which keeps cwd and environment across inputs. Each shell belongs
to the thread whose turn started it, in that thread's workspace: two
conversations that both use `term-1` in one folder get two shells. These
routes attach to those shells and never create one, because conjuring a shell
from an HTTP request would give the client a terminal the Engine does not
know about. Input is attributable by route: `input` is the client's writer,
`terminal/send` is the agent's.
- `GET /v1/terminal/{name}/output?cursor=<bytes>&max_bytes=<1-64KiB>&format=
<base64|text>` — the resumable byte stream. `{name, offset, next_cursor,
total, dropped, encoding, data, running, exit_code}`: pass `next_cursor`
back to continue; reads never consume, so several clients may hold
independent cursors; `dropped` reports bytes the 512 KiB ring discarded,
and a cursor past `total` is answered from `total` rather than echoed back
- `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, `base64` by
default (exact bytes) or `text` for UTF-8 → `{ "name", "written" }`
- `POST /v1/terminal/{name}/resize` — `{ "rows", "cols" }` → the kernel
window the child draws for
- `POST /v1/terminal/{name}/kill` — end the shell; observe the exit through
`output` (`running` / `exit_code`) rather than the acknowledgement
- `GET /v1/threads/{id}/terminals` → `{thread_id, workspace, instance,
terminals: [{terminal_id, name, state, running, exit_code, updated_at}]}` —
the thread's live shells, newest activity first. `terminal_id` is issued
when the shell starts and is never reused; the agent's terminal tool results
carry the same value as `terminal_session_id`, and `terminal/reset` also
reports `previous_session_id`. `instance` names this Engine process's
terminal registry
- `GET /v1/threads/{id}/terminals/{terminal_id}/output?cursor=<bytes>&
max_bytes=<1-64KiB>&format=<base64|text>` — the resumable byte stream.
`{terminal_id, offset, next_cursor, total, dropped, gap, encoding, data,
running, exit_code}`: pass `next_cursor` back to continue; reads never
consume, so several clients may hold independent cursors; `gap` is true and
`offset` exceeds `cursor` when the 512 KiB ring already discarded the bytes
asked for (`dropped` counts them), and a cursor past `total` is answered from
`total` rather than echoed back
- `POST /v1/threads/{id}/terminals/{terminal_id}/input` — `{ "data",
"encoding"? }`, `base64` by default (exact bytes) or `text` for UTF-8 →
`{ "terminal_id", "written" }`. It needs the thread's shell consent (`403`
`thread_shell_disabled`) and the loaded shell policy (`403`). When the
thread's current posture requires a sandbox, a shell that was not started
under exactly that policy refuses input (`403` `terminal_posture_changed`);
an exited shell refuses it too (`409` `terminal_exited`). A lone ETX
(Ctrl-C) while the agent's command runs takes the confirmed-interrupt path,
so the agent's wait sees exit status 130; if the shell does not confirm it
within 2 seconds the call answers `500` saying the interrupt was sent, and
the client must not send it again on its own
- `POST /v1/threads/{id}/terminals/{terminal_id}/resize` — `{ "rows", "cols" }`
→ the kernel window the child draws for
- `POST /v1/threads/{id}/terminals/{terminal_id}/kill` — end the shell →
`{ "terminal_id", "killed" }`, with `killed: false` when it had already
exited; observe the exit through `output` (`running` / `exit_code`) rather
than the acknowledgement
`GET /v1/runtime/info` advertises `terminal_stream`, `terminal_input`,
`terminal_resize` and `terminal_kill`. All four are `false` on Windows and OpenHarmony builds
today: the owner is Unix-only, those routes answer `501`, and a client
should gate its terminal controls on these flags rather than discovering it
from a failed request. Known limitations, stated because a reader would
otherwise assume them: there is no `wait_ms` long poll (poll the cursor),
scrollback dropped by the ring is gone with the process, a restarted Engine
reports no session rather than pretending to reattach, and the
`@codewhale/runtime-sdk` package has no terminal client wrapper yet — the raw
routes are the contract for now.
Every call resolves the thread's recorded workspace again. An unknown thread
is `404`; a workspace that is missing, replaced by a link or changed during
resolution is `409` `thread_workspace_unavailable`; and an identity that is not
one of this thread's live shells in that workspace (another thread's, another
workspace's, a reset shell's, or one from before an Engine restart) is `404`
`terminal_not_found`. The Engine never substitutes a shell with the same name.
Reconnect and restart semantics:
- **Reconnect** (a dropped connection, a reopened pane or window, or a client
app restart while the Engine kept running): list the thread's terminals,
confirm `instance` is the one recorded, and resume each `terminal_id` from
its stored cursor. Only retained output is replayed; `gap` reports what the
ring lost.
- **Reset** (`terminal/reset`): the shell is killed and a new one starts under
the same name with a new `terminal_id`. The old identity is `404`; the list
shows the replacement, and a client switches to it only on the person's
choice.
- **Exit**: the identity stays listed with `running: false` and its
`exit_code` until the agent resets it or the Engine restarts.
- **Engine restart**: live shells end with the process. The list reports a
different `instance` and none of the old identities, which answer `404`. No
shell is recreated and no command is rerun; a later `terminal/run` with the
same name starts a new identity.
- **Input across a failure**: input already sent cannot be retracted. A client
that loses the connection before the response cannot know whether the bytes
reached the shell and must not resend them automatically.
`GET /v1/runtime/info` advertises `thread_scoped_terminals`. It is `false` on
Windows and OpenHarmony builds today: the owner is Unix-only, those routes
answer `501`, and a client should gate its terminal controls on the flag
rather than discovering it from a failed request. The name-addressed routes
`/v1/terminal/{name}/*` and their `terminal_stream`, `terminal_input`,
`terminal_resize` and `terminal_kill` flags were retired in 0.10.3: a name at
the server's workspace could reach another thread's shell, so those paths now
answer `410` `terminal_route_retired`. Known limitations, stated because a
reader would otherwise assume them: there is no `wait_ms` long poll (poll the
cursor), scrollback dropped by the ring is gone with the process, shells
started outside a runtime thread (the TUI dock, headless runs) have no route
here, deleting a thread does not end its shells (they run until the Engine
exits), and the `@codewhale/runtime-sdk` package has no terminal client
wrapper yet — the raw routes are the contract for now.
**Jobs** (operator-scoped shell jobs; the terminal surface)
- `GET /v1/jobs` — every live and known-stale job across all threads
+64 -23
View File
@@ -6,6 +6,7 @@
> 2026-10-11 补齐工作区信任契约:`workspace_trusted` 与 `POST /v1/workspace/trust`。
> 2026-10-23 补齐完整 API 入口、兼容执行限制与 ACP MCP 拒绝边界。
> 2026-11-02 补齐控制帧大小、写入背压、ACP 拒绝恢复与当前 HTTP 监听器边界。
> 2026-11-07 补齐线程作用域的终端会话标识、重新连接与重启语义。
`codewhale app-server --http` 是本地运行时的规范 API 与控制面。本地 SDK、移动端/远程控制客户端
以及编辑器集成都与它对话,而不是去抓取终端输出。它提供完整的 HTTP/SSE 运行时
@@ -1718,33 +1719,73 @@ token 但成本为 `0.0`。于 v0.8.10 加入(#564)。
上面的 jobs 族每个作业运行一条命令。终端面板需要的是
**另一种**权威:智能体自己的终端工具所驱动的、有状态且基于 PTY 的 shell,它在多次输入之间保持 cwd 与环境。
这些路由附着到那个会话,且从不创建会话——一个没有活动会话的名字返回
`404`,因为从一次 HTTP 请求中凭空变出一个 shell 会让客户端得到一个
Engine 并不知道的终端。输入可按路由归因:
`input` 是客户端的写入通道,`terminal_send` 是智能体的。
每个 shell 属于启动它的那一轮对话所在的线程,并位于该线程的工作区中:
同一文件夹里两个都使用 `term-1` 的对话会得到两个 shell。
这些路由附着到这些 shell,且从不创建 shell,因为从一次 HTTP 请求中凭空变出一个 shell
会让客户端得到一个 Engine 并不知道的终端。输入可按路由归因:
`input` 是客户端的写入通道,`terminal/send` 是智能体的。
- `GET /v1/terminal/{name}/output?cursor=<bytes>&max_bytes=<1-64KiB>&format=
<base64|text>` — 可恢复的字节流。`{name, offset, next_cursor,
total, dropped, encoding, data, running, exit_code}`:把 `next_cursor`
传回来即可继续;读取永不消费,因此多个客户端可以持有
各自独立的游标;`dropped` 报告 512 KiB 环形缓冲区丢弃的字节数,
- `GET /v1/threads/{id}/terminals` → `{thread_id, workspace, instance,
terminals: [{terminal_id, name, state, running, exit_code, updated_at}]}` —
该线程的活动 shell,按最近活动排序。`terminal_id` 在 shell 启动时签发,
且永不复用;智能体终端工具的结果以 `terminal_session_id` 携带同一个值,
`terminal/reset` 还会报告 `previous_session_id`。`instance` 标识本 Engine
进程的终端注册表
- `GET /v1/threads/{id}/terminals/{terminal_id}/output?cursor=<bytes>&
max_bytes=<1-64KiB>&format=<base64|text>` — 可恢复的字节流。
`{terminal_id, offset, next_cursor, total, dropped, gap, encoding, data,
running, exit_code}`:把 `next_cursor` 传回来即可继续;读取永不消费,
因此多个客户端可以持有各自独立的游标;当 512 KiB 环形缓冲区已经丢弃了
所请求的字节时,`gap` 为真且 `offset` 大于 `cursor`(`dropped` 统计丢弃量),
而超出 `total` 的游标会从 `total` 作答,而不是把它回显回来
- `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`,默认
`base64`(精确字节),或用 `text` 传 UTF-8 → `{ "name", "written" }`
- `POST /v1/terminal/{name}/resize` — `{ "rows", "cols" }` → 子进程绘制目标的内核
窗口
- `POST /v1/terminal/{name}/kill` — 结束该 shell;通过
- `POST /v1/threads/{id}/terminals/{terminal_id}/input` — `{ "data",
"encoding"? }`,默认 `base64`(精确字节),或用 `text` 传 UTF-8 →
`{ "terminal_id", "written" }`。它需要线程的 shell 授权(`403`
`thread_shell_disabled`)与已加载的 shell 策略(`403`)。当线程当前的
权限姿态要求沙箱时,不是在完全相同的策略下启动的 shell 会拒绝输入
(`403` `terminal_posture_changed`);已退出的 shell 同样拒绝输入
(`409` `terminal_exited`)。在智能体的命令运行期间单独发送 ETX(Ctrl-C)
会走经确认的中断路径,因此智能体的等待会看到退出状态 130;若 shell 在 2 秒内
没有确认,调用返回 `500` 并说明中断已发送,客户端不得自行再次发送
- `POST /v1/threads/{id}/terminals/{terminal_id}/resize` — `{ "rows", "cols" }`
→ 子进程绘制目标的内核窗口
- `POST /v1/threads/{id}/terminals/{terminal_id}/kill` — 结束该 shell →
`{ "terminal_id", "killed" }`,若它早已退出则 `killed: false`;通过
`output`(`running` / `exit_code`)观察退出,而不是看这次确认
`GET /v1/runtime/info` 声明 `terminal_stream`、`terminal_input`、
`terminal_resize` 与 `terminal_kill`。这四个在 Windows 与 OpenHarmony 构建上
目前都是 `false`:其所有者仅支持 Unix,那些路由回答 `501`,因此客户端
应当用这些布尔标志来门控终端控件,而不是通过一次失败请求去发现这一点。
以下限制需要明说,否则读者会自行假设:没有 `wait_ms` 长轮询(请轮询游标),
被环形缓冲区丢弃的回滚内容随进程一起消失,重启后的 Engine
会报告没有会话,而不是假装重新附着;且
`@codewhale/runtime-sdk` 包还没有终端客户端封装——目前
裸路由就是契约。
每次调用都会重新解析线程记录的工作区。未知线程返回 `404`;缺失、被替换为链接
或在解析期间发生变化的工作区返回 `409` `thread_workspace_unavailable`;
不属于该线程在该工作区中的活动 shell 的标识(其他线程的、其他工作区的、
已重置的,或 Engine 重启之前的)返回 `404` `terminal_not_found`。
Engine 从不以同名 shell 代替。
重新连接与重启语义:
- **重新连接**(连接中断、面板或窗口重新打开,或 Engine 持续运行期间客户端
应用重启):列出线程的终端,确认 `instance` 与记录的一致,然后从保存的游标
继续每个 `terminal_id`。只会重放仍保留的输出;`gap` 报告环形缓冲区丢失的部分。
- **重置**(`terminal/reset`):旧 shell 被结束,同名的新 shell 以新的
`terminal_id` 启动。旧标识返回 `404`;列表会显示替代者,客户端只在用户
选择后才切换过去。
- **退出**:该标识仍会以 `running: false` 及其 `exit_code` 出现在列表中,
直到智能体重置它或 Engine 重启。
- **Engine 重启**:活动 shell 随进程一起结束。列表会报告不同的 `instance`,
且不含任何旧标识,旧标识返回 `404`。不会重建 shell,也不会重新运行命令;
之后同名的 `terminal/run` 会启动一个新标识。
- **故障中的输入**:已发送的输入无法撤回。在收到响应前失去连接的客户端
无法知道这些字节是否到达 shell,且不得自动重发。
`GET /v1/runtime/info` 声明 `thread_scoped_terminals`。它在 Windows 与
OpenHarmony 构建上目前为 `false`:其所有者仅支持 Unix,那些路由回答 `501`,
因此客户端应当用这个标志来门控终端控件,而不是通过一次失败请求去发现这一点。
按名称寻址的路由 `/v1/terminal/{name}/*` 及其 `terminal_stream`、
`terminal_input`、`terminal_resize` 与 `terminal_kill` 标志已在 0.10.3 中退役:
服务器工作区中的一个名字可能触及其他线程的 shell,因此这些路径现在返回
`410` `terminal_route_retired`。以下限制需要明说,否则读者会自行假设:
没有 `wait_ms` 长轮询(请轮询游标),被环形缓冲区丢弃的回滚内容随进程一起消失,
在运行时线程之外启动的 shell(TUI 终端坞、无头运行)在这里没有路由,
删除线程不会结束它的 shell(它们会一直运行到 Engine 退出),且
`@codewhale/runtime-sdk` 包还没有终端客户端封装——目前裸路由就是契约。
**作业**(操作者范围内的 shell 作业;终端表面)
- `GET /v1/jobs` — 跨所有线程的每个活动与已知过期作业
+1 -4
View File
@@ -64,6 +64,7 @@ export type RuntimeCapabilities = {
thread_scoped_reads: boolean
thread_scoped_git_writes: boolean
thread_scoped_snapshots: boolean
thread_scoped_terminals: boolean
worktrees: boolean
job_operations: boolean
thread_shell_consent: boolean
@@ -96,10 +97,6 @@ export type RuntimeCapabilities = {
skill_detail: boolean
plugin_management: boolean
agent_mail: boolean
terminal_stream: boolean
terminal_input: boolean
terminal_resize: boolean
terminal_kill: boolean
event_stream_resume: boolean
[key: string]: Json | undefined
}
+1 -1
View File
@@ -256,7 +256,7 @@
"path_canonicalize": 6
},
"crates/engine/src/tools/terminal_session.rs": {
"path_canonicalize": 3,
"path_canonicalize": 1,
"std_fs": 1,
"thread_sleep": 2
},
+2 -2
View File
@@ -293,8 +293,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "f4aa394d8b349d7dcecbb85f55c3af4a08f77339728a8aec39b76b03b794f3ce",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
"sha256": "5c280df537e2479cccb0016d3536bf8717f2593b3b87237376544a3c998d35a2",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
}
]
},