Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
eb28b3d
docs: plan REST Codex terminal identity publication
danshapiro Jul 29, 2026
3ac5974
docs: add implementation plan for rest-terminal-session-identity
danshapiro Jul 29, 2026
04da6d9
docs(plan): apply load-bearing validation findings to REST session-id…
danshapiro Jul 30, 2026
6d7e5a2
docs(plan): fix fresheyes blocking issues — sync binder trait, no asy…
danshapiro Jul 30, 2026
65cc1e7
docs(plan): fix fresheyes blocking issues — assert sessionRef on real…
danshapiro Jul 30, 2026
4709ee8
docs(plan): fix fresheyes blocking issues — respawn test body carries…
danshapiro Jul 30, 2026
180a15e
refactor(platform): shared fresh-claude preallocation predicate for b…
danshapiro Jul 30, 2026
4d46c86
fix(freshagent): REST claude creates mint a preallocated --session-id…
danshapiro Jul 30, 2026
f40a49c
test(freshagent): pin claude identity mint on REST split and respawn …
danshapiro Jul 30, 2026
4fbc9c9
feat(ws,terminal): PaneIdentityBinder seam — write-side identity/ledg…
danshapiro Jul 30, 2026
1bc31c5
feat(freshagent): REST creates write identity rows and ledger binding…
danshapiro Jul 30, 2026
c76296a
fix(freshagent): log pane-identity binder task join errors instead of…
danshapiro Jul 30, 2026
372250e
test(ws): e2e pins — REST claude identity durability, A13 refusal of …
danshapiro Jul 30, 2026
4d53104
test(ws): pin REST codex/opencode create -> identity row + ledger bin…
danshapiro Jul 30, 2026
be4dd45
Merge remote-tracking branch 'origin/main' into fix/rest-terminal-ses…
danshapiro Jul 30, 2026
ef3014b
test: add missing auto_resume_cancels field to test initializers afte…
danshapiro Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/freshell-freshagent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ pub struct FreshAgentState {
/// narrows the guard to the registry-row arm.
pub(crate) session_identity:
Option<Arc<dyn freshell_terminal::registry::SessionIdentityLookup>>,
/// Write-side pane-identity seam (kata hbsa): lets the REST spawn
/// pipeline write TerminalIdentityRegistry rows and PaneLedger bindings
/// across the freshagent->ws crate boundary. Read-side twin:
/// `session_identity`. `None` (tests without identity concerns) = the
/// legacy no-write behavior.
pub(crate) pane_identity: Option<Arc<dyn freshell_terminal::registry::PaneIdentityBinder>>,
/// paneId -> terminal pane record (Slice 1 `mode:'shell'` terminals
/// created via `POST /api/tabs`). Disjoint from `panes` (fresh-agent-only)
/// and `content_panes` (browser/editor) -- a pane id appears in exactly
Expand Down Expand Up @@ -275,6 +281,7 @@ impl FreshAgentState {
sessions_revision: Arc::new(AtomicI64::new(0)),
terminal_registry: None,
session_identity: None,
pane_identity: None,
terminal_panes: Arc::new(Mutex::new(HashMap::new())),
content_panes: Arc::new(Mutex::new(HashMap::new())),
tabs: Arc::new(Mutex::new(HashMap::new())),
Expand Down Expand Up @@ -460,6 +467,19 @@ impl FreshAgentState {
self
}

/// Write-side twin of [`Self::with_session_identity`] (kata hbsa): wire
/// in the pane-identity binder so the REST spawn pipeline
/// (`spawn_terminal_pane` -> `settle_gated_create`) can write identity
/// rows and durable ledger bindings exactly like the WS create path.
/// Unwired (`None`), REST creates keep the legacy no-write behavior.
pub fn with_pane_identity_binder(
mut self,
binder: Arc<dyn freshell_terminal::registry::PaneIdentityBinder>,
) -> Self {
self.pane_identity = Some(binder);
self
}

/// SESSION-09 fix-forward: replace this state's own `sessions_revision`
/// counter with a SHARED one -- in production, `freshell-server` wires
/// this to the SAME `Arc<AtomicI64>` as `freshell_ws::WsState::sessions_revision`
Expand Down
800 changes: 779 additions & 21 deletions crates/freshell-freshagent/src/terminal_tabs.rs

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions crates/freshell-platform/src/cli_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,34 @@ pub fn resolve_cli_launch(
label: spec.label.clone(),
})
}
// ===========================================================================
// Fresh-claude preallocation predicate (shared with REST spawn pipeline)
// ===========================================================================

/// LIVE-PATH LAW (specs/coding-cli.md § 2.1(3)): fresh claude ALWAYS gets a
/// server-preallocated `--session-id`. This is the single shared "is this
/// create a fresh claude that must mint its own session id?" predicate,
/// used by BOTH doors — the WS `terminal.create` handler
/// (`freshell-ws/src/terminal.rs`) and the REST spawn pipeline
/// (`freshell-freshagent/src/terminal_tabs.rs`) — so the two cannot drift
/// (kata hbsa: the REST door skipped preallocation entirely, leaving
/// un-resumable panes invisible to the A13 live-owner guard).
///
/// The caller that gets `true` mints `Uuid::new_v4()`, sets
/// `LaunchIntent::Start` (claude's manifest has `create_session_args`),
/// and marks the create as a fresh prealloc for PIN 2 gating (eaa25b7d).
pub fn should_preallocate_fresh_claude(
mode: &str,
restore: Option<bool>,
has_session_ref: bool,
resume_session_id: Option<&str>,
) -> bool {
mode == "claude"
&& restore != Some(true)
&& !has_session_ref
&& resume_session_id.filter(|s| !s.is_empty()).is_none()
}

// §4 golden argv tests (split to keep this file within the campaign's
// ≤1K-lines-per-file limit).
#[cfg(test)]
Expand Down Expand Up @@ -661,6 +689,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn fresh_claude_preallocation_predicate_truth_table() {
use super::should_preallocate_fresh_claude as pred;
// The three-part freshness predicate from the WS reference
// (crates/freshell-ws/src/terminal.rs:1630-1637): mode == "claude"
// AND restore != Some(true) AND no sessionRef AND no non-empty
// resumeSessionId.
assert!(pred("claude", None, false, None));
assert!(pred("claude", Some(false), false, None));
// Empty resume id is treated as absent (matches the WS
// `.filter(|s| !s.is_empty()).is_none()` shape).
assert!(pred("claude", None, false, Some("")));
// Any disqualifier kills the mint:
assert!(!pred("claude", Some(true), false, None)); // restore create
assert!(!pred("claude", None, true, None)); // wire sessionRef present
assert!(!pred(
"claude",
None,
false,
Some("29a53649-0000-4000-8000-000000000000")
)); // resume
// Only claude mints with Start intent; other providers never do here:
assert!(!pred("shell", None, false, None));
assert!(!pred("codex", None, false, None));
assert!(!pred("amplifier", None, false, None));
assert!(!pred("opencode", None, false, None));
assert!(!pred("gemini", None, false, None));
}

/// GNU date passthrough: full nanosecond precision is preserved.
#[cfg(unix)]
#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub mod firewall;
pub mod network;
pub mod port_forward;

pub use cli_launch::should_preallocate_fresh_claude;
pub use detect::{HostOs, Platform};
pub use elevated::{
build_elevated_powershell_args, ConfirmationAction, ConfirmationGate,
Expand Down
36 changes: 25 additions & 11 deletions crates/freshell-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,33 @@ async fn main() -> ExitCode {
// live-session guard can consume it through the `SessionIdentityLookup`
// seam (cheap-clone handle; `WsState` keeps using this same binding).
let terminal_identity = freshell_ws::identity::TerminalIdentityRegistry::new();
// P1.8: the pane-identity ledger (spec §4.2). Root resolved ONCE here;
// the module itself never reads env vars. No home => disabled no-op,
// same policy as tabs-snapshots. `new_locked` = the single-writer
// guard (V2.md): exclusive flock on <root>/lock, ConfigLock pattern —
// a second server on the same home comes up with a DISABLED ledger and
// a loud ERROR instead of two writers corrupting one store. Hoisted
// above the fresh-agent builder chain (kata hbsa Task 5, ledger A8):
// it depends only on `home`, and the REST spawn pipeline's
// `PaneIdentityBinder` below must share THIS instance with `ws_state`.
let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked(
home.as_ref()
.map(|h| h.join(".freshell").join("pane-ledger")),
));
let fresh_agent_state = fresh_agent_state
.with_terminal_registry(registry.clone())
.with_session_identity(std::sync::Arc::new(terminal_identity.clone()));
.with_session_identity(std::sync::Arc::new(terminal_identity.clone()))
// Write-side twin (kata hbsa Task 5): REST creates write identity
// rows and durable ledger bindings through the SAME
// `TerminalIdentityRegistry` + `PaneLedger` instances `ws_state`
// uses below — REST-written rows must be visible to the WS
// guard/drain and vice versa.
.with_pane_identity_binder(std::sync::Arc::new(
freshell_ws::pane_identity_binder::LedgerPaneIdentityBinder::new(
terminal_identity.clone(),
std::sync::Arc::clone(&pane_ledger),
),
));
// TERM-11 fix: honor `settings.safety.autoKillIdleMinutes` at boot (the
// Rust registry previously never read it at all, so a config that raised
// or lowered it from the default had no effect). See
Expand Down Expand Up @@ -529,16 +553,6 @@ async fn main() -> ExitCode {
);
// Shutdown latch shared with shutdown_signal (Task 7 wires the setter).
let shutdown_started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
// P1.8: the pane-identity ledger (spec §4.2). Root resolved ONCE here;
// the module itself never reads env vars. No home => disabled no-op,
// same policy as tabs-snapshots. `new_locked` = the single-writer
// guard (V2.md): exclusive flock on <root>/lock, ConfigLock pattern —
// a second server on the same home comes up with a DISABLED ledger and
// a loud ERROR instead of two writers corrupting one store.
let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked(
home.as_ref()
.map(|h| h.join(".freshell").join("pane-ledger")),
));
// P1.13: inject the ledger-backed identity sink into the fresh-agent
// states (constructed earlier, before the ledger exists — the
// post-construction setter exists precisely for this ordering). All
Expand Down
63 changes: 63 additions & 0 deletions crates/freshell-terminal/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,69 @@ pub trait SessionIdentityLookup: Send + Sync + std::fmt::Debug {
fn terminal_for_session(&self, provider: &str, session_id: &str) -> Option<String>;
}

/// Write-side pane-identity seam — [`SessionIdentityLookup`]'s twin for the
/// REST spawn pipeline (kata hbsa). Consumed by `freshell-freshagent`'s REST
/// create/split/respawn call sites (which cannot depend on `freshell-ws` —
/// circular); produced by `freshell-ws`'s `LedgerPaneIdentityBinder`
/// (identity registry + pane ledger), wired in `freshell-server::main`.
///
/// Fully synchronous ON PURPOSE: every underlying operation is sync (the
/// identity registry is a plain `RwLock`, every ledger writer a plain
/// `fn -> io::Result<()>`), and the one caller that CANNOT be async is the
/// pane exit hook — [`crate::pty::ExitHook`] is a `FnOnce` invoked on the
/// plain OS reader thread where no tokio runtime exists. Async REST call
/// sites hop ledger-touching calls through `tokio::task::spawn_blocking`.
pub trait PaneIdentityBinder: Send + Sync + std::fmt::Debug {
/// PIN 2 durability-before-argv: durable claude binding row written
/// BEFORE the spawn makes the preallocated id observable. Callers gate
/// this on their fresh-prealloc flag ONLY (eaa25b7d).
fn record_prespawn_claude_binding(
&self,
session_id: &str,
terminal_id: &str,
mode: &str,
cwd: Option<&str>,
create_request_id: Option<&str>,
);
/// Compensating delete when the spawn that minted the id fails.
/// MUST be gated on the SAME predicate as the record (eaa25b7d).
fn delete_prespawn_claude_binding(&self, session_id: &str);
/// Post-spawn identity registration, mirroring the WS post-spawn block
/// (freshell-ws/src/terminal.rs): identity row + durable binding for any
/// non-shell create with a session id; pending marker for the
/// locator-resolved providers (codex/opencode/amplifier) without one.
fn register_create_identity(
&self,
terminal_id: &str,
mode: &str,
resume_session_id: Option<&str>,
cwd: Option<&str>,
create_request_id: Option<&str>,
);
/// Exit-side hygiene (load-bearing ledger A2): mirrors the WS pane
/// EXIT hook (terminal.rs:1334-1342) EXACTLY — retire the identity row
/// (in-memory flag flip) and delete any pending marker. Deliberately
/// does NOT touch the ledger binding: `retire_closed` is the
/// explicit-user-close trigger only ("P1.8 trigger (e)", the WS kill
/// command path, terminal.rs:3849-3868), never the natural-exit path,
/// and the Bound-after-natural-exit ledger row is load-bearing —
/// `auto_resume::pre_respawn_guard` reads a still-Bound row as "pane
/// still wants this session" (auto_resume.rs:445-450) and the recovery
/// inventory keys on `RetiredReason::Closed` meaning deliberate close
/// (recovery_inventory.rs:299-301). Both A2 hazards are closed by the
/// identity-row retire alone: the session directory joins identity
/// rows for liveness (session_directory.rs:716-766, and the rename
/// cascade with it, sessions.rs:167-187), and the claude drain's no-op
/// arm checks `current.retired` (claude_signal.rs:253-342), so a late
/// new-id SessionStart cannot durably rebind a dead pane. Idempotent;
/// harmless no-op for terminals with no identity row. Called from the
/// pane exit hook for ALL non-shell creates. SYNC ON PURPOSE: the exit
/// hook is a plain FnOnce on the PTY reader thread — blocking IO is
/// safe there, .await is impossible (mirrors the WS exit hook,
/// terminal.rs:1334-1342).
fn retire_pane_identity(&self, terminal_id: &str);
}

impl TerminalRegistry {
pub fn new() -> Self {
Self {
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-ws/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub mod invariants;
pub mod opencode_association;
pub mod opencode_signal;
pub mod origin;
pub mod pane_identity_binder;
pub mod pane_ledger;
pub mod reconcile;
pub mod reconcile_freshagent;
Expand Down
Loading
Loading