diff --git a/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs
new file mode 100644
index 0000000..361dd22
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/ExternalProcessRunner.cs
@@ -0,0 +1,79 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace Everything2Everything.Core.Converters;
+
+public sealed record ProcessRunResult(int ExitCode, string StdOut, string StdErr, bool TimedOut)
+{
+ public bool Success => !TimedOut && ExitCode == 0;
+}
+
+///
+/// 외부 CLI 프로세스 실행을 일원화한다 — stdout/stderr 수집, 타임아웃, 취소 시 프로세스 트리 종료.
+/// LibreOffice·FFmpeg·Ghostscript·qpdf 등 모든 외부 도구 어댑터가 공유하는 실행기.
+///
+public static class ExternalProcessRunner
+{
+ public static async Task RunAsync(
+ string fileName,
+ IEnumerable arguments,
+ TimeSpan? timeout = null,
+ string? workingDirectory = null,
+ CancellationToken cancellationToken = default)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ foreach (var a in arguments) psi.ArgumentList.Add(a);
+ if (!string.IsNullOrEmpty(workingDirectory)) psi.WorkingDirectory = workingDirectory;
+
+ using var proc = new Process { StartInfo = psi };
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
+
+ if (!proc.Start())
+ throw new InvalidOperationException($"프로세스를 시작하지 못했습니다: {fileName}");
+
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ if (timeout is { } t) linked.CancelAfter(t);
+
+ var timedOut = false;
+ try
+ {
+ await proc.WaitForExitAsync(linked.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // 사용자 취소면 전파, 타임아웃이면 TimedOut으로 보고
+ timedOut = !cancellationToken.IsCancellationRequested;
+ try { proc.Kill(entireProcessTree: true); } catch { /* 이미 종료 */ }
+ if (cancellationToken.IsCancellationRequested)
+ {
+ try { proc.WaitForExit(2000); } catch { }
+ throw;
+ }
+ }
+
+ // Kill 또는 종료 후 비동기 출력 콜백이 마저 도착하도록 잠깐 대기
+ try { proc.WaitForExit(2000); } catch { }
+
+ var exitCode = timedOut ? -1 : SafeExitCode(proc);
+ return new ProcessRunResult(exitCode, stdout.ToString(), stderr.ToString(), timedOut);
+ }
+
+ private static int SafeExitCode(Process proc)
+ {
+ try { return proc.ExitCode; }
+ catch { return -1; }
+ }
+}
diff --git a/src/Everything2Everything.Core/Everything2Everything.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
index dc1e9ad..12a9a38 100644
--- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj
+++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
@@ -24,6 +24,7 @@
+
diff --git a/src/Everything2Everything.Core/SettingsStore.cs b/src/Everything2Everything.Core/SettingsStore.cs
new file mode 100644
index 0000000..e7fb48b
--- /dev/null
+++ b/src/Everything2Everything.Core/SettingsStore.cs
@@ -0,0 +1,92 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace Everything2Everything.Core;
+
+/// 키-값 설정 저장소. API 키·외부 도구 경로 등 민감 정보를 영속화한다.
+public interface ISettingsStore
+{
+ string? Get(string key);
+ void Set(string key, string value);
+ void Remove(string key);
+ bool Contains(string key);
+}
+
+///
+/// DPAPI(CurrentUser)로 암호화해 %LOCALAPPDATA%\Everything2Everything\settings.dat 에 저장한다.
+/// 현재 사용자 계정에서만 복호화 가능하므로 API 키 저장에 적합하다. Windows 전용.
+///
+public sealed class DpapiSettingsStore : ISettingsStore
+{
+ private readonly string _path;
+ private readonly Dictionary _cache;
+ private readonly object _lock = new();
+
+ public DpapiSettingsStore(string? filePath = null)
+ {
+ _path = filePath ?? DefaultPath();
+ _cache = Load(_path);
+ }
+
+ public static string DefaultPath()
+ {
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Everything2Everything");
+ Directory.CreateDirectory(dir);
+ return Path.Combine(dir, "settings.dat");
+ }
+
+ public string? Get(string key)
+ {
+ lock (_lock) return _cache.TryGetValue(key, out var v) ? v : null;
+ }
+
+ public bool Contains(string key)
+ {
+ lock (_lock) return _cache.ContainsKey(key);
+ }
+
+ public void Set(string key, string value)
+ {
+ lock (_lock)
+ {
+ _cache[key] = value;
+ Save();
+ }
+ }
+
+ public void Remove(string key)
+ {
+ lock (_lock)
+ {
+ if (_cache.Remove(key)) Save();
+ }
+ }
+
+ private void Save()
+ {
+ var json = JsonSerializer.SerializeToUtf8Bytes(_cache);
+ var encrypted = ProtectedData.Protect(json, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ var tmp = _path + ".tmp";
+ File.WriteAllBytes(tmp, encrypted);
+ if (File.Exists(_path)) File.Delete(_path);
+ File.Move(tmp, _path);
+ }
+
+ private static Dictionary Load(string path)
+ {
+ try
+ {
+ if (!File.Exists(path)) return new(StringComparer.Ordinal);
+ var encrypted = File.ReadAllBytes(path);
+ var json = ProtectedData.Unprotect(encrypted, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ return JsonSerializer.Deserialize>(json) ?? new(StringComparer.Ordinal);
+ }
+ catch
+ {
+ // 손상/복호화 실패 시 빈 저장소로 시작(설정은 비치명적)
+ return new(StringComparer.Ordinal);
+ }
+ }
+}
diff --git a/src/Everything2Everything.Tests/InfraTests.cs b/src/Everything2Everything.Tests/InfraTests.cs
new file mode 100644
index 0000000..442119e
--- /dev/null
+++ b/src/Everything2Everything.Tests/InfraTests.cs
@@ -0,0 +1,96 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+public class SettingsStoreTests
+{
+ private static string TempFile()
+ => Path.Combine(Path.GetTempPath(), "e2e_settings_" + Guid.NewGuid().ToString("N") + ".dat");
+
+ [Fact]
+ public void Set_Get_Roundtrip_AndPersists()
+ {
+ var path = TempFile();
+ try
+ {
+ var store = new DpapiSettingsStore(path);
+ store.Set("openai.key", "sk-secret-123");
+ Assert.Equal("sk-secret-123", store.Get("openai.key"));
+ Assert.True(store.Contains("openai.key"));
+
+ // 새 인스턴스로 다시 로드 — 영속 + 복호화 확인
+ var reloaded = new DpapiSettingsStore(path);
+ Assert.Equal("sk-secret-123", reloaded.Get("openai.key"));
+ }
+ finally { try { File.Delete(path); } catch { } }
+ }
+
+ [Fact]
+ public void File_IsEncrypted_NotPlaintext()
+ {
+ var path = TempFile();
+ try
+ {
+ new DpapiSettingsStore(path).Set("k", "PLAINTEXT_MARKER");
+ var bytes = File.ReadAllBytes(path);
+ var asText = System.Text.Encoding.UTF8.GetString(bytes);
+ Assert.DoesNotContain("PLAINTEXT_MARKER", asText);
+ }
+ finally { try { File.Delete(path); } catch { } }
+ }
+
+ [Fact]
+ public void Remove_DeletesKey()
+ {
+ var path = TempFile();
+ try
+ {
+ var store = new DpapiSettingsStore(path);
+ store.Set("k", "v");
+ store.Remove("k");
+ Assert.Null(store.Get("k"));
+ Assert.False(store.Contains("k"));
+ }
+ finally { try { File.Delete(path); } catch { } }
+ }
+}
+
+public class ExternalProcessRunnerTests
+{
+ [Fact]
+ public async Task Echo_CapturesStdout_AndExitZero()
+ {
+ var r = await ExternalProcessRunner.RunAsync(
+ "cmd.exe", new[] { "/c", "echo", "hello_e2e" },
+ timeout: TimeSpan.FromSeconds(10), cancellationToken: CancellationToken.None);
+ Assert.True(r.Success);
+ Assert.Equal(0, r.ExitCode);
+ Assert.Contains("hello_e2e", r.StdOut);
+ }
+
+ [Fact]
+ public async Task NonZeroExit_IsReported()
+ {
+ var r = await ExternalProcessRunner.RunAsync(
+ "cmd.exe", new[] { "/c", "exit", "3" },
+ timeout: TimeSpan.FromSeconds(10), cancellationToken: CancellationToken.None);
+ Assert.False(r.Success);
+ Assert.Equal(3, r.ExitCode);
+ }
+
+ [Fact]
+ public async Task Timeout_IsDetected_AndProcessKilled()
+ {
+ var r = await ExternalProcessRunner.RunAsync(
+ "cmd.exe", new[] { "/c", "ping", "127.0.0.1", "-n", "10" },
+ timeout: TimeSpan.FromMilliseconds(400), cancellationToken: CancellationToken.None);
+ Assert.True(r.TimedOut);
+ Assert.False(r.Success);
+ }
+}