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
1 change: 1 addition & 0 deletions Cargo.lock

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

17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ A tiny coding agent with a shared thread. Its native terminal client and browser
```sh
tress setup # connect to a host; no personal API key needed
tress # join your saved thread
tress attach -s <id> --chat-only # join another thread on that host
tress attach -s <id> # join another thread on that host
```

[Install and configuration guide](docs/setup.md). Use v0.3.0 or newer for connected
folders and environment-based startup. The v0.1.0 binary requires an explicit
URL with `tress attach`.
[Install and configuration guide](docs/setup.md). Published v0.3.0 supports
chat attachment, `tress connect` for paired folders, and environment-based
startup. Combined `attach` file sharing (`--root`, `--allow-write`, and
`--chat-only`) is a **source preview**: build this checkout before using those
options below. The installer currently downloads v0.3.0.

**Existing assistant-ui applications:** the [source preview](examples/existing-app/README.md)
connects the browser and terminal to your server-owned Harness thread, with
Expand Down Expand Up @@ -69,9 +71,10 @@ or the [site reference](site/README.md) for storage details.

The managed public demo can also use a visitor's local folder without giving
the visitor a Harness key. Run
`tress attach <site-url> -s <session-id> --root . --ui` in that folder, then
enter its pairing code in the same browser chat. The terminal UI opens after
pairing and keeps folder access running until you exit. Use `--root <path>` to
`tress attach <site-url> -s <session-id> --root . --ui` in that folder. With the
updated host and CLI, the session command connects directly and opens the terminal
UI. Hosts can require a browser pairing code with `TRESS_REQUIRE_PAIRING=1`.
Folder access stays active until you exit. Use `--root <path>` to
choose another folder. Omit `--root` to share the whole filesystem accessible
to your process, or use `--chat-only` for no file sharing. Access is read-only by default; add `--allow-write` to
permit file edits. This native process serves scoped file requests and does
Expand Down
3 changes: 3 additions & 0 deletions crates/tress/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ unicode-width = "0.2"
unicode-segmentation = "1.12"
tui-markdown = { version = "0.3.10", default-features = false }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[[bin]]
name = "tress"
path = "src/main.rs"
Expand Down
95 changes: 83 additions & 12 deletions crates/tress/src/connect.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
//! An explicitly rooted file connector for the public site. Harness and model
//! credentials remain on the site; this process receives only scoped file jobs.

use std::io::Read;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::time::Duration;

use cap_std::ambient_authority;
use cap_std::fs::Dir;
#[cfg(unix)]
use cap_std::fs::OpenOptionsExt;
use cap_std::fs::{Dir, File, OpenOptions};
use serde::{Deserialize, Serialize};

#[derive(Debug)]
Expand All @@ -23,13 +25,32 @@ struct Offer<'a> {
label: &'a str,
root_label: &'a str,
writable: bool,
direct: bool,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Offered {
code: String,
code: Option<String>,
token: String,
#[serde(default)]
paired: bool,
}

impl Offered {
fn pairing_code(&self, scoped: bool) -> Result<Option<&str>, String> {
if self.paired {
if !scoped {
return Err("A connection without a session must be paired in the browser.".into());
}
return Ok(None);
}
self.code
.as_deref()
.filter(|code| !code.is_empty())
.map(Some)
.ok_or_else(|| "The host did not provide a connection or pairing code.".into())
}
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -199,6 +220,27 @@ fn safe_path(
Ok(current)
}

// Validate the opened handle before reading or truncating it. O_NONBLOCK also
// prevents a regular file replaced by a FIFO during open from hanging the CLI.
fn open_regular_file(root: &Dir, path: &Path, write: bool) -> Result<File, String> {
let error = "only regular files can be read or written";
match root.metadata(path) {
Ok(metadata) if !metadata.is_file() => return Err(error.into()),
Ok(_) => {}
Err(error) if write && error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.to_string()),
}
let mut options = OpenOptions::new();
options.read(!write).write(write).create(write);
#[cfg(unix)]
options.custom_flags(libc::O_NONBLOCK);
let file = root.open_with(path, &options).map_err(|e| e.to_string())?;
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err(error.into());
}
Ok(file)
}

fn execute_scoped(
root: &Dir,
writable: bool,
Expand All @@ -215,7 +257,7 @@ fn execute_scoped(
if metadata.len() > 1_048_576 {
return Err("file exceeds the 1 MB read limit".into());
}
let file = root.open(path).map_err(|error| error.to_string())?;
let file = open_regular_file(root, &path, false)?;
let mut content = String::new();
file.take(1_048_577)
.read_to_string(&mut content)
Expand All @@ -231,7 +273,7 @@ fn execute_scoped(
for entry in root.read_dir(path).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let kind = entry.file_type().map_err(|error| error.to_string())?;
if kind.is_symlink() {
if !kind.is_file() && !kind.is_dir() {
continue;
}
entries.push(Entry {
Expand Down Expand Up @@ -261,7 +303,9 @@ fn execute_scoped(
if root.open_dir(parent).is_err() {
return Err("parent directory does not exist".into());
}
root.write(path, content)
let mut file = open_regular_file(root, &path, true)?;
file.set_len(0).map_err(|error| error.to_string())?;
file.write_all(content.as_bytes())
.map_err(|error| error.to_string())?;
Ok(serde_json::Value::String(format!("Wrote {}", job.path)))
}
Expand Down Expand Up @@ -290,6 +334,7 @@ pub struct Connection {
token: String,
session: Option<String>,
failures: u32,
requires_pairing: bool,
}

impl Connection {
Expand Down Expand Up @@ -339,6 +384,7 @@ impl Connection {
token: String::new(),
session: session.map(str::to_owned),
failures: 0,
requires_pairing: true,
};
let root_label = root_label(&connection.options.root);
let response = connection
Expand All @@ -348,6 +394,7 @@ impl Connection {
label: &connection.options.label,
root_label: &root_label,
writable: connection.options.writable,
direct: session.is_some(),
})
.send()
.await
Expand All @@ -359,15 +406,21 @@ impl Connection {
));
}
let offered: Offered = response.json().await.map_err(|e| e.to_string())?;
let code = offered.pairing_code(session.is_some())?.map(str::to_owned);
connection.requires_pairing = code.is_some();
connection.token = offered.token;
let mut browser_url = connection.options.site.clone();
if let Some(session) = session {
browser_url
.query_pairs_mut()
.append_pair("session", session);
}
println!("Pair this folder at {browser_url}");
println!("Code: {} (expires in 5 minutes)", offered.code);
if let Some(code) = code {
println!("Pair this folder at {browser_url}");
println!("Code: {code} (expires in 5 minutes)");
} else {
println!("Connecting this folder to {browser_url}");
}
println!("Folder: {}", connection.options.root.display());
if connection.options.root.parent().is_none() {
println!("Scope: Whole filesystem accessible to this process, subject to OS permissions. Use --root <folder> to restrict access.");
Expand Down Expand Up @@ -524,11 +577,13 @@ pub async fn prepare_attached(
args.push("--allow-write".into());
}
let mut connection = Connection::open(options(&args)?, Some(session)).await?;
println!(
"Enter this code in that chat's connect folder form. The terminal opens after pairing."
);
if connection.requires_pairing {
println!(
"Enter this code in that chat's connect folder form. The terminal opens after pairing."
);
}
connection.wait_for_pair().await?;
println!("Folder paired. Opening the shared chat…");
println!("Folder connected. Opening the shared chat…");
Ok(connection)
}

Expand All @@ -548,6 +603,22 @@ mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn offers_accept_direct_sessions_and_legacy_pairing_but_reject_unscoped_auto_connect() {
let direct: Offered =
serde_json::from_str(r#"{"token":"device-token","paired":true}"#).unwrap();
assert_eq!(direct.pairing_code(true).unwrap(), None);
assert!(direct.pairing_code(false).is_err());
let legacy: Offered = serde_json::from_str(
r#"{"token":"device-token","code":"pair-code","expiresInSeconds":300}"#,
)
.unwrap();
assert_eq!(legacy.pairing_code(true).unwrap(), Some("pair-code"));
assert_eq!(legacy.pairing_code(false).unwrap(), Some("pair-code"));
let invalid: Offered = serde_json::from_str(r#"{"token":"device-token"}"#).unwrap();
assert!(invalid.pairing_code(true).is_err());
}

fn execute(root: &Dir, writable: bool, job: &Job) -> JobResult {
execute_scoped(root, writable, job, None)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tress/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ fn usage() -> String {
tress attach -s <id> join a thread on the saved host\n \
tress attach <url> join a thread with plain terminal output\n \
tress attach <url> --ui opt into the full terminal interface\n \
tress attach <site> -s <id> pair this machine's filesystem, then chat (--session also works)\n \
tress attach <site> -s <id> connect this machine's filesystem and chat (--session also works)\n \
tress attach <site> -s <id> --chat-only join without sharing files\n \
tress attach <site> -s <id> --root . --allow-write --ui share a folder and chat\n \
tress attach <url> --headers-file <path> use app credentials from a private JSON file\n \
Expand Down
54 changes: 42 additions & 12 deletions docs/connected-folder.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# Connect a computer or VM workspace

**Source preview:** combined `attach` file sharing below requires a CLI built
from this checkout. The installer currently provides v0.3.0, where `attach`
joins chat and `tress connect --site <url> --root .` shares a folder from a
separate terminal. See [installation and release compatibility](setup.md#install).

The site owns the model credentials and, in managed mode, the Harness credentials. `tress attach` does
not log into Harness, need an API key, or open an inbound port. It offers a filesystem
to one browser-owned demo thread through a short-lived pairing code.
to the browser thread authorized by the session command. With the updated host
and CLI, no pairing code is needed by default. The host can opt into an extra
browser-owner approval with `TRESS_REQUIRE_PAIRING=1`.

This connection behavior is in the source preview; it requires an updated CLI
and host. Older clients and hosts retain their pairing-code flow.

## Visitor steps

Expand All @@ -22,11 +32,12 @@ to one browser-owned demo thread through a short-lived pairing code.
commands are exposed. Symlink paths and special files remain unavailable.

Whole-filesystem access includes personal files readable by that process.
The CLI prints the exact scope before pairing, and the browser labels it
The CLI prints the exact scope when connecting, and the browser labels it
**Whole filesystem**. No file content is sent until a file is requested.
3. Enter the code printed by the binary in the browser's pairing form. The
code expires after five minutes. Pair in the same chat as the command.
After pairing, the terminal UI opens and the same process serves file requests.
3. The session command connects the folder directly to that chat and opens the
terminal UI. If the host requires pairing, or either side uses an older
version, enter the printed code in that chat's **connect folder** form first.
Codes expire after five minutes. The same process serves file requests.
Omit `--ui` for plain terminal output. The browser shows the folder label,
permissions, and actual online/offline state. The file browser opens with
the real directory listing. Open subfolders and files, or use **Refresh**
Expand All @@ -41,20 +52,24 @@ to one browser-owned demo thread through a short-lived pairing code.
access. The managed conversation remains available when the device is
offline, but local file operations will not silently switch to demo files.

The binary prints the exact canonical root before pairing. Whole-filesystem
The binary prints the exact canonical root when connecting. Whole-filesystem
connections accept absolute paths such as `/Users/mac/oss/riftri/README.md`;
folder-scoped connections require paths relative to that folder. It keeps a
revocable, thread-scoped device token in memory for that process only; it
does not save the token or the Harness project key. Restarting it currently
requires a new pairing. Temporary network failures are retried while the
connects again using the session command; when the host requires pairing, it
requires a fresh code. Temporary network failures are retried while the
process stays open. Connected folders do not mirror the whole directory
to the site: the browser and agent request individual paths. Browser previews are loaded
on demand; selecting a file alone does not call the model. Agent tool output
may be sent to the model provider and stored in the conversation.

The thread owner cookie can pair or disconnect a folder. A shared `-s` thread
attach ID can join a conversation, browse files, and ask the agent to use the
connected folder, but cannot manage its device. Treat the attach link as access to the
The thread owner cookie can approve a pairing code or disconnect a folder.
By default, a shared `-s` session command can also attach a machine or replace
the thread's connected machine, as well as join the conversation and use its
files. With `TRESS_REQUIRE_PAIRING=1`, attaching or replacing a machine additionally
requires the browser owner's approval. Session commands do not transfer browser
ownership or grant access to the owner's other threads. Treat the attach link as access to the
folder while the connector is online; do not share it with people you would
not trust with those files. Pairing a
second folder to the same thread revokes the first connection. Keep the
Expand All @@ -75,6 +90,19 @@ never be sent to the browser or the native binary. The site routes scoped
read/list/write jobs through PostgreSQL; a long-running gateway service is
not required for this first version.

To require a short-lived pairing code for new session connections, set this on
the **host**, not on the user's terminal:

```dotenv
TRESS_REQUIRE_PAIRING=1
```

Unset it or use `0` for direct session attachment. The browser reflects the host
policy; the CLI cannot override a host that requires pairing. Existing connected
devices are not revoked by changing this setting; disconnect them separately.
Direct attachment still validates the session before creating a device, checks
that session on polling and results, and keeps file permissions and revocation.

Pairing also works with a long-running local thread host (`TRESS_THREAD_MODE=local`)
and a configured model; Harness Cloud is not needed for that setup.
New threads start without seeded example files. Folder attachment requires the
Expand All @@ -85,10 +113,12 @@ filesystem sharing applies to the explicit `attach` command.

The older `tress connect --site <url> [--root <folder>] [--allow-write]` command
remains available for a folder-only process without terminal chat. It uses the
current directory by default and the same browser pairing form.
current directory by default. Because it has no session, it always uses a code
to select and authorize the destination browser thread. On a host that connects
sessions directly, choose **Have a pairing code?** to reveal that form.

For local development without PostgreSQL, the connector store is in memory.
It resets when the dev server restarts, so reconnect and pair again after a
It resets when the dev server restarts, so reconnect after a
restart. Public deployments should use PostgreSQL, HTTPS, request-rate
limits, and the site's existing session/run quotas. One connector currently
serves one thread; the binary polls approximately once per second. A file
Expand Down
7 changes: 5 additions & 2 deletions docs/public-demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,11 @@ limits.
The session ID is an **access capability**, not a login or a database
password. Anyone with the complete ID or browser link can join and steer that
thread, including using its paired folder while it is online. Do not post it
publicly. Pairing and disconnecting a folder require the original browser's
owner cookie; sharing the ID does not transfer ownership. For a non-demo
publicly. Updated `tress attach <site> -s <id>` commands can attach or replace a
machine directly using that capability. Hosts can require a browser-owner
pairing code with `TRESS_REQUIRE_PAIRING=1`; the unscoped `connect` command above
always requires one. Approving codes and disconnecting from the browser require
the owner cookie; sharing the ID does not transfer ownership. For a non-demo
product, add your own user authentication and authorization before treating
these anonymous sessions as private accounts.

Expand Down
Loading
Loading