Keep state and keys out of the shared repo

The dev VM is treated as untrusted, and the repo is a read-write virtiofs
mount shared with the backend host -- so a CA private key sitting in it is
a key that machine can sign with, and a leaf signed by this CA is one the
phone's pinned app accepts without question. Pinning against a CA the
attacker holds is no pinning at all.

So certificates are now generated on the machine that serves them, into
$XDG_CONFIG_HOME/ai-app/certs at 0700 with 0600 keys (AI_APP_CERTS
overrides), and config.json and session transcripts move to the XDG config
and data directories. Transcripts move for a plainer reason than the keys:
they are whole conversations, and they were world-readable at 0644.

Two smaller things fall out. The host and VM stop sharing one config,
which had already put a test token on the production backend. And state
stops living where `git clean -xdf` would take the enrollment and every
transcript with it.

State that predates the move is still read from the repo, with a warning
naming where to move it, so an existing install keeps working rather than
silently coming up on an empty config -- the precedence is covered by a
test, since picking the wrong file would otherwise be silent.

Verified: 31 tests, clippy clean; the certificate script writing 0700/0600
into an overridden directory; and the server logging the fallback and
serving from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 03:49:34 -04:00
1 parent fff1fb49e8
commit d2d2832ec8
8 files changed
+248 -37

No files matched your search

+13 -4
View File
@@ -106,10 +106,19 @@ Established 2026-08-25, and it decides more than it looks like:
configured host, and a session that names it. For the host to ssh in, configured host, and a session that names it. For the host to ssh in,
the VM needs an inbound port forward in its launch configuration the VM needs an inbound port forward in its launch configuration
(qemu `hostfwd`) — usermode networking has none by default. (qemu `hostfwd`) — usermode networking has none by default.
- **`config.json` is shared with the host too**, so it is the *production* - **Nothing secret goes in the repo.** The VM is treated as untrusted (see
config: don't leave test tokens or throwaway hosts in it. Point PLAN.md's security section), and the repo is shared read-write with the
development at a scratch one instead: host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`. and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. The server
still reads a pre-move `config.json`/`sessions/` from the repo, with a
warning, so an old install keeps working.
- Certificates are generated **on the machine that serves them**
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
a separate throwaway dev CA for emulator work — never install a build
pinning that on the real phone.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
`XDG_CONFIG_HOME=… XDG_DATA_HOME=…`.
## Things that have bitten ## Things that have bitten
+34 -3
View File
@@ -252,15 +252,46 @@ GET /usage cached usage windows
GET/PUT /hosts, /models config editing from the phone GET/PUT /hosts, /models config editing from the phone
``` ```
Sessions live in `config.json` + a per-session directory (transcript.jsonl, Sessions live in `config.json` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session
attachments, produced images). Deleting a session is the complete path out of directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
everything spawning one created. attachments, produced images), owner-only. Deleting a session is the
complete path out of everything spawning one created. State that predates
the move is still read from the repo, loudly, so an existing install keeps
working until it is moved by hand.
### Security ### Security
- TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and - TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf `PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
scheme, same one-way-door caveat about regenerating the CA. scheme, same one-way-door caveat about regenerating the CA.
- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't
malicious but could become so. It matters because the repo is a
read-write virtiofs mount shared between the VM and the backend host, so
under this model everything in it — source, `server/target/` binaries,
and the shell scripts the host runs, some with sudo — is
attacker-writable. Two consequences:
- **Nothing secret lives in the repo.** Certificates are generated on
the machine that serves them and written to
`$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.json` and
session transcripts go to the XDG config and data directories, per
machine. A CA private key the VM could read would let it mint a leaf
the pinned app accepts, which is precisely the attack pinning exists
to stop — pinning against a CA the attacker holds is no pinning at
all. Transcripts move for a plainer reason: they are whole
conversations. As a bonus this ends the host and VM sharing one
config, which had already produced a test token live on the backend,
and takes state out of reach of `git clean -xdf`.
- **The host should not execute what the VM can write** — build and run
the backend from a host-only checkout rather than the shared mount.
Moving the keys closes the smaller door; this is the larger one.
- Development in the VM generates its own throwaway CA. Whatever is
installed on the real phone must pin only the host's.
- The CA key is not needed by the server at all (only `leaf.pem` and
`leaf-key.pem` are read), so it can move offline once the setup is
stable; reissuing a leaf is the only time it is wanted.
- Not addressed, and accepted: a compromised VM can return anything it
likes from the sessions it runs, since running an agent there is the
point. The blast radius is that session's content, not the backend.
- This server is strictly more dangerous than the updater: its API *is* - This server is strictly more dangerous than the updater: its API *is*
remote code execution (spawn a bypass-permissions Claude on any SSH host). remote code execution (spawn a bypass-permissions Claude on any SSH host).
Pinning authenticates the server to the phone but not the phone to the Pinning authenticates the server to the phone but not the phone to the
+38 -9
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# Generates the self-signed dev CA and leaf certificate `server/` serves its # Generates the self-signed dev CA and leaf certificate `server/` serves its
# TLS listener with. Run once before the first `cargo run`; the server exits # TLS listener with. Run it once, ON THE MACHINE THAT RUNS THE BACKEND, before
# with a clear message if `certs/` is missing. # the first start; the server exits with a clear message if the certificates
# are missing.
# #
# Same scheme as ../local-updater's: nothing on a device trusts this # Same scheme as ../local-updater's: nothing on a device trusts this
# automatically -- the app embeds the CA certificate verbatim and pins to it # automatically -- the app embeds the CA certificate verbatim and pins to it
@@ -9,6 +10,25 @@
# This server's API *is* remote code execution (it spawns AI sessions on # This server's API *is* remote code execution (it spawns AI sessions on
# request), so a MITM on it would be as bad as it gets -- hence pinning. # request), so a MITM on it would be as bad as it gets -- hence pinning.
# #
# WHERE THE KEYS LIVE, AND WHY NOT IN THE REPO
#
# Output goes to $XDG_CONFIG_HOME/ai-app/certs (0700), deliberately *not*
# beside this script. The repo is a virtiofs mount shared with the dev VM,
# and that VM is treated as untrusted -- a machine that isn't malicious but
# could become so. A CA private key it can read is a CA private key it can
# sign with, and a leaf signed by this CA is one the phone's pinned app
# accepts without question. Keeping the key off the shared mount is what
# makes pinning mean anything.
#
# The same reasoning says the CA key doesn't belong on the backend either,
# strictly: the server only ever reads leaf.pem and leaf-key.pem, and the CA
# key is needed solely to reissue a leaf. Moving ca-key.pem somewhere offline
# once the setup is stable costs nothing but having it to hand at reissue.
#
# Running this inside the VM is fine and expected for emulator work -- it
# just produces a *different*, throwaway CA there. Never install a build
# pinning that dev CA on the real phone.
#
# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so # The CA is idempotent -- skipped if `certs/ca.pem` already exists, so
# re-running this doesn't invalidate the certificate the installed app has # re-running this doesn't invalidate the certificate the installed app has
# pinned against without a reason to. The leaf is cheap and reissued on # pinned against without a reason to. The leaf is cheap and reissued on
@@ -40,7 +60,7 @@
set -eu set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
CERTS_DIR="$SCRIPT_DIR/certs" CERTS_DIR="${AI_APP_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}"
# The backend's WireGuard address -- the one address the phone ever dials in # The backend's WireGuard address -- the one address the phone ever dials in
# production (PLAN.md: single-path addressing, no home/away distinction). # production (PLAN.md: single-path addressing, no home/away distinction).
SERVER_IP="${SERVER_IP:-10.66.0.1}" SERVER_IP="${SERVER_IP:-10.66.0.1}"
@@ -53,8 +73,13 @@ LOOPBACK_IP="127.0.0.1"
EMULATOR_HOST_IP="10.0.2.2" EMULATOR_HOST_IP="10.0.2.2"
LAN_IP="${LAN_IP:-192.168.1.168}" LAN_IP="${LAN_IP:-192.168.1.168}"
# Private key material: owner-only from the moment it exists, rather than
# created world-readable and chmod'ed a beat later.
umask 077
mkdir -p "$CERTS_DIR" mkdir -p "$CERTS_DIR"
chmod 700 "$CERTS_DIR"
cd "$CERTS_DIR" cd "$CERTS_DIR"
echo "==> Writing certificates to $CERTS_DIR"
if [ -f ca.pem ]; then if [ -f ca.pem ]; then
echo "==> ca.pem already exists, reusing existing CA." echo "==> ca.pem already exists, reusing existing CA."
@@ -103,11 +128,15 @@ echo "==> Done."
echo " CA fingerprint (base64): $CA_SHA256" echo " CA fingerprint (base64): $CA_SHA256"
echo " Leaf fingerprint (base64): $LEAF_SHA256" echo " Leaf fingerprint (base64): $LEAF_SHA256"
echo echo
echo " Only relevant if the CA was regenerated just now (i.e. certs/ca.pem" echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did"
echo " did not already exist): the app embeds PINNED_CA_PEM and needs the" echo " not already exist): the app embeds PINNED_CA_PEM and needs the new"
echo " new certs/ca.pem contents pasted in, or it silently stops being able" echo " $CERTS_DIR/ca.pem contents pasted in, or it silently"
echo " to reach this server. The app installs via Local Updater, so" echo " stops being able to reach this server. The app installs via Local"
echo " recovery is a reinstall through that -- but it's still a one-way" echo " Updater, so recovery is a reinstall through that -- but it's still"
echo " door for the installed copy." echo " a one-way door for the installed copy."
echo echo
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt" echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
echo
echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not"
echo " put the certs directory back in the repo, which is shared with the"
echo " VM (see this script's header)."
+39 -3
View File
@@ -11,11 +11,35 @@
//! JSONL file in its own directory (see `session::transcript`); this file //! JSONL file in its own directory (see `session::transcript`); this file
//! holds only the metadata needed to list and respawn sessions. //! holds only the metadata needed to list and respawn sessions.
use std::fs::File;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Creates `dir` (and parents) owner-accessible only. Session directories
/// and the config directory both go through here: transcripts are whole
/// conversations, which is the most sensitive thing this server stores.
pub fn create_private_dir(dir: &Path) -> Result<()> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))
}
/// Opens `path` for writing, creating it owner-readable only.
pub fn private_file(path: &Path) -> Result<File> {
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct Config { pub struct Config {
@@ -175,14 +199,26 @@ impl Config {
} }
} }
/// Writes the config, owner-readable only.
///
/// The token hashes here are verifiers, not secrets -- a 256-bit
/// random token can't be recovered from its SHA-256 -- but the file
/// also names every host this backend can reach and every session it
/// is running, which is nobody else's business on a shared machine.
/// The mode is set on the temporary file *before* the rename, so the
/// config is never briefly world-readable at its real path.
pub fn save(&self, path: &Path) -> Result<()> { pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) create_private_dir(parent)?;
.with_context(|| format!("create {}", parent.display()))?;
} }
let text = serde_json::to_string_pretty(self).context("serialize config")?; let text = serde_json::to_string_pretty(self).context("serialize config")?;
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?; {
use std::io::Write;
let mut file = private_file(&tmp)?;
file.write_all(text.as_bytes())
.with_context(|| format!("write {}", tmp.display()))?;
}
std::fs::rename(&tmp, path) std::fs::rename(&tmp, path)
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; .with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?;
Ok(()) Ok(())
+114 -13
View File
@@ -33,9 +33,55 @@ use session::SessionManager;
const DEFAULT_PORT: u16 = 8443; const DEFAULT_PORT: u16 = 8443;
const WG_INTERFACE: &str = "wg0"; const WG_INTERFACE: &str = "wg0";
/// The repo root, one level above this crate. Everything the server reads /// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json`
/// by default -- the TLS cert, the config, the session data -- resolves /// and `certs/`.
/// from here, so there's one definition of it rather than one per caller. fn config_home() -> PathBuf {
xdg_dir("XDG_CONFIG_HOME", ".config")
}
/// `$XDG_DATA_HOME/ai-app`, or `~/.local/share/ai-app`. Holds the session
/// directories: transcripts, attachments, produced images.
fn data_home() -> PathBuf {
xdg_dir("XDG_DATA_HOME", ".local/share")
}
fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
std::env::var_os(var)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| {
std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback)
})
.join("ai-app")
}
/// Picks `preferred`, unless only the older in-repo `legacy` path exists --
/// in which case it is used, loudly.
///
/// State used to live in the repo, which is wrong for two reasons: a
/// `git clean -xdf` would take the enrollment and every transcript with
/// it, and this repo is a virtiofs mount shared with a machine that is not
/// trusted with them (see PLAN.md's security section). Moving is left to a
/// person rather than done silently, because relocating a live config out
/// from under a running backend is not the kind of thing a program should
/// decide to do on its own.
fn prefer_xdg(preferred: PathBuf, legacy: PathBuf, what: &str) -> PathBuf {
if !preferred.exists() && legacy.exists() {
tracing::warn!(
"using {} from {} -- move it to {} when convenient; the repo is shared with the VM \
and is not a good home for {what}",
what,
legacy.display(),
preferred.display(),
);
return legacy;
}
preferred
}
/// The repo root, one level above this crate. Only used now to find the
/// legacy in-repo state above; everything the server reads by default
/// resolves from the XDG directories instead.
/// ///
/// Found from the running executable first, and only then from the path /// Found from the running executable first, and only then from the path
/// compiled in. This repo is shared between a VM and its host over /// compiled in. This repo is shared between a VM and its host over
@@ -76,18 +122,18 @@ struct Args {
#[arg(long)] #[arg(long)]
bind: Option<IpAddr>, bind: Option<IpAddr>,
/// Where the token hashes and session list live. Defaults to /// Where the token hashes, providers, hosts, and session list live.
/// `config.json` beside this repo's `certs/`. /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.json`.
#[arg(long)] #[arg(long)]
config: Option<PathBuf>, config: Option<PathBuf>,
/// Directory for per-session data (transcripts, attachments, images). /// Directory for per-session data (transcripts, attachments, images).
/// Defaults to `sessions/` in the repo root. /// Defaults to `$XDG_DATA_HOME/ai-app/sessions`.
#[arg(long)] #[arg(long)]
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
/// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this /// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by
/// repo's `certs/`, as produced by `gen-dev-cert.sh`. /// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
#[arg(long)] #[arg(long)]
certs: Option<PathBuf>, certs: Option<PathBuf>,
@@ -147,8 +193,16 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init(); tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse(); let args = Args::parse();
let config_path = args.config.unwrap_or_else(|| repo_root().join("config.json")); let config_path = args.config.unwrap_or_else(|| {
let data_dir = args.data_dir.unwrap_or_else(|| repo_root().join("sessions")); prefer_xdg(
config_home().join("config.json"),
repo_root().join("config.json"),
"the config",
)
});
let data_dir = args.data_dir.unwrap_or_else(|| {
prefer_xdg(data_home().join("sessions"), repo_root().join("sessions"), "transcripts")
});
let manager = Arc::new( let manager = Arc::new(
SessionManager::new(config_path.clone(), data_dir) SessionManager::new(config_path.clone(), data_dir)
.with_context(|| format!("failed to load {}", config_path.display()))?, .with_context(|| format!("failed to load {}", config_path.display()))?,
@@ -190,13 +244,16 @@ async fn main() -> Result<()> {
print_enrollment(bind_ip, args.port, &token)?; print_enrollment(bind_ip, args.port, &token)?;
} }
let certs_dir = args.certs.unwrap_or_else(|| repo_root().join("certs")); let certs_dir = args
.certs
.unwrap_or_else(|| prefer_xdg(config_home().join("certs"), repo_root().join("certs"), "certificates"));
let leaf_cert = certs_dir.join("leaf.pem"); let leaf_cert = certs_dir.join("leaf.pem");
let leaf_key = certs_dir.join("leaf-key.pem"); let leaf_key = certs_dir.join("leaf-key.pem");
if !leaf_cert.is_file() || !leaf_key.is_file() { if !leaf_cert.is_file() || !leaf_key.is_file() {
bail!( bail!(
"missing {} / {} -- run ./gen-dev-cert.sh first (the app pins the CA it generates, \ "missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
and this server refuses to serve without TLS)", it generates and this server refuses to serve without TLS, so the private keys must \
be generated here and stay here.",
leaf_cert.display(), leaf_cert.display(),
leaf_key.display(), leaf_key.display(),
); );
@@ -230,3 +287,47 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
/// Choosing the wrong file here is silent -- the server would come up
/// happily on the wrong tokens and the wrong session list -- so the
/// precedence is worth pinning down.
#[test]
fn state_prefers_xdg_and_falls_back_to_the_repo_only_when_it_must() {
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("xdg/config.json");
let legacy = dir.path().join("repo/config.json");
std::fs::create_dir_all(xdg.parent().unwrap()).expect("mkdir");
std::fs::create_dir_all(legacy.parent().unwrap()).expect("mkdir");
// Neither exists: the XDG path, which is where a first run writes.
assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), xdg);
// Only the old in-repo one exists: keep using it rather than
// silently starting empty and losing the enrollment.
std::fs::write(&legacy, "{}").expect("write");
assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), legacy);
// Both exist: the moved one wins, so finishing a migration takes
// effect even if the old file was left behind.
std::fs::write(&xdg, "{}").expect("write");
assert_eq!(prefer_xdg(xdg.clone(), legacy, "the config"), xdg);
}
#[test]
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
// Relative values are ignored per the spec, rather than resolving
// against whatever the working directory happens to be.
unsafe { std::env::set_var("AI_APP_TEST_XDG", "relative/path") };
let fallback = xdg_dir("AI_APP_TEST_XDG", ".config");
assert!(fallback.is_absolute() || fallback.starts_with("."));
assert!(fallback.ends_with("ai-app"));
unsafe { std::env::set_var("AI_APP_TEST_XDG", "/somewhere") };
assert_eq!(xdg_dir("AI_APP_TEST_XDG", ".config"), PathBuf::from("/somewhere/ai-app"));
unsafe { std::env::remove_var("AI_APP_TEST_XDG") };
}
}
+3 -1
View File
@@ -629,7 +629,9 @@ impl Translator {
let name = format!("{}.{extension}", super::random_hex()); let name = format!("{}.{extension}", super::random_hex());
let dir = self.session_dir.join("files"); let dir = self.session_dir.join("files");
if let Err(err) = if let Err(err) =
std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(dir.join(&name), bytes)) crate::config::create_private_dir(&dir)
.map_err(std::io::Error::other)
.and_then(|()| std::fs::write(dir.join(&name), bytes))
{ {
tracing::error!("couldn't save produced image: {err}"); tracing::error!("couldn't save produced image: {err}");
return None; return None;
+3 -4
View File
@@ -147,7 +147,7 @@ impl LiveSession {
}; };
let name = format!("{}.{extension}", random_hex()); let name = format!("{}.{extension}", random_hex());
let dir = self.dir().join("attachments"); let dir = self.dir().join("attachments");
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; crate::config::create_private_dir(&dir)?;
std::fs::write(dir.join(&name), bytes) std::fs::write(dir.join(&name), bytes)
.with_context(|| format!("write attachment {name}"))?; .with_context(|| format!("write attachment {name}"))?;
Ok(name) Ok(name)
@@ -189,8 +189,7 @@ impl SessionManager {
/// session spawns its event pump). /// session spawns its event pump).
pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> { pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> {
let config = Config::load(&config_path)?; let config = Config::load(&config_path)?;
std::fs::create_dir_all(&data_dir) crate::config::create_private_dir(&data_dir)?;
.with_context(|| format!("create {}", data_dir.display()))?;
let mut live = HashMap::new(); let mut live = HashMap::new();
for meta in &config.sessions { for meta in &config.sessions {
@@ -452,7 +451,7 @@ fn launch(
data_dir: &Path, data_dir: &Path,
) -> Result<Arc<LiveSession>> { ) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id); let dir = data_dir.join(&meta.id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; crate::config::create_private_dir(&dir)?;
let transcript_path = dir.join("transcript.jsonl"); let transcript_path = dir.join("transcript.jsonl");
let transcript = Transcript::open(&transcript_path)?; let transcript = Transcript::open(&transcript_path)?;
+4
View File
@@ -8,6 +8,7 @@
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -36,9 +37,12 @@ impl Transcript {
/// the last line if one exists. /// the last line if one exists.
pub fn open(path: &Path) -> Result<Self> { pub fn open(path: &Path) -> Result<Self> {
let last_seq = last_seq(path)?; let last_seq = last_seq(path)?;
// Owner-only: a transcript is the whole conversation, including
// whatever the session read, wrote, or was told.
let file = OpenOptions::new() let file = OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.mode(0o600)
.open(path) .open(path)
.with_context(|| format!("open transcript {}", path.display()))?; .with_context(|| format!("open transcript {}", path.display()))?;
Ok(Self { file, next_seq: last_seq + 1 }) Ok(Self { file, next_seq: last_seq + 1 })