Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
29 changes: 29 additions & 0 deletions docs/next/website/src/content/docs/integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ herdr integration uninstall antigravity-cli
herdr integration uninstall grok
```

On Linux and macOS, shared agent configuration is staged in a temporary file and atomically replaced after a complete write. Existing Windows configs are instead backed up before being updated in place, preserving their ownership and access rules; interrupted updates may require recovery. New files use staged replacement on every platform. Herdr follows symlinks and rejects configurations with multiple hard links before changing hook files; use a separate file or a symlink before retrying. Protection is per file, not an installation transaction, concurrency control, or a power-loss durability guarantee. Later failures can leave earlier installation changes in place.

<details>
<summary>Windows config recovery</summary>

Before changing an existing config, Herdr saves and syncs its original contents in a private `<config>.herdr-backup` file. A failed write retains this recovery copy and reports both paths. Further updates refuse to overwrite it, even if the config is missing or invalid. Existing EFS-encrypted configs are rejected unchanged rather than creating plaintext backups.

A `.herdr-backup.pending` file is an unfinished backup, **not a recovery source**. The in-place write has not started for that backup. Inspect the config before removing the unfinished file and retrying. A completed backup can also remain after a successful update if cleanup failed or was interrupted; its presence alone does not mean the current config is damaged.

If recovery is needed, close the agent and restore the backup's **contents into the existing config**, not by moving the backup over it. In PowerShell, set `$config` to the config path reported by Herdr:

```powershell
$ErrorActionPreference = 'Stop'
$config = 'C:\path\to\cli.json'
$backup = "$config.herdr-backup"
$bytes = [System.IO.File]::ReadAllBytes($backup)
$file = [System.IO.File]::Open($config, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
$file.SetLength(0)
$file.Write($bytes, 0, $bytes.Length)
$file.Flush($true)
} finally {
$file.Dispose()
}
```

Keep the backup if restoration fails. After verifying the config is correct, remove the completed backup with `Remove-Item -LiteralPath $backup` before retrying the integration update. If the original file is missing, restore its intended permissions as well; creating a replacement file does not preserve them automatically.
</details>

## How Herdr uses integrations

Herdr uses integrations in two ways:
Expand Down
29 changes: 29 additions & 0 deletions docs/next/website/src/content/docs/ja/integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ herdr integration uninstall antigravity-cli
herdr integration uninstall grok
```

Linux と macOS では、共有エージェント設定を一時ファイルに完全に書き込んでからアトミックに置き換えます。Windows の既存設定は、所有者とアクセス規則を維持するため、バックアップ後に同じファイルを更新します。中断された更新には復旧が必要な場合があります。新規ファイルはすべてのプラットフォームで一時ファイルを使って作成します。Herdr はシンボリックリンクをたどり、複数のハードリンクがある設定はフックファイルの変更前に拒否します。独立したファイルまたはシンボリックリンクに変更して再試行してください。これはファイル単位の保護であり、インストール全体のトランザクション、同時編集の制御、停電時の永続性は保証しません。後から失敗すると、それまでのインストール変更が残る場合があります。

<details>
<summary>Windows の設定の復旧</summary>

既存設定を変更する前に、Herdr は元の内容を非公開の `<config>.herdr-backup` に保存して同期します。書き込みが失敗すると、この復旧用コピーを保持し、両方のパスを通知します。設定が存在しない場合や無効な場合も、コピーが残っている間は再更新を拒否します。EFS で暗号化された既存設定は、平文バックアップを作らず、変更せずに拒否します。

`.herdr-backup.pending` は未完了のバックアップであり、**復旧元として使わないでください**。そのバックアップに対応する設定への書き込みはまだ始まっていません。設定を確認してから未完了ファイルを削除し、再試行してください。更新成功後でも、後処理の失敗や中断により完成済みバックアップが残ることがあります。バックアップの存在だけでは、現在の設定が壊れているとは判断できません。

復旧が必要ならエージェントを終了し、バックアップを移動して置き換えるのではなく、**既存設定ファイルに内容を書き戻してください**。PowerShell で `$config` を Herdr が通知した設定パスに変更します。

```powershell
$ErrorActionPreference = 'Stop'
$config = 'C:\path\to\cli.json'
$backup = "$config.herdr-backup"
$bytes = [System.IO.File]::ReadAllBytes($backup)
$file = [System.IO.File]::Open($config, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write)
try {
$file.SetLength(0)
$file.Write($bytes, 0, $bytes.Length)
$file.Flush($true)
} finally {
$file.Dispose()
}
```

復旧に失敗したらバックアップを保持してください。設定が正しいことを確認した後、`Remove-Item -LiteralPath $backup` で完成済みバックアップを削除してから統合の更新を再試行します。元のファイルが存在しない場合は、意図したアクセス権も復元してください。新しいファイルを作るだけでは元の権限は保持されません。
</details>

## Herdr がインテグレーションをどう使うか

Herdr はインテグレーションを 2 つの異なる方法で使います:
Expand Down
29 changes: 29 additions & 0 deletions docs/next/website/src/content/docs/zh-cn/integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ herdr integration uninstall antigravity-cli
herdr integration uninstall grok
```

在 Linux 和 macOS 上,共享的智能体配置会完整写入临时文件,再进行原子替换。Windows 上的现有配置则先备份,再原地更新,以保留文件所有者和访问规则;更新中断后可能需要恢复。所有平台的新文件都使用临时文件方式创建。Herdr 会跟随符号链接,并在修改钩子文件之前拒绝具有多个硬链接的配置;请改用独立文件或符号链接后重试。这是单文件保护,不是整个安装的事务、并发编辑控制或断电持久性保证。后续失败仍可能留下之前已完成的安装变更。

<details>
<summary>Windows 配置恢复</summary>

修改现有配置前,Herdr 会将原始内容保存并同步到私有的 `<config>.herdr-backup` 文件。写入失败时会保留此恢复副本并报告两个路径。即使配置已丢失或无效,后续更新也不会覆盖尚未处理的恢复副本。对于现有的 EFS 加密配置,Herdr 会拒绝更新并保持原样,而不是创建明文备份。

`.herdr-backup.pending` 是未完成的备份,**不能作为恢复来源**。对应的原地写入尚未开始。请检查配置后再删除未完成文件并重试。更新成功后,如果清理失败或中断,完整备份也可能留下;仅凭备份存在不能判断当前配置已损坏。

需要恢复时,请先关闭智能体,将备份的**内容写回现有配置文件**,不要移动备份来替换它。在 PowerShell 中,将 `$config` 设置为 Herdr 报告的配置路径:

```powershell
$ErrorActionPreference = 'Stop'
$config = 'C:\path\to\cli.json'
$backup = "$config.herdr-backup"
$bytes = [System.IO.File]::ReadAllBytes($backup)
$file = [System.IO.File]::Open($config, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write)
try {
$file.SetLength(0)
$file.Write($bytes, 0, $bytes.Length)
$file.Flush($true)
} finally {
$file.Dispose()
}
```

恢复失败时请保留备份。确认配置正确后,运行 `Remove-Item -LiteralPath $backup` 删除完整备份,再重试集成更新。如果原文件已丢失,还需恢复其预期权限;创建替代文件不会自动保留原权限。
</details>

## Herdr 如何使用集成

Herdr 以两种不同方式使用集成:
Expand Down
147 changes: 147 additions & 0 deletions src/integration/config_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! Protected writes for user-owned integration configuration, not managed assets.

use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

#[cfg(test)]
mod tests;

static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);

/// Check before changing assets as well as immediately before replacing a config.
/// This is deliberately not config parsing or a transaction across multiple files.
pub(super) fn check_config_targets(dir: &Path, names: &[&str]) -> io::Result<()> {
for name in names {
check_config_target(&dir.join(name))?;
}
Ok(())
}

pub(super) fn check_config_target(path: &Path) -> io::Result<()> {
reject_hard_links(path)?;
crate::platform::check_config_write_target(&resolve_target(path)?)
}

fn reject_hard_links(path: &Path) -> io::Result<()> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
if metadata.is_file() && crate::platform::config_file_link_count(path)? > 1 {
return Err(io::Error::other(format!(
"cannot update {}: config has multiple hard links; use a separate file or a symlink before retrying",
path.display()
)));
}
Ok(())
}

// Unlike canonicalize, this also follows dangling symlinks on a first install.
fn resolve_target(path: &Path) -> io::Result<PathBuf> {
let mut current = path.to_path_buf();
for _ in 0..40 {
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let link = fs::read_link(&current)?;
current = if link.is_absolute() {
link
} else {
current.parent().unwrap_or(Path::new(".")).join(link)
};
}
Ok(metadata) if !metadata.is_file() => {
return Err(io::Error::other(format!(
"cannot update {}: config is not a regular file",
path.display()
)));
}
Ok(_) => return Ok(current),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(current),
Err(error) => return Err(error),
}
}
Err(io::Error::other(format!(
"cannot update {}: too many symbolic links",
path.display()
)))
}

pub(super) fn write_config(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
check_config_target(path)?;
let target = resolve_target(path)?;
if crate::platform::write_existing_config(&target, contents.as_ref())? {
return Ok(());
}
let replacement = Replacement::prepare(&target, contents.as_ref())?;
replacement.commit()
}

struct Replacement {
target: PathBuf,
temporary: PathBuf,
}

impl Replacement {
fn prepare(path: &Path, contents: &[u8]) -> io::Result<Self> {
reject_hard_links(path)?;
let target = resolve_target(path)?;
let existing = match fs::metadata(&target) {
Ok(_) => {
// A writable parent must not let rename bypass file write permissions.
OpenOptions::new().read(true).write(true).open(&target)?;
Some(target.as_path())
}
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
for _ in 0..128 {
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
let temporary = parent.join(format!(
".herdr-config-{}-{sequence}.tmp",
std::process::id()
));
// Existing configs can contain secrets. Start their staging file private;
// the platform writer preserves the original permissions before publication.
// New configs retain ordinary create/umask/inherited-ACL defaults.
let created = crate::platform::create_config_temporary(&temporary, existing.is_some());
match created {
Ok(file) => drop(file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
let replacement = Self {
target: target.clone(),
temporary,
};
crate::platform::write_config_temporary(existing, &replacement.temporary, contents)?;
return Ok(replacement);
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not allocate a unique config temporary file",
))
}

fn commit(self) -> io::Result<()> {
reject_hard_links(&self.target)?;
crate::platform::replace_file(&self.temporary, &self.target)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

impl Drop for Replacement {
fn drop(&mut self) {
// After publication the temporary name is absent. Never remove the target.
if let Err(error) = fs::remove_file(&self.temporary) {
if error.kind() != io::ErrorKind::NotFound {
tracing::warn!(path = %self.temporary.display(), %error, "failed to remove integration config temporary file");
}
}
}
}
Loading
Loading