mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
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:
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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user