1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//! The one rule deciding whether a `session_new` `repo_url` names something
//! trusty-mpm can run a session on (ADR-0055, #6000).
//!
//! Why: trusty-mpm used to clone a remote `repo_url` and add a worktree for it
//! on `session_new`'s behalf. ADR-0055 removed that path, so a `repo_url` that
//! is not already a directory on the daemon host has nowhere to route. Every
//! entry point — the `session_new` MCP tool, the daemon's spawn RPC, and the
//! `tm session new` CLI — must refuse it with the same words, so the rule and
//! its message live here rather than being restated per surface.
//! What: [`is_local_workdir`], the detection heuristic itself, and
//! [`require_local_repo_url`], which turns a failing `repo_url` into the typed
//! [`NonLocalRepoUrl`] error naming ADR-0055 and the supported form.
//! Test: `local_repo_url_accepts_an_existing_directory`,
//! `local_repo_url_rejects_a_remote_url`,
//! `non_local_repo_url_message_names_adr_0055_and_the_remedy` below.
use std::path::Path;
/// A `repo_url` that is not an existing local directory (ADR-0055).
///
/// Why: the three `session_new` entry points each surface errors differently
/// (JSON string, HTTP body, rendered CLI line), so the refusal has to be a
/// value they can each carry rather than a string one of them formats. Typed
/// so a caller can match on it instead of substring-matching a message.
/// What: carries the rejected `repo_url`; its `Display` names ADR-0055, the
/// supported form, and the two-step remedy, and shows the URL with its
/// credentials redacted (#9124).
/// Test: `non_local_repo_url_message_names_adr_0055_and_the_remedy`,
/// `non_local_repo_url_message_redacts_the_url_credentials`.
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"repo_url {:?} is not an existing local directory. trusty-mpm no longer clones a \
repository or creates a worktree for a session (ADR-0055): the only supported form is an \
ABSOLUTE path to a directory that already exists on the daemon host. Clone the repository \
yourself, then pass that path — e.g. `git clone <url> <dir>` followed by \
`tm session new <dir>`.",
crate::core::remote_url_redact::redact_stored_url(.repo_url) // #9124
)]
pub struct NonLocalRepoUrl {
/// The `repo_url` as the caller supplied it.
pub repo_url: String,
}
// #9124: derived `Debug` printed the raw field; `{:?}` reaches logs and panics.
impl std::fmt::Debug for NonLocalRepoUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NonLocalRepoUrl")
.field(
"repo_url",
&crate::core::remote_url_redact::redact_stored_url(&self.repo_url),
)
.finish()
}
}
/// Whether `s` names an EXISTING local directory usable as a session workspace
/// directly, i.e. without a git clone (#1433).
///
/// Why: this used to select between the local-path branch and the clone branch.
/// Since ADR-0055 removed the clone branch it selects between running and
/// refusing, which is why it and the refusal now live in one module.
/// What: returns `true` iff `s` is an ABSOLUTE path that is a directory.
/// `is_dir()` already implies existence in a single `stat` syscall (a missing
/// path is not a dir) and follows symlinks, so a separate `exists()` probe is
/// redundant.
/// Test: `is_local_workdir_detects_absolute_dir`,
/// `is_local_workdir_rejects_url_relative_and_missing` in tests/local_spawn.rs.
pub fn is_local_workdir(s: &str) -> bool {
let p = Path::new(s);
p.is_absolute() && p.is_dir()
}
/// Refuse a `repo_url` that is not an existing local directory (ADR-0055).
///
/// Why: decision B of ADR-0055 — with the clone path gone, a non-local
/// `repo_url` must fail loudly and name the remedy rather than degrade into a
/// different placement. Called before any side effect at every entry point.
/// What: `Ok(())` when [`is_local_workdir`] accepts `repo_url`, else the typed
/// [`NonLocalRepoUrl`].
/// Test: `local_repo_url_accepts_an_existing_directory`,
/// `local_repo_url_rejects_a_remote_url`.
pub fn require_local_repo_url(repo_url: &str) -> Result<(), NonLocalRepoUrl> {
if is_local_workdir(repo_url) {
return Ok(());
}
Err(NonLocalRepoUrl {
repo_url: repo_url.to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_repo_url_accepts_an_existing_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_string_lossy().into_owned();
assert!(require_local_repo_url(&path).is_ok());
}
#[test]
fn local_repo_url_rejects_a_remote_url() {
let err = require_local_repo_url("https://github.com/owner/repo.git")
.expect_err("a remote URL has no local directory to run in");
assert_eq!(err.repo_url, "https://github.com/owner/repo.git");
}
/// The refusal must be actionable on its own — an operator reading only the
/// error has to learn WHY it cannot work and WHAT to do instead.
#[test]
fn non_local_repo_url_message_names_adr_0055_and_the_remedy() {
let msg = require_local_repo_url("git@github.com:owner/repo.git")
.expect_err("ssh-style remote is not local")
.to_string();
assert!(msg.contains("ADR-0055"), "message must cite the ADR: {msg}");
assert!(
msg.contains("tm session new"),
"message must name the two-step remedy: {msg}"
);
}
/// #9124 delta critic: the refusal reaches the client and the daemon log,
/// so a credentialed `repo_url` is shown redacted. The field keeps the raw
/// value for a caller that matches on it.
#[test]
fn non_local_repo_url_message_redacts_the_url_credentials() {
const TOKEN: &str = "ghp_9124LocalRepoSyntheticToken00";
let url = format!("https://octo:{TOKEN}@github.com/owner/repo.git");
let err = require_local_repo_url(&url).expect_err("a remote URL is not local");
let msg = err.to_string();
assert!(!msg.contains(TOKEN), "the token reached the message");
// #9124: `Debug` redacts too; the field keeps the raw value.
assert!(
!format!("{err:?}").contains(TOKEN),
"the token reached Debug"
);
assert_eq!(err.repo_url, url);
assert!(
msg.contains("https://***@github.com/owner/repo.git"),
"{msg}"
);
assert_eq!(err.repo_url, url);
// #9259: a quote or space in the password does not end the userinfo.
for url in [
"https://octo:pa'ss9259@github.com/owner/repo.git",
"https://octo:pa ss9259@github.com/owner/repo.git",
] {
let err = require_local_repo_url(url).expect_err("a remote URL is not local");
for text in [err.to_string(), format!("{err:?}")] {
assert!(!text.contains("ss9259"), "the password reached {text}");
assert!(text.contains("https://***@github.com/owner/repo.git"));
}
}
}
}