Stage private-file writes under a name no other process shares

d10db9e7eda7 · AtlantisPleb · · parent d143a5b1e186

Stage private-file writes under a name no other process shares

`auth::write_private_file` staged through `path.with_extension("tmp")`, a
name derived only from the target. Every process writing a given file
therefore shared one staging path. Two `oa` runs over one config
directory truncated and renamed each other's half-written bytes: one won,
and the other's `rename` found nothing and reported

    could not write .../device-authorizations.json: No such file or directory

for a credential that may well have been stored. A caller that cannot
tell whether its own token landed has no move left, and a fleet of agents
under one `$HOME` is the normal way this CLI runs -- the delegation
engine starts children that each carry a credential.

Running only the two scope tests in `tests/flags.rs`, which is enough to
make two `oa` runs overlap, failed 29 times in 30 before this and 0 in 30
after. `repeated_scopes_are_all_sent` was never flaky in its scope logic.

Staging now goes through `auth::unique_temp_path`: the target's name plus
process id, wall clock, and a per-process counter, in the same directory,
then `rename` onto the target. `rename` within a directory is atomic, so
the last writer wins whole and no reader sees a partial file. The staging
file is created `0600` with `create_new`, and is removed on every failure
path so a unique name cannot litter the directory.

The other two private-file writers had the same shape:

- The identity seed staged through a fixed `seed.tmp` and removed it
  before each write, so two writers could rename a half-written envelope
  over the seed -- the one file where that loses an identity outright.
  `forget` now sweeps staging files rather than deleting one known name.
- `computer.json` and the agent-key store wrote in place, `fs::write`
  then `chmod`, so concurrent writers could interleave into one file and
  any reader in the truncate window was told its own policy is not valid
  JSON. It also left the file world-readable between the create and the
  `chmod`, which for the agent-key store is a window on credentials.

`tests/private_file_race_test.rs` covers all three by running writers
rather than asserting a helper was called: twelve real `oa auth login
--headless` processes over one `$HOME`, and barrier-gated threads for the
credential store, the pending authorizations, and the seed. Each demands
that every writer reports success and that the file left behind is whole.
The policy test adds concurrent readers, because two small writes usually
land intact and a writers-only test passes against the broken version.
Put any of the three writers back the way it was and the matching tests
fail.

Refs #114

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/auth.rs
  • modified crates/openagents-cli/src/computer.rs
  • modified crates/openagents-cli/src/identity.rs
  • added crates/openagents-cli/tests/private_file_race_test.rs

Diff

4 files changed, +612 -29

crates/openagents-cli/src/auth.rs modified +75 -17

@@ -309,40 +309,98 @@ fn ensure_private_directory(directory: &Path) -> Result<(), AuthError> {

309 309
    Ok(())
310 310
}
311 311
312
/// The suffix every staging file carries, so a sweep can recognise one.
313
pub(crate) const TEMP_SUFFIX: &str = ".tmp";
314
315
/// A staging path beside `path` that no other writer can be using.
316
///
317
/// The name a staged write goes through must not be shared. A fixed one —
318
/// `path.with_extension("tmp")` — gives every process writing this file the
319
/// same staging path, so two `oa` runs over one config directory truncate and
320
/// rename each other's half-written bytes: one wins, and the other's `rename`
321
/// finds nothing and reports a failed credential write for a credential that
322
/// may in fact have been stored. That is the worst shape a credential error
323
/// can take, because the caller cannot tell what is on disk. A fleet of agents
324
/// under one `$HOME` is the normal way this CLI runs, so the overlap is not a
325
/// corner case.
326
///
327
/// Process id, wall clock, and a per-process counter make the name unique
328
/// across processes, across threads, and across calls on one thread. The
329
/// original file name stays in it so a human reading the directory knows what
330
/// crashed, and so that path guards matching on `credentials.json` still match
331
/// the staging file.
332
pub(crate) fn unique_temp_path(path: &Path) -> PathBuf {
333
    use std::sync::atomic::{AtomicU64, Ordering};
334
    static COUNTER: AtomicU64 = AtomicU64::new(0);
335
    let name = path
336
        .file_name()
337
        .map(|name| name.to_string_lossy().into_owned())
338
        .unwrap_or_else(|| "file".to_string());
339
    let nanos = SystemTime::now()
340
        .duration_since(UNIX_EPOCH)
341
        .map(|since| since.as_nanos())
342
        .unwrap_or(0);
343
    let ordinal = COUNTER.fetch_add(1, Ordering::Relaxed);
344
    path.with_file_name(format!(
345
        ".{name}.{}.{nanos}.{ordinal}{TEMP_SUFFIX}",
346
        std::process::id()
347
    ))
348
}
349
312 350
/// Write a file 0600 through a temporary file in the same directory.
351
///
352
/// Staged and renamed, so a reader never sees half a file and a crash leaves
353
/// the previous contents intact. The staging name is unique per call — see
354
/// [`unique_temp_path`] — which is what makes concurrent writers safe rather
355
/// than merely atomic for one.
313 356
fn write_private_file(path: &Path, contents: &str) -> Result<(), AuthError> {
314 357
    let parent = path
315 358
        .parent()
316 359
        .ok_or_else(|| AuthError::new(format!("{} has no parent directory", path.display())))?;
317 360
    ensure_private_directory(parent)?;
318
    let temporary = path.with_extension("tmp");
361
    let temporary = unique_temp_path(path);
362
    let staged = stage_private_file(&temporary, contents);
363
    if staged.is_err() {
364
        // The name was ours alone, so removing it can strand nobody else's
365
        // write. Leaving it would litter the config directory once per failure.
366
        let _ = fs::remove_file(&temporary);
367
    }
368
    staged?;
369
    fs::rename(&temporary, path).map_err(|error| {
370
        let _ = fs::remove_file(&temporary);
371
        AuthError::new(format!("could not write {}: {error}", path.display()))
372
    })
373
}
374
375
/// Create the staging file 0600 and put `contents` in it.
376
fn stage_private_file(temporary: &Path, contents: &str) -> Result<(), AuthError> {
377
    let mut options = fs::OpenOptions::new();
378
    // `create_new` rather than `truncate`: the name is unique to this call, so
379
    // finding one already there means the assumption broke and the write must
380
    // refuse rather than trample whatever is in it.
381
    options.write(true).create_new(true);
382
    #[cfg(unix)]
319 383
    {
320
        let mut options = fs::OpenOptions::new();
321
        options.write(true).create(true).truncate(true);
322
        #[cfg(unix)]
323
        {
324
            use std::os::unix::fs::OpenOptionsExt;
325
            options.mode(0o600);
326
        }
327
        let mut file = options.open(&temporary).map_err(|error| {
328
            AuthError::new(format!("could not write {}: {error}", temporary.display()))
329
        })?;
330
        file.write_all(contents.as_bytes()).map_err(|error| {
331
            AuthError::new(format!("could not write {}: {error}", temporary.display()))
332
        })?;
384
        use std::os::unix::fs::OpenOptionsExt;
385
        options.mode(0o600);
333 386
    }
387
    let mut file = options.open(temporary).map_err(|error| {
388
        AuthError::new(format!("could not write {}: {error}", temporary.display()))
389
    })?;
390
    file.write_all(contents.as_bytes()).map_err(|error| {
391
        AuthError::new(format!("could not write {}: {error}", temporary.display()))
392
    })?;
334 393
    #[cfg(unix)]
335 394
    {
336 395
        use std::os::unix::fs::PermissionsExt;
337
        fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(|error| {
396
        fs::set_permissions(temporary, fs::Permissions::from_mode(0o600)).map_err(|error| {
338 397
            AuthError::new(format!(
339 398
                "could not restrict {} to 0600: {error}",
340 399
                temporary.display()
341 400
            ))
342 401
        })?;
343 402
    }
344
    fs::rename(&temporary, path)
345
        .map_err(|error| AuthError::new(format!("could not write {}: {error}", path.display())))
403
    Ok(())
346 404
}
347 405
348 406
// ---------------------------------------------------------------------------
crates/openagents-cli/src/computer.rs modified +39 -4

@@ -233,6 +233,16 @@ pub fn write_config(config: &PolicyConfig) -> Result<(), String> {

233 233
    write_private_file(&config.paths.config, &format!("{encoded}\n"))
234 234
}
235 235
236
/// Write `computer.json` or the agent-key store `0600`, staged and renamed.
237
///
238
/// This wrote in place — `fs::write` onto the target, then `chmod` — which two
239
/// `oa` processes under one config directory could interleave into one file,
240
/// leaving JSON that neither of them wrote and that the next run refuses to
241
/// decode. It also left the file world-readable for the moment between the
242
/// create and the `chmod`, which for the agent-key store is a window on
243
/// credentials. Staging under a name unique to this call and renaming fixes
244
/// both: `rename` inside a directory is atomic, so the last writer wins whole
245
/// and no reader sees a partial file. See [`crate::auth::unique_temp_path`].
236 246
fn write_private_file(path: &Path, contents: &str) -> Result<(), String> {
237 247
    if let Some(directory) = path.parent() {
238 248
        std::fs::create_dir_all(directory)

@@ -243,13 +253,38 @@ fn write_private_file(path: &Path, contents: &str) -> Result<(), String> {

243 253
            let _ = std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700));
244 254
        }
245 255
    }
246
    std::fs::write(path, contents)
247
        .map_err(|error| format!("could not write {}: {error}", path.display()))?;
256
    let temporary = crate::auth::unique_temp_path(path);
257
    if let Err(error) = stage_private_file(&temporary, contents) {
258
        let _ = std::fs::remove_file(&temporary);
259
        return Err(error);
260
    }
261
    std::fs::rename(&temporary, path).map_err(|error| {
262
        let _ = std::fs::remove_file(&temporary);
263
        format!("could not write {}: {error}", path.display())
264
    })
265
}
266
267
/// Create the staging file `0600` and put `contents` in it.
268
fn stage_private_file(temporary: &Path, contents: &str) -> Result<(), String> {
269
    let mut options = std::fs::OpenOptions::new();
270
    // The name is unique to this call, so anything already there means the
271
    // assumption broke; refuse rather than trample it.
272
    options.write(true).create_new(true);
273
    #[cfg(unix)]
274
    {
275
        use std::os::unix::fs::OpenOptionsExt;
276
        options.mode(0o600);
277
    }
278
    let mut file = options
279
        .open(temporary)
280
        .map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
281
    file.write_all(contents.as_bytes())
282
        .map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
248 283
    #[cfg(unix)]
249 284
    {
250 285
        use std::os::unix::fs::PermissionsExt;
251
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
252
            .map_err(|error| format!("could not secure {}: {error}", path.display()))?;
286
        std::fs::set_permissions(temporary, std::fs::Permissions::from_mode(0o600))
287
            .map_err(|error| format!("could not secure {}: {error}", temporary.display()))?;
253 288
    }
254 289
    Ok(())
255 290
}
crates/openagents-cli/src/identity.rs modified +62 -8

@@ -676,13 +676,44 @@ impl SeedStore {

676 676
    /// The seed file: a sealed envelope under the OS keychain, or the mnemonic
677 677
    /// itself where there is no keychain. Mode `0600` either way.
678 678
    pub fn path(&self) -> PathBuf {
679
        self.directory.join("seed")
679
        self.directory.join(Self::SEED_FILE_NAME)
680 680
    }
681 681
682
    /// The path the atomic rewrite stages through. Named so `forget` and the
683
    /// write path can both make sure a crashed write leaves nothing behind.
682
    const SEED_FILE_NAME: &'static str = "seed";
683
684
    /// A staging path for one rewrite, unique to this call.
685
    ///
686
    /// It used to be the fixed `seed.tmp`. Two processes writing a seed under
687
    /// one `$HOME` then shared it: each truncated the other's staged bytes and
688
    /// removed the file out from under it, so one rewrite could rename a
689
    /// half-written envelope over the seed, or fail after the other had already
690
    /// replaced it. A seed is the one file where either outcome loses an
691
    /// identity outright. See [`crate::auth::unique_temp_path`].
684 692
    fn temp_path(&self) -> PathBuf {
685
        self.directory.join("seed.tmp")
693
        crate::auth::unique_temp_path(&self.path())
694
    }
695
696
    /// Remove staging files this directory still holds from crashed rewrites.
697
    ///
698
    /// With one fixed staging name `forget` could just delete it. Unique names
699
    /// mean a sweep instead: anything beside the seed that this store's writes
700
    /// would have named.
701
    fn sweep_temp_files(&self) {
702
        let Ok(entries) = fs::read_dir(&self.directory) else {
703
            return;
704
        };
705
        let prefix = format!(".{}.", Self::SEED_FILE_NAME);
706
        for entry in entries.flatten() {
707
            let name = entry.file_name();
708
            let name = name.to_string_lossy();
709
            if name.starts_with(&prefix) && name.ends_with(crate::auth::TEMP_SUFFIX) {
710
                let _ = fs::remove_file(entry.path());
711
            }
712
            // The name a crashed pre-sweep `oa` left behind.
713
            if name == "seed.tmp" {
714
                let _ = fs::remove_file(entry.path());
715
            }
716
        }
686 717
    }
687 718
688 719
    /// True when a seed is already stored. Presence only; the bytes stay on disk.

@@ -807,9 +838,13 @@ impl SeedStore {

807 838
        Self::set_mode(&self.directory, 0o700)?;
808 839
        let path = self.path();
809 840
        let temp = self.temp_path();
810
        let _ = fs::remove_file(&temp);
811
        fs::write(&temp, bytes)?;
812
        Self::set_mode(&temp, 0o600)?;
841
        // No `remove_file` first: the name belongs to this call alone, so
842
        // anything already at it would be a surprise rather than our own
843
        // leftovers, and `create_new` inside `write_sealed` says so.
844
        if let Err(error) = Self::write_sealed(&temp, bytes) {
845
            let _ = fs::remove_file(&temp);
846
            return Err(error);
847
        }
813 848
        if let Err(error) = fs::rename(&temp, &path) {
814 849
            let _ = fs::remove_file(&temp);
815 850
            return Err(IdentityError::Io(error));

@@ -818,6 +853,25 @@ impl SeedStore {

818 853
        Ok(path)
819 854
    }
820 855
856
    /// Put `bytes` in a new file that is `0600` from the moment it exists.
857
    ///
858
    /// Created `0600` rather than created and then restricted: the seed must
859
    /// never be readable to the rest of the machine, not even for the instant
860
    /// between the two calls.
861
    fn write_sealed(temp: &std::path::Path, bytes: &[u8]) -> Result<(), IdentityError> {
862
        let mut options = fs::OpenOptions::new();
863
        options.write(true).create_new(true);
864
        #[cfg(unix)]
865
        {
866
            use std::os::unix::fs::OpenOptionsExt;
867
            options.mode(0o600);
868
        }
869
        let mut file = options.open(temp)?;
870
        file.write_all(bytes)?;
871
        Self::set_mode(temp, 0o600)?;
872
        Ok(())
873
    }
874
821 875
    /// Move a plaintext seed under the OS keychain, and report what is protecting
822 876
    /// it afterwards. `Ok(None)` when nothing is stored.
823 877
    ///

@@ -848,7 +902,7 @@ impl SeedStore {

848 902
    /// for an identity that no longer exists.
849 903
    pub fn forget(&self) -> Result<bool, IdentityError> {
850 904
        let path = self.path();
851
        let _ = fs::remove_file(self.temp_path());
905
        self.sweep_temp_files();
852 906
        if !path.exists() {
853 907
            self.keys.delete();
854 908
            return Ok(false);
crates/openagents-cli/tests/private_file_race_test.rs added +436

@@ -0,0 +1,436 @@

1
//! Concurrent writers to one config directory.
2
//!
3
//! Every private file this CLI keeps — the credential store, the pending
4
//! device authorizations, the identity seed, `computer.json` — is written by a
5
//! process that has no claim on the machine. A fleet of agents runs under one
6
//! `$HOME` by design: the delegation engine starts children and each one
7
//! carries a credential. Two `oa auth login` runs at once are the same thing at
8
//! human speed.
9
//!
10
//! These writes used to stage through a name derived only from the target —
11
//! `path.with_extension("tmp")` — so every writer of a given file shared one
12
//! staging path. They truncated and renamed each other's half-written bytes:
13
//! one won, and the other's `rename` found nothing and reported a failed
14
//! credential write for a credential that may well have been stored. A caller
15
//! that cannot tell whether its own token landed has no move left.
16
//!
17
//! So each test here runs N writers against one directory and demands two
18
//! things a shared staging name cannot give: **every writer reports success**,
19
//! and **the file left behind is whole**. `computer.json` had it worse still —
20
//! it wrote in place, with no staging at all — so its test adds concurrent
21
//! readers, which is what makes the truncate window visible. Put any of these
22
//! writers back the way it was and the matching test fails.
23
24
use openagents_cli::auth::{CredentialStore, PendingDeviceAuthorization, PendingStore, Secret};
25
use openagents_cli::computer::{ComputerPaths, PolicyConfig};
26
use openagents_cli::identity::{generate_seed_phrase, NoKeyStore, SeedStore};
27
use std::io::{BufRead, BufReader, Read, Write};
28
use std::net::TcpListener;
29
use std::path::Path;
30
use std::process::Command;
31
use std::thread;
32
33
/// Enough writers to overlap on any machine that runs this, and few enough
34
/// that spawning them as processes stays quick.
35
const WRITERS: usize = 12;
36
37
// ---------------------------------------------------------------------------
38
// the reported failure: separate `oa` processes, one config directory
39
// ---------------------------------------------------------------------------
40
41
/// A server that answers every request with one canned device authorization.
42
///
43
/// `oa auth login --headless` starts an authorization, prints the code, writes
44
/// it to `device-authorizations.json`, and exits — no polling, so a run of it
45
/// is a short process whose only side effect is that write. That makes it the
46
/// honest way to put N real `oa` processes on one file at once.
47
fn stub_authorization_server() -> String {
48
    const BODY: &str = r#"{"device_code":"d-race","user_code":"AAAA-BBBB",
49
        "verification_uri":"https://example.test/device",
50
        "verification_uri_complete":"https://example.test/device?user_code=AAAA-BBBB",
51
        "expires_in":600,"interval":5,"scope":"forge:write"}"#;
52
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
53
    let port = listener.local_addr().expect("read the port").port();
54
    thread::spawn(move || {
55
        for stream in listener.incoming() {
56
            let Ok(mut stream) = stream else { break };
57
            thread::spawn(move || {
58
                let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
59
                let mut line = String::new();
60
                if reader.read_line(&mut line).is_err() {
61
                    return;
62
                }
63
                let mut length = 0usize;
64
                loop {
65
                    let mut header = String::new();
66
                    if reader.read_line(&mut header).unwrap_or(0) == 0 || header.trim().is_empty() {
67
                        break;
68
                    }
69
                    if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
70
                        length = value.trim().parse().unwrap_or(0);
71
                    }
72
                }
73
                if length > 0 {
74
                    let mut discard = vec![0u8; length];
75
                    let _ = reader.read_exact(&mut discard);
76
                }
77
                let response = format!(
78
                    "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{BODY}",
79
                    BODY.len()
80
                );
81
                let _ = stream.write_all(response.as_bytes());
82
                let _ = stream.flush();
83
            });
84
        }
85
    });
86
    format!("http://127.0.0.1:{port}")
87
}
88
89
/// N `oa` processes sharing a config directory each store their authorization,
90
/// and none of them is told the write failed.
91
///
92
/// This is the reported bug end to end. With the staging name derived only
93
/// from the target, running only the two scope tests in `tests/flags.rs` —
94
/// which is enough to make two `oa` runs overlap — failed 29 times in 30 with
95
/// `could not write .../device-authorizations.json: No such file or directory`.
96
/// Twelve deliberate writers make that certain rather than likely.
97
#[test]
98
fn concurrent_oa_processes_all_record_their_authorization() {
99
    let home = tempfile::tempdir().expect("a home of this test's own");
100
    let origin = stub_authorization_server();
101
102
    let runs: Vec<_> = (0..WRITERS)
103
        .map(|_| {
104
            let home = home.path().to_path_buf();
105
            let origin = origin.clone();
106
            thread::spawn(move || {
107
                Command::new(env!("CARGO_BIN_EXE_oa"))
108
                    .args(["--api-url", &origin, "auth", "login", "--headless"])
109
                    .env("NO_COLOR", "")
110
                    .env("HOME", &home)
111
                    .output()
112
                    .expect("run oa")
113
            })
114
        })
115
        .collect();
116
117
    for (index, run) in runs.into_iter().enumerate() {
118
        let output = run.join().expect("an oa process finished");
119
        assert_eq!(
120
            output.status.code(),
121
            Some(0),
122
            "writer {index} was told its authorization was not stored: {}",
123
            String::from_utf8_lossy(&output.stderr)
124
        );
125
    }
126
127
    let path = home
128
        .path()
129
        .join(".config")
130
        .join("openagents")
131
        .join("device-authorizations.json");
132
    let stored: serde_json::Value =
133
        serde_json::from_str(&std::fs::read_to_string(&path).expect("the file is on disk"))
134
            .expect("concurrent writers left whole JSON behind");
135
    assert_eq!(
136
        stored["authorizations"][&origin]["user_code"], "AAAA-BBBB",
137
        "the file survived but holds no authorization for the origin every writer used: {stored}"
138
    );
139
    assert_no_staging_files_left(path.parent().expect("the config directory"));
140
}
141
142
// ---------------------------------------------------------------------------
143
// the same overlap, one writer per thread, for each store in turn
144
// ---------------------------------------------------------------------------
145
146
/// Run `writer` on `WRITERS` threads at once and return what each one reported.
147
fn race<T, E>(writer: impl Fn(usize) -> Result<T, E> + Send + Sync + 'static) -> Vec<Result<T, E>>
148
where
149
    T: Send + 'static,
150
    E: Send + 'static,
151
{
152
    let writer = std::sync::Arc::new(writer);
153
    // A barrier rather than "spawn and hope": the point is that the writes
154
    // overlap, and a thread that starts after another has finished proves
155
    // nothing.
156
    let gate = std::sync::Arc::new(std::sync::Barrier::new(WRITERS));
157
    let threads: Vec<_> = (0..WRITERS)
158
        .map(|index| {
159
            let writer = writer.clone();
160
            let gate = gate.clone();
161
            thread::spawn(move || {
162
                gate.wait();
163
                writer(index)
164
            })
165
        })
166
        .collect();
167
    threads
168
        .into_iter()
169
        .map(|thread| thread.join().expect("a writer thread finished"))
170
        .collect()
171
}
172
173
/// No `.tmp` litter: a staging file left behind is a write that half happened,
174
/// and in this directory it is a half-written credential sitting on disk.
175
fn assert_no_staging_files_left(directory: &Path) {
176
    let left: Vec<String> = std::fs::read_dir(directory)
177
        .expect("read the directory")
178
        .flatten()
179
        .map(|entry| entry.file_name().to_string_lossy().into_owned())
180
        .filter(|name| name.ends_with(".tmp"))
181
        .collect();
182
    assert!(
183
        left.is_empty(),
184
        "staging files were left in {}: {left:?}",
185
        directory.display()
186
    );
187
}
188
189
#[cfg(unix)]
190
fn mode_of(path: &Path) -> u32 {
191
    use std::os::unix::fs::PermissionsExt;
192
    std::fs::metadata(path)
193
        .expect("stat the path")
194
        .permissions()
195
        .mode()
196
        & 0o777
197
}
198
199
/// Concurrent token writes all report where the token landed, and the store is
200
/// still readable afterwards.
201
///
202
/// Each writer uses its own origin, so the file is also the one place the
203
/// writes meet. The assertion is not that every origin survives — these are
204
/// read-modify-write callers and the last one legitimately wins — but that no
205
/// writer was told its token failed, and that the file the survivor left is a
206
/// credential store rather than two of them spliced together.
207
#[test]
208
fn concurrent_credential_writes_all_succeed_and_leave_a_readable_store() {
209
    let directory = tempfile::tempdir().expect("a config directory");
210
    let at = directory.path().to_path_buf();
211
212
    let results = race(move |index| {
213
        CredentialStore::isolated(&format!("https://writer-{index}.test"), &at)
214
            .store(&Secret::new(format!("token-for-{index}")))
215
    });
216
    for (index, result) in results.iter().enumerate() {
217
        assert!(
218
            result.is_ok(),
219
            "writer {index} was told its token was not stored: {}",
220
            result.as_ref().unwrap_err()
221
        );
222
    }
223
224
    let path = directory.path().join("credentials.json");
225
    let stored: serde_json::Value =
226
        serde_json::from_str(&std::fs::read_to_string(&path).expect("the store is on disk"))
227
            .expect("concurrent writers left whole JSON behind");
228
    let tokens = stored["tokens"]
229
        .as_object()
230
        .expect("the store holds a token map");
231
    assert!(
232
        !tokens.is_empty(),
233
        "every writer reported success and the store holds nothing: {stored}"
234
    );
235
    for (origin, token) in tokens {
236
        let index = origin
237
            .trim_start_matches("https://writer-")
238
            .trim_end_matches(".test");
239
        assert_eq!(
240
            token,
241
            &serde_json::json!(format!("token-for-{index}")),
242
            "the store pairs {origin} with a token no writer wrote, so two writes were spliced"
243
        );
244
    }
245
    #[cfg(unix)]
246
    {
247
        assert_eq!(
248
            mode_of(&path),
249
            0o600,
250
            "the store is readable to the machine"
251
        );
252
        assert_eq!(
253
            mode_of(directory.path()),
254
            0o700,
255
            "the config directory is open to the machine"
256
        );
257
    }
258
    assert_no_staging_files_left(directory.path());
259
}
260
261
/// The same for the half-finished logins, which is the file the reported
262
/// failure actually named.
263
#[test]
264
fn concurrent_pending_authorization_writes_all_succeed() {
265
    let directory = tempfile::tempdir().expect("a config directory");
266
    let path = directory.path().join("device-authorizations.json");
267
    let at = path.clone();
268
269
    let results = race(move |index| {
270
        PendingStore::at(at.clone()).set(&PendingDeviceAuthorization {
271
            origin: format!("https://writer-{index}.test"),
272
            device_code: format!("device-{index}"),
273
            user_code: format!("CODE-{index:04}"),
274
            verification_uri: "https://example.test/device".to_string(),
275
            verification_uri_complete: "https://example.test/device?user_code=X".to_string(),
276
            expires_at_ms: 1_000_000,
277
            interval: 5,
278
            kind: None,
279
        })
280
    });
281
    for (index, result) in results.iter().enumerate() {
282
        assert!(
283
            result.is_ok(),
284
            "writer {index} was told its authorization was not stored: {}",
285
            result.as_ref().unwrap_err()
286
        );
287
    }
288
289
    let stored: serde_json::Value =
290
        serde_json::from_str(&std::fs::read_to_string(&path).expect("the file is on disk"))
291
            .expect("concurrent writers left whole JSON behind");
292
    assert!(
293
        !stored["authorizations"]
294
            .as_object()
295
            .expect("the file holds an authorization map")
296
            .is_empty(),
297
        "every writer reported success and the file holds nothing: {stored}"
298
    );
299
    #[cfg(unix)]
300
    assert_eq!(mode_of(&path), 0o600, "the file is readable to the machine");
301
    assert_no_staging_files_left(directory.path());
302
}
303
304
/// Concurrent seed writes all succeed and the seed that remains is one somebody
305
/// actually wrote.
306
///
307
/// The seed store staged through a fixed `seed.tmp` and removed it before each
308
/// write, so two writers could put half of one phrase and half of another in
309
/// the file that then got renamed over the seed. A seed is the one file where
310
/// that costs an identity outright: nothing recovers a mnemonic that is six
311
/// words from one wallet and six from another.
312
#[test]
313
fn concurrent_seed_writes_all_succeed_and_the_seed_still_opens() {
314
    let directory = tempfile::tempdir().expect("an identity directory");
315
    let at = directory.path().join("identity");
316
    let phrases: Vec<String> = (0..WRITERS)
317
        .map(|_| generate_seed_phrase(12).expect("a phrase"))
318
        .collect();
319
320
    let written = phrases.clone();
321
    let target = at.clone();
322
    let results = race(move |index| {
323
        SeedStore::with_key_store(target.clone(), Box::new(NoKeyStore))
324
            .write_phrase(&written[index])
325
    });
326
    for (index, result) in results.iter().enumerate() {
327
        assert!(
328
            result.is_ok(),
329
            "writer {index} was told the seed was not written: {}",
330
            result.as_ref().unwrap_err()
331
        );
332
    }
333
334
    let store = SeedStore::with_key_store(at.clone(), Box::new(NoKeyStore));
335
    let recovered = store
336
        .read_phrase()
337
        .expect("the seed opens")
338
        .expect("a seed is stored");
339
    assert!(
340
        phrases.contains(&recovered),
341
        "the stored seed is not any phrase that was written, so two writes were spliced"
342
    );
343
    #[cfg(unix)]
344
    {
345
        assert_eq!(
346
            mode_of(&store.path()),
347
            0o600,
348
            "the seed is readable to the machine"
349
        );
350
        assert_eq!(
351
            mode_of(&at),
352
            0o700,
353
            "the identity directory is open to the machine"
354
        );
355
    }
356
    assert_no_staging_files_left(&at);
357
}
358
359
/// A reader of `computer.json` never sees a policy it cannot decode, however
360
/// many writers are working on it.
361
///
362
/// `computer.json` wrote in place — truncate the target, then write it — so
363
/// between those two calls the file on disk is empty, and any `oa` that read
364
/// the policy in that window was told its own configuration is not valid JSON.
365
/// It is the policy file: the answer decides which commands the Computer will
366
/// run at all, and `load_config` is right to refuse a file it cannot read
367
/// rather than fall back to a default the owner never chose. So the refusal
368
/// lands on a reader that did nothing wrong.
369
///
370
/// Writers alone would not settle this. Two small `write` calls usually land
371
/// whole, so a test that only writes passes against the broken version. Readers
372
/// are what make the truncate window visible, and staging elsewhere and
373
/// renaming is what closes it: the target is only ever the old file or the new
374
/// one.
375
#[test]
376
fn a_reader_never_sees_a_half_written_computer_policy() {
377
    /// Enough passes for the truncate window to be observed if it is open.
378
    const PASSES: usize = 40;
379
380
    let directory = tempfile::tempdir().expect("a config directory");
381
    let paths = ComputerPaths::in_directory(directory.path());
382
    // Seed the file, so a reader finding it absent means it was unlinked rather
383
    // than never written.
384
    let mut initial = PolicyConfig::closed(paths.clone());
385
    initial.pre_approved = vec!["writer-initial".to_string()];
386
    openagents_cli::computer::write_config(&initial).expect("the first write lands");
387
388
    let at = paths.clone();
389
    let results = race(move |index| {
390
        // Half write and half read, so both are going at once.
391
        if index.is_multiple_of(2) {
392
            for pass in 0..PASSES {
393
                let mut config = PolicyConfig::closed(at.clone());
394
                // Lengths differ between writers, so an interleave leaves a tail
395
                // rather than a file that happens to be the same size.
396
                config.pre_approved = (0..=index * pass % 7)
397
                    .map(|n| format!("writer-{index}-pass-{pass}-entry-{n}"))
398
                    .collect();
399
                openagents_cli::computer::write_config(&config)
400
                    .map_err(|error| format!("writer {index} pass {pass}: {error}"))?;
401
            }
402
        } else {
403
            for pass in 0..PASSES {
404
                openagents_cli::computer::load_config(&at)
405
                    .map(|_| ())
406
                    .map_err(|error| format!("reader {index} pass {pass}: {error}"))?;
407
            }
408
        }
409
        Ok::<(), String>(())
410
    });
411
    for result in &results {
412
        assert!(
413
            result.is_ok(),
414
            "a concurrent run of the policy file failed: {}",
415
            result.as_ref().unwrap_err()
416
        );
417
    }
418
419
    let settled = openagents_cli::computer::load_config(&paths)
420
        .expect("the policy the writers left behind still decodes");
421
    assert!(
422
        settled
423
            .pre_approved
424
            .iter()
425
            .all(|entry| entry.starts_with("writer-")),
426
        "the policy holds an entry no writer wrote, so two writes were spliced: {:?}",
427
        settled.pre_approved
428
    );
429
    #[cfg(unix)]
430
    assert_eq!(
431
        mode_of(&paths.config),
432
        0o600,
433
        "the configuration is readable to the machine"
434
    );
435
    assert_no_staging_files_left(directory.path());
436
}

This page updates live while a promote is in flight · changelog