Skip to main content

opencode_ctl/
pairing.rs

1//! The pairing contract on disk: identity resolution, the pair record, the `claim_file` primitive
2//! and the liveness sentinels (spec "Pairing contract"; files are `pair.<pair-id>.*` in the ctl home).
3
4use std::fs::{File, OpenOptions};
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9use serde::{Deserialize, Serialize};
10
11use crate::engine::{valid_session_id, EnvSnapshot};
12use crate::error::{ErrorCode, ToolError};
13use crate::liveness::Sentinel;
14use crate::settings::{Settings, Source};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IdentitySource {
18    Env,
19    File,
20    None,
21}
22
23/// Who this server is paired with. `source` is `File` only when a record was read and parsed.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Identity {
26    pub session_id: Option<String>,
27    pub directory: Option<PathBuf>,
28    pub server_url: Option<String>,
29    pub pair_id: Option<String>,
30    pub source: IdentitySource,
31}
32
33impl Identity {
34    fn unpaired() -> Self {
35        Identity {
36            session_id: None,
37            directory: None,
38            server_url: None,
39            pair_id: None,
40            source: IdentitySource::None,
41        }
42    }
43}
44
45/// `pair.<id>.json`.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Record {
48    pub pair_id: String,
49    pub session_id: String,
50    pub directory: PathBuf,
51    #[serde(default)]
52    pub server_url: Option<String>,
53    pub created: i64,
54    #[serde(default)]
55    pub controller: Option<String>,
56    pub generation: u32,
57    pub tab: bool,
58}
59
60/// `^[0-9a-f]{12}$`.
61pub fn valid_pair_id(id: &str) -> bool {
62    id.len() == 12 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
63}
64
65fn invalid_pair(id: &str) -> ToolError {
66    ToolError::new(
67        ErrorCode::InvalidArgument,
68        format!("'{}' is not a pair id", crate::compact::redact_text(id)),
69    )
70    .hint("pair ids match ^[0-9a-f]{12}$")
71}
72
73/// Env vars first; only `OPENCODE_CTL_PAIR` set means the record supplies the rest. Returns the
74/// identity and the startup warnings (logged by the caller). An invalid id leaves the server unpaired.
75pub fn resolve_identity(env: &EnvSnapshot, home: &Path) -> (Identity, Vec<ToolError>) {
76    let mut warnings = Vec::new();
77    if let Some(p) = env.pair.as_deref() {
78        if !valid_pair_id(p) {
79            warnings.push(
80                invalid_pair(p).hint("OPENCODE_CTL_PAIR is ignored; this server runs unpaired"),
81            );
82        }
83    }
84    if let Some(s) = env.session.as_deref() {
85        if !valid_session_id(s) {
86            warnings.push(
87                ToolError::new(
88                    ErrorCode::InvalidArgument,
89                    format!(
90                        "OPENCODE_CTL_SESSION '{}' is not a session id",
91                        crate::compact::redact_text(s)
92                    ),
93                )
94                .hint("session ids match ^ses_[A-Za-z0-9]{26}$; this server runs unpaired"),
95            );
96        }
97    }
98    if !warnings.is_empty() {
99        return (Identity::unpaired(), warnings);
100    }
101    if env.session.is_some() {
102        return (
103            Identity {
104                session_id: env.session.clone(),
105                directory: env.directory.clone(),
106                server_url: env.server.clone(),
107                pair_id: env.pair.clone(),
108                source: IdentitySource::Env,
109            },
110            warnings,
111        );
112    }
113    let Some(pair) = env.pair.clone() else {
114        let any = env.directory.is_some() || env.server.is_some();
115        return (
116            Identity {
117                session_id: None,
118                directory: env.directory.clone(),
119                server_url: env.server.clone(),
120                pair_id: None,
121                source: if any {
122                    IdentitySource::Env
123                } else {
124                    IdentitySource::None
125                },
126            },
127            warnings,
128        );
129    };
130    let fallback = Identity {
131        pair_id: Some(pair.clone()),
132        ..Identity::unpaired()
133    };
134    match read_record(home, &pair) {
135        Ok(Some(r)) if valid_session_id(&r.session_id) => (
136            Identity {
137                session_id: Some(r.session_id),
138                directory: env.directory.clone().or(Some(r.directory)),
139                server_url: env.server.clone().or(r.server_url),
140                pair_id: Some(pair),
141                source: IdentitySource::File,
142            },
143            warnings,
144        ),
145        Ok(_) => {
146            warnings.push(
147                ToolError::new(
148                    ErrorCode::PairNotFound,
149                    format!("no usable pair record for {pair}: running with the pair id only"),
150                )
151                .hint("restart the controller with `opencode-pair resume <pair-id>`"),
152            );
153            (fallback, warnings)
154        }
155        Err(e) => {
156            warnings.push(e);
157            (fallback, warnings)
158        }
159    }
160}
161
162fn has_userinfo(url: &str) -> bool {
163    let rest = url.split_once("://").map_or(url, |(_, r)| r);
164    rest.split('/').next().is_some_and(|a| a.contains('@'))
165}
166
167/// Write the resolved identity into the environment snapshot and, for a `File` identity, the
168/// record's `server_url` into the settings: `settings.server_url = identity.server_url` (a null
169/// record means the background service, exactly as an empty `OPENCODE_CTL_SERVER` does) with the
170/// source `Env`, overriding a user-file value. `Env` and `None` identities (a missing or
171/// unparsable record yields `None`) leave the settings untouched. A record URL carrying userinfo
172/// is ignored: the settings keep their current value and source, and `env.server` is cleared.
173#[must_use = "the warnings must reach the startup log"]
174pub fn apply_identity(
175    env: &mut EnvSnapshot,
176    settings: &mut Settings,
177    id: &Identity,
178) -> Vec<ToolError> {
179    let env_server = env.server.clone();
180    env.session = id.session_id.clone();
181    env.directory = id.directory.clone();
182    env.server = id.server_url.clone();
183    env.pair = id.pair_id.clone();
184    if id.source != IdentitySource::File {
185        return Vec::new();
186    }
187    if let Some(url) = id.server_url.as_deref().filter(|u| has_userinfo(u)) {
188        let origin = if env_server.as_deref() == Some(url) {
189            "OPENCODE_CTL_SERVER"
190        } else {
191            "the pair record's server_url"
192        };
193        env.server = None;
194        return vec![ToolError::new(
195            ErrorCode::InvalidArgument,
196            format!(
197                "{origin} ({}) carries userinfo and is ignored",
198                crate::compact::redact_text(url)
199            ),
200        )
201        .hint("put credentials in OPENCODE_SERVER_PASSWORD, not in the URL")];
202    }
203    settings.server_url = id.server_url.clone();
204    settings.sources.insert("server_url", Source::Env);
205    Vec::new()
206}
207
208/// `resume` talks to the server its pair was made for: the record's `server_url` is applied to the settings
209/// exactly as `apply_identity` applies a File identity (a null value is the background service, a URL with
210/// userinfo is ignored). A missing or unreadable record changes nothing; `resume` itself reports that.
211#[must_use = "the warnings must reach the startup log"]
212pub fn apply_record_server(
213    home: &Path,
214    pair_id: &str,
215    env: &mut EnvSnapshot,
216    settings: &mut Settings,
217) -> Vec<ToolError> {
218    let Ok(Some(record)) = read_record(home, pair_id) else {
219        return Vec::new();
220    };
221    let id = Identity {
222        session_id: Some(record.session_id),
223        directory: Some(record.directory),
224        server_url: record.server_url,
225        pair_id: Some(pair_id.to_string()),
226        source: IdentitySource::File,
227    };
228    apply_identity(env, settings, &id)
229}
230
231// ------------------------------------------------------------------ files
232
233pub fn record_path(home: &Path, id: &str) -> PathBuf {
234    home.join(format!("pair.{id}.json"))
235}
236
237fn lock_path(home: &Path, id: &str) -> PathBuf {
238    home.join(format!("pair.{id}.lock"))
239}
240
241fn ensure_dir(dir: &Path) -> Result<(), ToolError> {
242    #[cfg(unix)]
243    {
244        use std::os::unix::fs::DirBuilderExt;
245        std::fs::DirBuilder::new()
246            .recursive(true)
247            .mode(0o700)
248            .create(dir)?;
249    }
250    #[cfg(not(unix))]
251    std::fs::create_dir_all(dir)?;
252    Ok(())
253}
254
255fn open_new(path: &Path, mode: u32) -> std::io::Result<File> {
256    let mut o = OpenOptions::new();
257    o.write(true).create_new(true);
258    #[cfg(unix)]
259    {
260        use std::os::unix::fs::OpenOptionsExt;
261        o.mode(mode);
262    }
263    #[cfg(not(unix))]
264    let _ = mode;
265    o.open(path)
266}
267
268fn temp_sibling(path: &Path) -> PathBuf {
269    let mut name = path.file_name().unwrap_or_default().to_os_string();
270    name.push(format!(".tmp-{}", uuid::Uuid::new_v4()));
271    path.with_file_name(name)
272}
273
274fn write_temp(path: &Path, content: &[u8], mode: u32) -> Result<PathBuf, ToolError> {
275    let tmp = temp_sibling(path);
276    let mut f = open_new(&tmp, mode)?;
277    if let Err(e) = f.write_all(content).and_then(|()| f.flush()) {
278        drop(f);
279        let _ = std::fs::remove_file(&tmp);
280        return Err(e.into());
281    }
282    Ok(tmp)
283}
284
285/// Create `path` with `content` only if it does not exist, complete from the first moment it is
286/// visible (temp + `hard_link`). `Ok(false)` when `path` already exists.
287pub fn claim_file(path: &Path, content: &[u8], mode: u32) -> Result<bool, ToolError> {
288    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
289        ensure_dir(dir)?;
290    }
291    let tmp = write_temp(path, content, mode)?;
292    let linked = std::fs::hard_link(&tmp, path);
293    let _ = std::fs::remove_file(&tmp);
294    match linked {
295        Ok(()) => Ok(true),
296        Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
297        Err(e)
298            if matches!(
299                e.kind(),
300                ErrorKind::Unsupported | ErrorKind::PermissionDenied | ErrorKind::CrossesDevices
301            ) =>
302        {
303            static LOGGED: OnceLock<()> = OnceLock::new();
304            LOGGED.get_or_init(|| tracing::warn!("claim fallback: {:?}", e.kind()));
305            match open_new(path, mode) {
306                Ok(mut f) => {
307                    f.write_all(content).and_then(|()| f.flush())?;
308                    Ok(true)
309                }
310                Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
311                Err(e) => Err(e.into()),
312            }
313        }
314        Err(e) => Err(e.into()),
315    }
316}
317
318/// Temp + rename over `path`.
319fn replace_file(path: &Path, content: &[u8], mode: u32) -> Result<(), ToolError> {
320    let tmp = write_temp(path, content, mode)?;
321    std::fs::rename(&tmp, path).map_err(|e| {
322        let _ = std::fs::remove_file(&tmp);
323        ToolError::from(e)
324    })
325}
326
327pub fn write_record_new(home: &Path, record: &Record) -> Result<(), ToolError> {
328    if !valid_pair_id(&record.pair_id) {
329        return Err(invalid_pair(&record.pair_id));
330    }
331    let body = serde_json::to_vec_pretty(record)
332        .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
333    if claim_file(&record_path(home, &record.pair_id), &body, 0o600)? {
334        Ok(())
335    } else {
336        Err(ToolError::new(
337            ErrorCode::InvalidArgument,
338            format!("pair record {} already exists", record.pair_id),
339        ))
340    }
341}
342
343fn parse_record(path: &Path, raw: &str) -> Option<Record> {
344    match serde_json::from_str::<Record>(raw) {
345        Ok(r) => Some(r),
346        Err(e) => {
347            tracing::warn!("unparsable pair record {}: {e}", path.display());
348            None
349        }
350    }
351}
352
353/// `Ok(None)` for a missing or unparsable record (the latter logged); `IO_ERROR` for other read errors.
354pub fn read_record(home: &Path, id: &str) -> Result<Option<Record>, ToolError> {
355    if !valid_pair_id(id) {
356        return Err(invalid_pair(id));
357    }
358    let path = record_path(home, id);
359    match std::fs::read_to_string(&path) {
360        Ok(raw) => Ok(parse_record(&path, &raw).map(|mut r| {
361            // the FILE NAME's id is the key; a differing id inside the file must never steer another pair's files
362            r.pair_id = id.to_string();
363            r
364        })),
365        Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
366        Err(e) => Err(ToolError::new(
367            ErrorCode::IoError,
368            format!("pair record unreadable: {e}"),
369        )),
370    }
371}
372
373/// The valid pair ids named by `pair.<id>.json` FILE NAMES, sorted (whatever the file holds).
374pub fn record_ids(home: &Path) -> Vec<String> {
375    let Ok(entries) = std::fs::read_dir(home) else {
376        return Vec::new();
377    };
378    let mut ids: Vec<String> = entries
379        .flatten()
380        .filter_map(|e| {
381            let name = e.file_name().into_string().ok()?;
382            let id = name.strip_prefix("pair.")?.strip_suffix(".json")?;
383            valid_pair_id(id).then(|| id.to_string())
384        })
385        .collect();
386    ids.sort();
387    ids
388}
389
390/// Every parsable `pair.<id>.json`, keyed on the FILE NAME's id (see `read_record`); an unparsable one is
391/// logged and skipped.
392pub fn list_records(home: &Path) -> Vec<Record> {
393    record_ids(home)
394        .into_iter()
395        .filter_map(|id| read_record(home, &id).ok().flatten())
396        .collect()
397}
398
399/// Rewrite only `generation` and `tab`, under the pair's lock, never lowering the generation.
400pub fn rewrite_record(
401    home: &Path,
402    id: &str,
403    generation: u32,
404    tab: bool,
405    after_lock: Option<&dyn Fn()>,
406) -> Result<(), ToolError> {
407    if !valid_pair_id(id) {
408        return Err(invalid_pair(id));
409    }
410    ensure_dir(home)?;
411    let mut o = OpenOptions::new();
412    o.write(true).create(true).truncate(false);
413    #[cfg(unix)]
414    {
415        use std::os::unix::fs::OpenOptionsExt;
416        o.mode(0o600);
417    }
418    let lock = o.open(lock_path(home, id))?;
419    lock.lock()?;
420    if let Some(f) = after_lock {
421        f();
422    }
423    let mut record = read_record(home, id)?.ok_or_else(|| {
424        ToolError::new(ErrorCode::PairNotFound, format!("no pair record for {id}"))
425    })?;
426    if generation < record.generation {
427        tracing::warn!(
428            "rewrite_record {id}: generation {generation} is below the stored {}; ignored",
429            record.generation
430        );
431        return Ok(());
432    }
433    record.generation = generation;
434    record.tab = tab;
435    let body = serde_json::to_vec_pretty(&record)
436        .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
437    replace_file(&record_path(home, id), &body, 0o600)
438    // `lock` is released on drop
439}
440
441/// Remove every file of one pair (`pair.<id>.*`, including temp files and the lock).
442pub fn delete_pair_files(home: &Path, id: &str) -> Result<(), ToolError> {
443    if !valid_pair_id(id) {
444        return Err(invalid_pair(id));
445    }
446    let prefix = format!("pair.{id}.");
447    let Ok(entries) = std::fs::read_dir(home) else {
448        return Ok(());
449    };
450    for entry in entries.flatten() {
451        if entry.file_name().to_string_lossy().starts_with(&prefix) {
452            match std::fs::remove_file(entry.path()) {
453                Ok(()) => {}
454                Err(e) if e.kind() == ErrorKind::NotFound => {}
455                Err(e) => return Err(e.into()),
456            }
457        }
458    }
459    Ok(())
460}
461
462pub fn write_sentinel(path: &Path, s: &Sentinel) -> Result<(), ToolError> {
463    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
464        ensure_dir(dir)?;
465    }
466    replace_file(path, s.to_string().as_bytes(), 0o600)
467}
468
469/// An absent, empty or unparsable file reads as `None`.
470pub fn read_sentinel(path: &Path) -> Option<Sentinel> {
471    Sentinel::parse(&std::fs::read_to_string(path).ok()?)
472}