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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ neo_frizbee = "=0.11.0"
opentelemetry = { version = "=0.32.0", default-features = false, features = ["trace"] }
opentelemetry-otlp = { version = "=0.32.0", default-features = false, features = ["grpc-tonic", "http-proto", "http-json", "reqwest-client", "tls-webpki-roots", "trace"] }
opentelemetry_sdk = { version = "=0.32.1", default-features = false, features = ["experimental_trace_batch_span_processor_with_async_runtime", "rt-tokio", "trace"] }
pulldown-cmark = { version = "=0.13.0", default-features = false }
ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm", "layout-cache"] }
ratatui-image = { version = "=11.0.8", default-features = false, features = ["crossterm"] }
reqwest = { version = "=0.13.4", default-features = false, features = ["blocking", "form", "rustls", "stream"] }
Expand Down
146 changes: 0 additions & 146 deletions docs/plans/compose-managed-files.md

This file was deleted.

18 changes: 18 additions & 0 deletions docs/user/tui-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ The catalog requires an existing directory and is workspace-filtered and newest-

A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` uses the same durable sessions: it prints `session_id: <id>` after its answer, and that ID can be continued by either `kit prompt --resume <session-id>` or `kit tui --resume <session-id>`.

## Images in the transcript

User attachments, native assistant-generated images, typed tool results, and Markdown image nodes use the same terminal image renderer. Image loading and decoding are asynchronous. Loading or unavailable images retain text placeholders; unsupported terminal graphics retain readable text and source links. Image viewports keep a fixed height and refit to the terminal width after resize. When the visible image set exceeds cache capacity, Kit keeps a stable admitted subset and shows a capacity-deferred placeholder for the rest; scrolling to a smaller set permits recovery without repeated background downloads or decoding. Display is presentation-only: it never attaches pixels to a prompt, grants File access, or changes provider requests.

Markdown images use CommonMark image syntax, including reference-style images. Ordinary links, escaped image syntax, and images inside code do not load. An incomplete streamed image node remains text until it parses as an image. Explicit repeated image nodes remain separate occurrences. A typed attachment and a Markdown image are deduplicated only after the resolved bytes identify the same image; an inaccessible Markdown source does not hide the typed attachment.

- `![Edited image](kit-file://file_<64-hex-digits>)` resolves through the current session's managed-file authority. An ID alone cannot grant access to another session's image.
- Relative paths resolve from the session project root. Absolute paths and local `file:` URLs use OS process permissions, including symlink targets; only bounded regular files are read. This is not a workspace filesystem sandbox.
- Remote images are **disabled by default**. To authorize anonymous HTTPS image loads from an exact origin, start Kit with an explicit environment policy, for example:

```sh
KIT_TUI_IMAGE_ORIGINS=https://images.example.com kit tui --root /path/to/project
```

Multiple origins are comma-separated. Authorization is for the entire origin, not an individual path. URLs with credentials, nondefault ports, nonpublic destination addresses, or redirects are rejected. Each DNS address must pass validation and the connection is pinned to the validated addresses while retaining TLS hostname verification. The dedicated client does not inherit proxies, cookies, or authorization headers. Reads, connection time, response size, decode work, and concurrent jobs are bounded. An ordinary HTTPS link is still only a link.

External Markdown images are temporary presentation snapshots, not managed imports. Cache reset or session replay re-resolves local files and remote URLs; changes outside Kit can therefore change those images. Managed and typed images retain their original snapshot semantics. Session changes invalidate cached authorization and prevent stale work from publishing into the new session. Already running blocking work can finish before its bounded worker slot is released. Missing, denied, and corrupt sources are negatively cached rather than retried on every redraw. Restart the TUI to change the remote-origin policy.

## Recovering from full storage

Kit routes its internal persistence through a shared filesystem service. If a write fails because storage is full or a quota is exceeded, the service retains the pending change in a bounded memory overlay. Internal reads and session listings use the same view, so finishing a turn or closing a session handle does not discard accepted changes. An existing session can be reopened in the same running process while persistence is pending.
Expand Down
32 changes: 28 additions & 4 deletions src/managed_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,11 +450,34 @@ impl FileStore {

pub(crate) fn resolve(&self, session: &str, selected: &FileReference) -> Result<Vec<u8>> {
selected.validate()?;
self.resolve_stored(session, &selected.id, Some(selected))
.map(|(bytes, _)| bytes)
}

/// Resolve an existing session-owned object without importing or granting it.
pub(crate) fn resolve_id(&self, session: &str, id: &str) -> Result<(Vec<u8>, String)> {
if !id.strip_prefix("file_").is_some_and(|hash| {
hash.len() == 64
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}) {
return Err("invalid managed file ID".into());
}
self.resolve_stored(session, id, None)
}

fn resolve_stored(
&self,
session: &str,
id: &str,
selected: Option<&FileReference>,
) -> Result<(Vec<u8>, String)> {
let directory = self.session_directory(session);
// A reference must never resolve an import still retained only in the
// resilient filesystem's volatile write-back layer.
fs::require_disk(directory.join(&selected.id)).map_err(display)?;
let mut file = fs::open_beneath(&directory, Path::new(&selected.id)).map_err(|error| {
fs::require_disk(directory.join(id)).map_err(display)?;
let mut file = fs::open_beneath(&directory, Path::new(id)).map_err(|error| {
format!("managed file is missing or inaccessible in this session: {error}")
})?;
let length = file.metadata().map_err(display)?.len();
Expand All @@ -475,9 +498,10 @@ impl FileStore {
file.read_exact(&mut header).map_err(display)?;
let header: Header = serde_json::from_slice(&header).map_err(display)?;
header.file.validate()?;
if &header.file != selected {
if header.file.id != id || selected.is_some_and(|selected| &header.file != selected) {
return Err("selected file metadata does not match its stored object".into());
}
let selected = &header.file;
if length != 12 + header_length as u64 + selected.size_bytes {
return Err("managed file envelope length does not match its payload".into());
}
Expand All @@ -492,7 +516,7 @@ impl FileStore {
}
// Digest and metadata bind the already validated immutable import. No
// repeated pixel decode is necessary for each selection or replay.
Ok(bytes)
Ok((bytes, header.file.mime_type))
}

pub(crate) fn selected_parts(
Expand Down
41 changes: 41 additions & 0 deletions src/managed_files/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,47 @@ impl Fixture {
self.store.selected_parts("session", value, None)
}
}
#[test]
fn id_lookup_uses_session_authority_and_validates_stored_envelope() {
let f = Fixture::new();
let reference = f.import("image.png");
let expected = f.store.resolve("session", &reference).unwrap();
assert_eq!(
f.store.resolve_id("session", &reference.id).unwrap(),
(expected, "image/png".into())
);
assert!(f.store.resolve_id("other-session", &reference.id).is_err());
for invalid in [
format!("{}/", reference.id),
format!("{}?q=1", reference.id),
format!("{}#x", reference.id),
"../image".into(),
format!("file_{}", "A".repeat(64)),
format!("file_{}", "0".repeat(64)),
] {
assert!(f.store.resolve_id("session", &invalid).is_err());
}
let path = f.object(&reference);
let mut bytes = disk::read(&path).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 1;
disk::write(&path, bytes).unwrap();
assert!(f.store.resolve_id("session", &reference.id).is_err());
}

#[test]
fn id_lookup_rejects_stored_descriptor_for_another_id() {
let f = Fixture::new();
let reference = f.import("image.png");
let alternate = format!("file_{}", "f".repeat(64));
disk::copy(
f.object(&reference),
f.store.session_directory("session").join(&alternate),
)
.unwrap();
assert!(f.store.resolve_id("session", &alternate).is_err());
}

#[test]
fn png_and_jpeg_snapshots_survive_source_deletion_and_reopen() {
for (format, name, mime) in [
Expand Down
Loading
Loading