Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion crates/freshell-platform/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ edition.workspace = true
rust-version.workspace = true
publish.workspace = true

# Deterministic core needs no external crates: pure std logic with injected IO.
# Deterministic core: std + serde_json + percent-encoding, with injected IO.
# The deferred live-process modules (tokio::process/reqwest for netsh/ipconfig/etc.)
# will add deps when implemented in a later sub-step.
[dependencies]
percent-encoding = "2"
serde_json = { workspace = true }

[dev-dependencies]
Expand Down
105 changes: 104 additions & 1 deletion crates/freshell-platform/src/cli_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,18 @@ pub const CLAUDE_BELL_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile
/// launch silently disable the injected hook -- graceful degradation to
/// today's stale-resume behavior (no rebind), never corruption; freshell
/// cannot detect it.
pub const CLAUDE_SESSION_START_COMMAND_UNIX: &str = "sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$$-$(date +%s%N)\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true";
///
/// Nonce portability + ordering contract: `%N` is GNU-only (BSD/macOS echo a
/// literal `N`), so the nonce is built via a POSIX fallback -- timestamp
/// FIRST (19 digits, zero-stable width; second-granularity where %N is
/// unavailable), shell pid as tiebreaker. Digits and `-` only, so it can
/// never contain the `__` filename delimiter, and a lexicographic filename
/// sort in the consumer (`claude_signal.rs::drain`) is emission order on
/// nanosecond-precision `date` (GNU) -- deterministic last-write-wins under
/// rapid A->B->A switching. On the second-granularity fallback, same-second
/// same-terminal order degrades to pid-string order (not guaranteed emission
/// order) -- a residual sub-second ambiguity, corrected by the next signal.
pub const CLAUDE_SESSION_START_COMMAND_UNIX: &str = "sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; n=$(date +%s%N 2>/dev/null); case \"$n\" in *[!0-9]*|\"\") n=\"$(date +%s)000000000\";; esac; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$n-$$\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true";

/// Windows twin of [`CLAUDE_SESSION_START_COMMAND_UNIX`] (see its doc for the
/// semantics + A7 degradation notes): reads the hook's stdin JSON via
Expand Down Expand Up @@ -591,3 +602,95 @@ pub fn resolve_cli_launch(
#[cfg(test)]
#[path = "cli_launch_goldens.rs"]
mod cli_argv_goldens_file;

#[cfg(test)]
mod tests {
use super::*;

/// Single source of truth for the portable nonce logic embedded in
/// CLAUDE_SESSION_START_COMMAND_UNIX; the contains() test below keeps
/// the two in sync so the executable tests exercise the real snippet.
const CLAUDE_SIGNAL_NONCE_SNIPPET: &str =
"n=$(date +%s%N 2>/dev/null); case \"$n\" in *[!0-9]*|\"\") n=\"$(date +%s)000000000\";; esac";

#[test]
fn session_start_command_embeds_the_portable_nonce_snippet() {
assert!(
CLAUDE_SESSION_START_COMMAND_UNIX.contains(CLAUDE_SIGNAL_NONCE_SNIPPET),
"the unix SessionStart hook must build its nonce with the portable snippet"
);
assert!(
!CLAUDE_SESSION_START_COMMAND_UNIX.contains("__$$-"),
"nonce must be timestamp-first (pid last), not pid-first"
);
}

/// BSD/macOS `date` has no %N: it echoes a literal `N`. The snippet must
/// detect that and fall back to a zero-padded, digits-only nonce so the
/// consumer's filename sort stays correct on every platform.
#[cfg(unix)]
#[test]
fn nonce_snippet_is_all_digits_even_without_gnu_date() {
let dir =
std::env::temp_dir().join(format!("freshell-bsd-date-stub-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stub = dir.join("date");
std::fs::write(
&stub,
"#!/bin/sh\nif [ \"$1\" = \"+%s%N\" ]; then echo 1769000000N; else echo 1769000000; fi\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"PATH=\"{}:$PATH\"; {}; printf %s \"$n\"",
dir.display(),
CLAUDE_SIGNAL_NONCE_SNIPPET
))
.output()
.unwrap();
let n = String::from_utf8(out.stdout).unwrap();
assert_eq!(
n, "1769000000000000000",
"BSD fallback: seconds + 9 zero digits"
);
let _ = std::fs::remove_dir_all(&dir);
}

/// GNU date passthrough: full nanosecond precision is preserved.
#[cfg(unix)]
#[test]
fn nonce_snippet_preserves_gnu_precision() {
let dir =
std::env::temp_dir().join(format!("freshell-gnu-date-stub-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stub = dir.join("date");
std::fs::write(
&stub,
"#!/bin/sh\nif [ \"$1\" = \"+%s%N\" ]; then echo 1769000000123456789; else echo 1769000000; fi\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"PATH=\"{}:$PATH\"; {}; printf %s \"$n\"",
dir.display(),
CLAUDE_SIGNAL_NONCE_SNIPPET
))
.output()
.unwrap();
assert_eq!(
String::from_utf8(out.stdout).unwrap(),
"1769000000123456789"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
2 changes: 1 addition & 1 deletion crates/freshell-platform/src/cli_launch_goldens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::spawn::{build_windows_cli_spawn_spec, quote_powershell_literal, Shell

/// `CLAUDE_SETTINGS_UNIX` (§4 conventions) — exact compact-JSON bytes:
/// `SessionStart` (session-id signal file hook, P4) then `Stop` (bell).
const CLAUDE_SETTINGS_UNIX: &str = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$$-$(date +%s%N)\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"sh -lc \"printf '\\a' > /dev/tty 2>/dev/null || true\""}]}]}}"#;
const CLAUDE_SETTINGS_UNIX: &str = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; n=$(date +%s%N 2>/dev/null); case \"$n\" in *[!0-9]*|\"\") n=\"$(date +%s)000000000\";; esac; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$n-$$\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"sh -lc \"printf '\\a' > /dev/tty 2>/dev/null || true\""}]}]}}"#;

/// `CLAUDE_SETTINGS_WIN` — compact JSON: `SessionStart` (signal file hook,
/// `\` appears in JSON as `\\`) then `Stop` (the windows bell string;
Expand Down
81 changes: 73 additions & 8 deletions crates/freshell-platform/src/opencode_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! sources merge and plugin arrays union, so a plugin-only file can never
//! shadow the user's own TUI config.

use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use std::io::Write;
use std::path::{Path, PathBuf};

Expand All @@ -27,14 +28,48 @@ pub fn tui_config_path(home: &Path) -> PathBuf {
home.join(".freshell").join("opencode").join("tui.json")
}

/// File-URL *path* percent-encode set: byte-parity with `pathToFileURL` as
/// implemented by Node v22 AND Bun (opencode's runtime) -- oracle-verified
/// 2026-07-29 by differential sweep on the installed Bun 1.3.14 / Node
/// v22.21.1. STRICTER than the WHATWG-minimal path set: C0 controls + DEL
/// (both in `CONTROLS`), space, `"` `#` `<` `>` `?` backtick `[` `\` `]`
/// `^` `{` `|` `}` `~`, plus `%` itself so pre-existing escapes round-trip
/// literally. `/` separators and the Windows drive `:` stay bare; non-ASCII
/// bytes are always UTF-8 percent-encoded by `utf8_percent_encode`.
const FILE_URL_PATH_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}')
.add(b'~');

/// `file://` spec for the tui.json `plugin` array. Unix: `file:///abs`.
/// Windows drive paths get forward slashes and a third slash.
/// Windows drive paths get forward slashes and a third slash. The path is
/// percent-encoded to `pathToFileURL` parity (Node v22 == Bun, opencode's
/// runtime): opencode dedupes plugin origins by exact file-URL string and
/// Bun's `import()` hard-fails (silently, for the pane lifetime) on raw
/// `#`/`?`/`%`, so byte-identical output is load-bearing.
pub fn plugin_file_spec(plugin_path: &Path) -> String {
let s = plugin_path.display().to_string().replace('\\', "/");
if s.starts_with('/') {
format!("file://{s}")
let raw = plugin_path.display().to_string();
if raw.starts_with('/') {
// Posix absolute path: `\` is an ordinary filename byte here --
// percent-encoded (%5C, pathToFileURL parity), never a separator.
format!("file://{}", utf8_percent_encode(&raw, FILE_URL_PATH_SET))
} else {
format!("file:///{s}")
// Windows drive path: separators become `/`, third slash added.
let s = raw.replace('\\', "/");
format!("file:///{}", utf8_percent_encode(&s, FILE_URL_PATH_SET))
}
}

Expand Down Expand Up @@ -139,10 +174,40 @@ mod tests {
}

#[test]
fn file_spec_is_a_file_url() {
fn file_spec_is_a_canonical_file_url() {
// Expected values match `pathToFileURL(p).href` under BOTH Node v22
// and Bun 1.3.14 (opencode's runtime; oracle-verified 2026-07-29) --
// the form Bun imports and opencode dedupes plugin origins by.
let cases: &[(&str, &str)] = &[
("/a/b/p.ts", "file:///a/b/p.ts"),
("/a/b c/p.ts", "file:///a/b%20c/p.ts"),
("/a/b#c/p.ts", "file:///a/b%23c/p.ts"),
("/a/b?c/p.ts", "file:///a/b%3Fc/p.ts"),
("/a/b%41c/p.ts", "file:///a/b%2541c/p.ts"),
("/a/100%/p.ts", "file:///a/100%25/p.ts"),
(
"/home/\u{fc}n\u{ef}/p.ts",
"file:///home/%C3%BCn%C3%AF/p.ts",
),
// pathToFileURL is stricter than the WHATWG-minimal path set:
("/a/x~y/p.ts", "file:///a/x%7Ey/p.ts"),
("/a/[b]/p.ts", "file:///a/%5Bb%5D/p.ts"),
("/a/b^c/p.ts", "file:///a/b%5Ec/p.ts"),
("/a/b|c/p.ts", "file:///a/b%7Cc/p.ts"),
// posix backslash is an ordinary byte -- encoded, not a separator:
(r"/a/b\c/p.ts", "file:///a/b%5Cc/p.ts"),
];
for (input, expected) in cases {
assert_eq!(
&plugin_file_spec(Path::new(input)),
expected,
"input: {input}"
);
}
// Windows drive path: forward slashes, third slash, ':' NOT escaped.
assert_eq!(
plugin_file_spec(Path::new("/a/b c/p.ts")),
"file:///a/b c/p.ts"
plugin_file_spec(Path::new(r"C:\Users\dev\p.ts")),
"file:///C:/Users/dev/p.ts"
);
}
}
3 changes: 3 additions & 0 deletions crates/freshell-sessions/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ tracing = "0.1"
# The opencode SQLite parity test builds fixture databases with a writable connection
# before the parser opens them read-only (same bundled engine, unified by cargo).
rusqlite = { version = "0.31", features = ["bundled"] }
# codex_locator's warn-once-per-window test installs a thread-local layer
# that counts the codex_fork_ambiguous WARN events.
tracing-subscriber = "0.3"
# SESSION-07: unique temp-file names for `search.rs`'s file-content search
# tests (same version/feature set as every other crate's `uuid` dependency).
uuid = { version = "1", features = ["v4"] }
Expand Down
Loading
Loading