Skip to content
Open
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
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,14 @@ when set (otherwise `~/.codex` / `~/.claude`). MCP responses default to the
`mcp_safe` redaction profile. Upload redaction defaults to `off`, preserving raw
events as the rebuildable source of truth; opt in with `init
--upload-redaction standard`. A repository allowlist is enforced before upload
and during import. Unknown sessions fail closed. Changing upload redaction or
the repository allowlist after offsets advance requires a new bucket prefix or
an intentional ledger reset, because already-uploaded chunks are immutable and
filtered history cannot be backfilled from an advanced cursor.
and during import. An explicitly allowed local-only repository is resolved from
its working-directory path even when it intentionally has no Git remote; the
tracker stamps that canonical repository into session metadata so remote trace
queries retain the same attribution. Unknown sessions fail closed. Changing
upload redaction or the repository allowlist after offsets advance requires a
new bucket prefix or an intentional ledger reset, because already-uploaded
chunks are immutable and filtered history cannot be backfilled from an
advanced cursor.

On a systemd-based EC2 developer VM, enable lingering once so the per-user
tracker starts at boot without an SSH login, then run `init` normally:
Expand All @@ -169,11 +173,16 @@ tracker starts at boot without an SSH login, then run `init` normally:
sudo loginctl enable-linger "$USER"
```

The installed service runs from `$HOME`; its config, cursors, corpus, upload
ledger, and log therefore stay under exactly one `$HOME/.synty/` directory.

The bucket is the durable shared backplane; an S3 deployment may optionally add
Glue catalog metadata and a bounded Athena workgroup for remote trace queries.
There is still no migration, crawler, build server, or coordination service.
Each machine writes a stable `edge-<machine>-<source>` stream, so
writers do not overwrite one another. Local readers and builders pull every
writers do not overwrite one another. `init` persists the resolved machine id
and writes that same id into the login-time tracker, activation marker, and
stream names. Local readers and builders pull every
stream plus the latest published read-model. MCP-only readers pull the semantic
index and compact analysis projection. With `--athena-workgroup`, their trace
tools query time/stream-pruned raw event rows directly and do not download
Expand Down
4 changes: 3 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,9 @@ the watcher. `--capture-since` persists an absolute event boundary and
`--upload-interval` sets the network batching cadence. Optional `--campaign` /
`--role` persist campaign stamps used by later track/import runs.
`--capture-repo`, `--upload-redaction`, and `--mcp-redaction` persist the
corresponding privacy policy. A systemd
corresponding privacy policy. The resolved `--machine` identity is persisted
and reused by autostart, so activation and event streams cannot silently fall
back to `local`. A systemd
user service starts at boot on a headless developer VM when the administrator
enables lingering for that user (`loginctl enable-linger`).
The state shows on `status` and the TUI footer (`◐ local`, accent → `✓
Expand Down
45 changes: 42 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub struct Config {
/// defaults to it; an explicit --bucket flag still wins.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
/// Resolved machine id used by the login-time tracker. Persisting it keeps
/// the autostart unit aligned with the activation marker and stream names.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine: Option<String>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Named AWS shared-config profile used by the S3 credential chain. For an
/// unattended workstation this should use credential_process (including
/// IAM Roles Anywhere); ordinary shared credentials also work. Empty means
Expand Down Expand Up @@ -171,14 +175,25 @@ pub fn captured_at(ts: &str, cutoff_ms: Option<i64>) -> bool {
}

pub fn load() -> Config {
std::fs::read_to_string(PATH).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
load_from(Path::new(PATH))
}

pub fn save(c: &Config) -> Result<()> {
if let Some(dir) = Path::new(PATH).parent() {
save_to(Path::new(PATH), c)
}

fn load_from(path: &Path) -> Config {
std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}

fn save_to(path: &Path, c: &Config) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
crate::write_atomic(PATH, serde_json::to_string_pretty(c)?.as_bytes())?;
let path = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("config path must be valid UTF-8"))?;
crate::write_atomic(path, serde_json::to_string_pretty(c)?.as_bytes())?;
Ok(())
}

Expand All @@ -202,4 +217,28 @@ mod tests {
assert!(captured_at("2026-07-21T00:00:00Z", Some(cutoff)));
assert!(captured_at("future-envelope-time", Some(cutoff)));
}

#[test]
fn machine_identity_roundtrips_and_legacy_config_defaults_to_none() {
let root = std::env::temp_dir().join(format!(
"synty-machine-config-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
let path = root.join("config.json");
let configured = Config {
machine: Some("sie-dev-cuda-rust".into()),
..Default::default()
};

save_to(&path, &configured).unwrap();
assert_eq!(
load_from(&path).machine.as_deref(),
Some("sie-dev-cuda-rust")
);

std::fs::write(&path, r#"{"bucket":"s3://team"}"#).unwrap();
assert!(load_from(&path).machine.is_none());
let _ = std::fs::remove_dir_all(&root);
}
}
15 changes: 14 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub fn run(opts: Opts) -> Result<()> {
no_build,
no_autostart,
} = opts;
let machine = identity::resolve_machine(&machine);
let mut cfg = config::load();

// 1. Bucket: setting it is the local→bucket switch; absent → stay local
Expand Down Expand Up @@ -73,6 +74,7 @@ pub fn run(opts: Opts) -> Result<()> {
if let Some(role) = role.filter(|value| !value.is_empty()) {
cfg.campaign_role = Some(role);
}
cfg.machine = Some(machine.clone());

// 2. GitHub identity — best-effort, no prompts. With a token, pin the login
// (so sessions merge with the person's PRs) and default the backfill org
Expand Down Expand Up @@ -114,7 +116,7 @@ pub fn run(opts: Opts) -> Result<()> {
if no_autostart {
eprintln!("init: autostart skipped — run `synty track --watch` under your supervisor");
} else {
track::autostart_set(true).context(
track::autostart_set_for_machine(true, Some(&machine)).context(
"enable login-time tracker (configuration was saved, but initialization is not complete)",
)?;
eprintln!("init: login-time tracker enabled");
Expand Down Expand Up @@ -165,4 +167,15 @@ mod tests {
assert!(root.join("members/dev-b/activation.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn explicit_machine_is_resolved_once_for_activation_and_tracking() {
let requested = "runner/7";
let resolved = identity::resolve_machine(requested);
assert_eq!(resolved, "runner-7");
assert_eq!(
track::autostart_machine(Some(&resolved), Some("stale-machine")),
"runner-7"
);
}
}
11 changes: 6 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,9 +696,9 @@ enum TraceCmd {

/// Run from the synty home: an explicit $SYNTY_HOME wins; a cwd that already
/// holds synty state (.synty/) is its own home (the dev-checkout case); else
/// fall back to ~/.synty when the installer created it. Every state path in
/// the binary is home-relative, so this one chdir makes `synty tui` work from
/// any directory.
/// fall back to $HOME when the installer created ~/.synty. Every state path in
/// the binary already includes `.synty/`, so this one chdir makes `synty tui`
/// work from any directory without nesting the state directory.
fn resolve_home() {
if let Ok(h) = std::env::var("SYNTY_HOME") {
if let Err(e) = std::env::set_current_dir(&h) {
Expand All @@ -711,8 +711,9 @@ fn resolve_home() {
}
if let Ok(home) = std::env::var("HOME") {
let d = std::path::Path::new(&home).join(".synty");
if d.is_dir() && std::env::set_current_dir(&d).is_ok() {
eprintln!("synty: home {}", d.display());
let workdir = track::installed_workdir(std::path::Path::new(&home));
if d.is_dir() && std::env::set_current_dir(&workdir).is_ok() {
eprintln!("synty: home {}", workdir.display());
}
}
}
Expand Down
30 changes: 29 additions & 1 deletion src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub(crate) struct Server {
scope: crate::policy::ReadScope,
redaction: crate::redact::Profile,
allow_repo_paths: bool,
bucket: Option<String>,
trace: TraceBackend,
/// The build the engine serves + encoder + index — loaded on the first
/// search call, kept warm, reopened when the pointer moves.
Expand All @@ -216,6 +217,7 @@ impl Server {
scope,
redaction,
allow_repo_paths,
bucket,
trace,
engine: None,
}
Expand Down Expand Up @@ -264,7 +266,9 @@ impl Server {
"synty_related" => self.related(a),
"synty_topics" => topics_text(a, &self.scope),
"synty_recent" => recent_text(a, &self.scope),
"synty_status" => view::status().map(|s| view::status_md(&s)),
"synty_status" => {
view::status_for_bucket(self.bucket.as_deref()).map(|s| view::status_md(&s))
}
"synty_stats" => view::stats(bounded_positive(a, "weeks", 4, 52)).map(|s| view::stats_md(&s)),
"synty_tool" => {
let name = a["name"].as_str().unwrap_or("");
Expand Down Expand Up @@ -753,6 +757,30 @@ mod tests {
assert!(called["error"]["message"].as_str().unwrap().contains("restricted"));
}

#[test]
fn status_reports_the_server_bucket_without_local_config() {
let mut server = Server::new(
"m".into(),
crate::policy::McpRole::Operator,
crate::policy::ReadScope::default(),
crate::redact::Profile::Off,
true,
Some("s3://team-synty".into()),
None,
);
let response = server
.handle(&json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"synty_status","arguments":{}}}))
.unwrap();
assert_eq!(response["result"]["isError"], false);
assert!(
response["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("✓ on the team — s3://team-synty")
);
}

#[test]
fn remote_related_schema_requires_context_and_hides_server_paths() {
let tools = tool_defs(
Expand Down
37 changes: 36 additions & 1 deletion src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ fn push_events_scoped(
let profile = crate::config::upload_redaction();
let config = crate::config::load();
let capture_repos = config.capture_repos;
let known_repos: std::collections::HashSet<String> = config.repos.into_iter().collect();
let known_repos = upload_known_repos(config.repos, &capture_repos);
let profile_name = profile.as_str();
ensure_upload_policy_compatible(
&state,
Expand Down Expand Up @@ -398,6 +398,19 @@ fn push_events_scoped(
Ok(n)
}

/// Repository names that can be resolved without consulting a checkout's Git
/// remote. Explicit upload policy is part of this set: local-only repositories
/// have no remote by design, but their cwd still identifies them unambiguously.
fn upload_known_repos(
configured: Vec<String>,
capture_repos: &[String],
) -> std::collections::HashSet<String> {
configured
.into_iter()
.chain(capture_repos.iter().cloned())
.collect()
}

fn ensure_upload_policy_compatible(
state: &UploadState,
bucket_uri: &str,
Expand Down Expand Up @@ -1139,6 +1152,28 @@ mod tests {
assert!(!filtered.contains("not-json"));
}

#[test]
fn explicitly_allowed_local_repo_resolves_without_a_git_remote() {
let capture_repos = vec!["sie-harness".to_string()];
let known_repos = upload_known_repos(Vec::new(), &capture_repos);
let raw = concat!(
r#"{"session_id":"local","kind":"session_start","payload":{"cwd":"/mnt/cache/workspaces/sie-harness"}}"#,
"\n",
r#"{"session_id":"other","kind":"session_start","payload":{"cwd":"/mnt/cache/workspaces/unlisted-local"}}"#,
"\n",
);
let mut allowed = BTreeSet::new();

update_allowed_sessions(
raw.as_bytes(),
&capture_repos,
&known_repos,
&mut allowed,
);

assert_eq!(allowed, BTreeSet::from(["local".to_string()]));
}

#[test]
fn upload_redaction_removes_bearer_tokens_while_off_is_lossless() {
let raw = concat!(
Expand Down
41 changes: 40 additions & 1 deletion src/trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,14 @@ impl TraceStore {
if ctx.role.is_empty() {
ctx.role = crate::policy::role(ev).to_string();
}
if ctx.repo.is_empty() {
ctx.repo = ev.payload["repo"].as_str().unwrap_or("").to_string();
}
if let Some(cwd) = event_cwd(&ev.payload).filter(|cwd| !cwd.is_empty()) {
if ctx.cwd.is_empty() {
ctx.repo = crate::units::resolve_repo(&cwd, known);
if ctx.repo.is_empty() {
ctx.repo = crate::units::resolve_repo(&cwd, known);
}
Comment on lines +485 to +492

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only accept repository stamps from the trusted session-start event.

fold_context runs for every event, so any event with a top-level repo field can become authoritative session context. In a bounded remote trace where session_start is absent or arrives later, this can misattribute the session and all downstream spans/units, while also preventing the cwd or later session-start fallback from correcting it. Gate this assignment on the trusted session-start event (and add a scenario covering a non-session event with repo).

Suggested fix
-        if ctx.repo.is_empty() {
+        if ev.kind == "session_start" && ctx.repo.is_empty() {
             ctx.repo = ev.payload["repo"].as_str().unwrap_or("").to_string();
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ctx.repo.is_empty() {
ctx.repo = ev.payload["repo"].as_str().unwrap_or("").to_string();
}
if let Some(cwd) = event_cwd(&ev.payload).filter(|cwd| !cwd.is_empty()) {
if ctx.cwd.is_empty() {
ctx.repo = crate::units::resolve_repo(&cwd, known);
if ctx.repo.is_empty() {
ctx.repo = crate::units::resolve_repo(&cwd, known);
}
if ctx.repo.is_empty() {
ctx.repo = ev.payload["repo"].as_str().unwrap_or("").to_string();
}
if let Some(cwd) = event_cwd(&ev.payload).filter(|cwd| !cwd.is_empty()) {
if ctx.cwd.is_empty() {
if ctx.repo.is_empty() {
ctx.repo = crate::units::resolve_repo(&cwd, known);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/trace.rs` around lines 485 - 492, Update the repository assignment in
fold_context so the top-level ev.payload["repo"] value is accepted only when
processing the trusted session-start event; otherwise leave ctx.repo available
for the cwd-based or later session-start fallback. Add a scenario covering a
non-session event containing repo and verify it does not establish authoritative
session context.

ctx.cwd = cwd;
}
}
Expand Down Expand Up @@ -2917,6 +2922,40 @@ mod tests {
assert_eq!(hits[0].repo, "repo");
}

#[test]
fn remote_trace_uses_edge_stamped_repo_without_a_local_checkout() {
let lines = [
ev(
"start",
"2026-06-01T10:00:00Z",
"codex_cli",
"S",
"session_start",
json!({"cwd":"/mnt/cache/workspaces/sie-harness","repo":"sie-harness"}),
),
ev(
"call",
"2026-06-01T10:00:01Z",
"codex_cli",
"S",
"tool_call",
json!({"name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"pytest\"}"}),
),
ev(
"result",
"2026-06-01T10:00:02Z",
"codex_cli",
"S",
"tool_result",
json!({"call_id":"c1","output":"Process exited with code 0"}),
),
];
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let store = TraceStore::from_lines(&refs);

assert_eq!(store.spans[0].repo, "sie-harness");
}

#[test]
fn published_trace_excludes_pre_boundary_evidence_but_keeps_context() {
let lines = [
Expand Down
Loading
Loading