From cc11e2985b239bb6610bbd2817927fac868870d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:51:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Windows:=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B5=D0=BC=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BD=D1=83=D0=B6=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=B0=D0=BA=20=D0=BD?= =?UTF-8?q?=D0=B0=20Mac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше restic, Claude Code, docker и остальные получали всё окружение человека, менялся только PATH. Свой RESTIC_PASSWORD подменял пароль, введённый в OffLoadAI (restic берёт его вместо stdin и отвечает «wrong password» — проверено на restic 0.16.4), а ANTHROPIC_API_KEY или CLAUDE_CODE_USE_BEDROCK меняли вход Claude Code и то, куда уходит список файлов. Теперь передаются только системные переменные (каталоги Windows, профиль, временная папка), куда подключаться Docker и где лежат машины VirtualBox. Claude Code сверх этого получает папку своих настроек и входа, путь к Git Bash, прокси и сертификаты прокси. Проверки: фильтр и настоящий запуск программы, которая печатает своё окружение. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019g9zXsGLHyDxJzGtUAQUVY --- SECURITY.md | 6 ++--- windows/src/Offload.Checks/ChecksAssistant.cs | 2 ++ windows/src/Offload.Checks/ChecksMisc.cs | 26 +++++++++++++++++++ windows/src/Offload.Checks/Program.cs | 7 ++++- .../src/Offload.Core/AssistantClaudeCode.cs | 11 +++++++- windows/src/Offload.Core/Runner.cs | 21 +++++++++++++-- 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 51031b8..f5c70c0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,7 +16,7 @@ OffLoadAI переносит и удаляет файлы, поэтому без **Открытые файлы.** Пока файлы открыты в другом приложении, перенос запрещён. Проверка идёт при плане и ещё раз — перед копированием и перед удалением оригинала: план может часами стоять в окне подтверждения. Если проверить не удалось при плане, OffLoadAI требует явного подтверждения, а не считает, что всё чисто. Архив на диске, который удаляется после «Зашифровать перенесённое» или «Вернуть и удалить с диска», тоже должен быть закрыт и не меняться с начала копирования, иначе он остаётся на месте. -**Внешние программы запускаются без оболочки.** Аргументы передаются массивом, поэтому пробелы, кавычки, `;`, `$()` и обратные кавычки в путях не становятся командами. Системные программы (`hdiutil`, `lsof`, `du`, `ssh-keygen`) берутся только из `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin`; переменная `PATH` не используется. +**Внешние программы запускаются без оболочки.** Аргументы передаются массивом, поэтому пробелы, кавычки, `;`, `$()` и обратные кавычки в путях не становятся командами. Системные программы (`hdiutil`, `lsof`, `du`, `ssh-keygen`) берутся только из `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin`; переменная `PATH` не используется. Окружение запущенным программам передаётся только нужное — `PATH` из известных каталогов, домашняя папка, язык и куда подключаться Docker; свои `RESTIC_PASSWORD` или `ANTHROPIC_API_KEY` человека до restic и Claude Code не доходят и не подменяют введённый пароль или вход. **Файлы назначения не перезаписываются.** Они открываются с `O_EXCL | O_NOFOLLOW`: подложенная символическая ссылка на месте копии не приведёт к записи в чужой файл. Это касается и спутников архива — списка сумм `.sha256` и прав `.modes.json`: они пишутся так же и до переименования архива, а имя архива выбирается свободным вместе с ними. Служебные файлы с внешнего диска (журнал, списки, метки копирования) читаются без перехода по ссылкам, только если это обычный файл нужного размера: ссылка на `/dev/zero` или FIFO на их месте не подвешивает программу. Исходные файлы открываются с `O_NOFOLLOW`: если файл подменят ссылкой между проверкой и копированием, чтение не уйдёт по ней. @@ -79,10 +79,10 @@ OffLoadAI переносит и удаляет файлы, поэтому без - **Копия заголовка** — три блока метаданных BitLocker по 64 КБ, снятые с закрытого образа (права — только владельцу). Восстанавливается она на закрытый образ и проверяется открытием паролем; не подошла — прежние метаданные возвращаются, а на время работы они лежат рядом с образом. - **Сжатие сейфа.** TRIM через образ VHDX до файла не доходит, поэтому «Вернуть место на диск» переписывает сейф заново: новый образ с тем же паролем, копирование со сверкой, и только потом замена. После этого старые копии заголовка к сейфу не подходят — OffLoadAI об этом говорит. - **Корзина.** Удаляемое уходит в Корзину Windows так же, как из Проводника; OffLoadAI запоминает, куда легло каждое, и возвращает или удаляет насовсем только то самое (по номеру файла). То, что больше Корзины (Windows удалила бы это насовсем, не спросив), в Корзину не отправляется вовсе. -- **Внешние программы** запускаются без оболочки; системные (`powershell`, `tar`, `ssh-keygen`, `defrag`) — только из `System32`, сторонние (`docker`, `zstd`, `restic`) — из известных каталогов. Сценарии PowerShell, которые OffLoadAI запускает для разметки дисков, секретов не содержат. +- **Внешние программы** запускаются без оболочки; системные (`powershell`, `tar`, `ssh-keygen`, `defrag`) — только из `System32`, сторонние (`docker`, `zstd`, `restic`) — из известных каталогов. Окружение у них, как на Mac, — только нужное: каталоги Windows, профиль, временная папка, настройки Docker и VirtualBox (до этой версии передавалось всё окружение, и свой `RESTIC_PASSWORD` человека подменял пароль, введённый в OffLoadAI). Сценарии PowerShell, которые OffLoadAI запускает для разметки дисков, секретов не содержат. - **Установка** командой `irm … | iex` сверяет SHA-256 и, если есть `gh`, подтверждение сборки. `OffLoadAI.exe` не подписан сертификатом: при запуске файла, скачанного браузером, Windows SmartScreen попросит подтвердить запуск. Файлы, скачанные установщиком PowerShell, отметки «из интернета» не получают. - **Ограничения, которых нет на Mac:** сейфу нужна Windows 10/11 Pro, Enterprise или Education (в Home нет BitLocker); символические ссылки при переносе Windows создаёт только администратору или в режиме разработчика — без этого перенос папки с ними отклоняется заранее (точки соединения создаются всегда); журнал и база решений лежат в `%LOCALAPPDATA%\Offload` и защищены правами профиля и BitLocker системного диска, если он включён. ## Security (English summary) -Report vulnerabilities privately via **Security → Report a vulnerability**. OffLoadAI deletes an original only after a byte-for-byte SHA-256 verified copy and a check that the source did not change; refuses to move app-registered bundles and app data; runs external tools without a shell and system tools only from system directories; cross-checks two independent traversals (`readdir` and `fts`) before deleting anything; trashes a redundant duplicate only after re-comparing it byte-for-byte with the copy that stays (one copy of every file always stays, library media, bundles, git projects, APFS clones, not-downloaded iCloud files, encrypted .dmg and .iso images are never removed); cleanup acts only on your explicit "yes" to each question: deletion is offered only for regenerable caches from a fixed list (not caches of running apps), Docker build cache and dangling images (never tagged images, volumes or containers), redundant duplicate copies, and installers older than a week (only by their own answer, never by "Allow all"); UTM virtual machines are never deleted — that is done in UTM itself; everything it deletes except Docker images goes to the Trash with one-click "put back" (permanent deletion only on request, only of what this run trashed); learns habits from your own answers only on this Mac (a habit never adds anything to a deletion question; "Forget my decisions" clears them); never overwrites destination files or follows symlinks when reading sources (`O_EXCL | O_NOFOLLOW`); applies restored permissions only to paths that were actually restored; passes the vault password to `hdiutil` via stdin; treats the on-drive journal as untrusted; validates Docker volume names and runs helper containers with `--network none --log-driver none`; refuses to touch a non-local Docker context; verifies Docker volume archives by per-file content hashes; restores only into a volume it created itself; locks the vault root to the owner (`700`); flushes copies with `F_FULLFSYNC` and verifies them bypassing the page cache. The optional AI assistant is the only part that goes online: it runs only with consent given per backend (Anthropic via Claude Code or an API key, local Ollama, or the OffLoadAI server, which also receives the Pro key with the buyer's name) and revocable at any time; it sends paths relative to home, sizes, dates and rule marks — the beginning of small text files only if you turn that on, and never for spreadsheets, key files or text that looks like a password, token, card number or recovery phrase; its advice is re-checked by the same rules (it can suggest deleting only regenerable caches and installers older than a week, anything else only moving to the vault), re-checked again before trashing, and every action is your click. Known limitations: ad-hoc signing without notarization, checksum-from-same-release in the curl installer, heuristic secret detection for open backups and for assistant previews (off by default). The Windows version (`windows/`) keeps the same rules with Windows mechanisms: `CREATE_NEW` + reparse-point-safe opens, `FlushFileBuffers` and unbuffered verification, two traversals (`FindFirstFileEx` and .NET enumeration), Restart Manager for open files, a BitLocker (XTS-AES-256) VHDX vault whose encryption is verified from the image header before opening and by BitLocker itself after, password passed to BitLocker via WMI, administrator rights only for an elevated vault helper process reached over an ACL-restricted, peer-verified named pipe, and the Windows Recycle Bin with identity checks. +Report vulnerabilities privately via **Security → Report a vulnerability**. OffLoadAI deletes an original only after a byte-for-byte SHA-256 verified copy and a check that the source did not change; refuses to move app-registered bundles and app data; runs external tools without a shell, with only the environment they need (your own `RESTIC_PASSWORD` or `ANTHROPIC_API_KEY` never reaches restic or Claude Code), and system tools only from system directories; cross-checks two independent traversals (`readdir` and `fts`) before deleting anything; trashes a redundant duplicate only after re-comparing it byte-for-byte with the copy that stays (one copy of every file always stays, library media, bundles, git projects, APFS clones, not-downloaded iCloud files, encrypted .dmg and .iso images are never removed); cleanup acts only on your explicit "yes" to each question: deletion is offered only for regenerable caches from a fixed list (not caches of running apps), Docker build cache and dangling images (never tagged images, volumes or containers), redundant duplicate copies, and installers older than a week (only by their own answer, never by "Allow all"); UTM virtual machines are never deleted — that is done in UTM itself; everything it deletes except Docker images goes to the Trash with one-click "put back" (permanent deletion only on request, only of what this run trashed); learns habits from your own answers only on this Mac (a habit never adds anything to a deletion question; "Forget my decisions" clears them); never overwrites destination files or follows symlinks when reading sources (`O_EXCL | O_NOFOLLOW`); applies restored permissions only to paths that were actually restored; passes the vault password to `hdiutil` via stdin; treats the on-drive journal as untrusted; validates Docker volume names and runs helper containers with `--network none --log-driver none`; refuses to touch a non-local Docker context; verifies Docker volume archives by per-file content hashes; restores only into a volume it created itself; locks the vault root to the owner (`700`); flushes copies with `F_FULLFSYNC` and verifies them bypassing the page cache. The optional AI assistant is the only part that goes online: it runs only with consent given per backend (Anthropic via Claude Code or an API key, local Ollama, or the OffLoadAI server, which also receives the Pro key with the buyer's name) and revocable at any time; it sends paths relative to home, sizes, dates and rule marks — the beginning of small text files only if you turn that on, and never for spreadsheets, key files or text that looks like a password, token, card number or recovery phrase; its advice is re-checked by the same rules (it can suggest deleting only regenerable caches and installers older than a week, anything else only moving to the vault), re-checked again before trashing, and every action is your click. Known limitations: ad-hoc signing without notarization, checksum-from-same-release in the curl installer, heuristic secret detection for open backups and for assistant previews (off by default). The Windows version (`windows/`) keeps the same rules with Windows mechanisms: `CREATE_NEW` + reparse-point-safe opens, `FlushFileBuffers` and unbuffered verification, two traversals (`FindFirstFileEx` and .NET enumeration), Restart Manager for open files, a BitLocker (XTS-AES-256) VHDX vault whose encryption is verified from the image header before opening and by BitLocker itself after, password passed to BitLocker via WMI, administrator rights only for an elevated vault helper process reached over an ACL-restricted, peer-verified named pipe, and the Windows Recycle Bin with identity checks. diff --git a/windows/src/Offload.Checks/ChecksAssistant.cs b/windows/src/Offload.Checks/ChecksAssistant.cs index ca1f27a..30f36ea 100644 --- a/windows/src/Offload.Checks/ChecksAssistant.cs +++ b/windows/src/Offload.Checks/ChecksAssistant.cs @@ -137,6 +137,8 @@ SpaceItem Item(string path, int daysAgo, bool folder = false, Verdict? verdict = ExpectError("не вошёл в учётную запись — понятная ошибка", () => claude.Read("""{"is_error":true,"result":"Not logged in · Please run /login"}""", "", facts), e => e is AssistantException { Kind: AssistantErrorKind.NotSignedIn }); ExpectError("мусор вместо JSON — ошибка", () => claude.Read("oops", "boom", facts), e => e is AssistantException { Kind: AssistantErrorKind.Failed }); + Check(!ClaudeCodeAssistant.ClaudeVariables.Any(name => name.StartsWith("ANTHROPIC_") || name.StartsWith("CLAUDE_CODE_USE_")), + "Claude Code не получает ни ключ API, ни выбор другого облака"); }); if (!Env("OFFLOAD_ASSISTANT_LIVE")) return; diff --git a/windows/src/Offload.Checks/ChecksMisc.cs b/windows/src/Offload.Checks/ChecksMisc.cs index b34c9e8..238abc0 100644 --- a/windows/src/Offload.Checks/ChecksMisc.cs +++ b/windows/src/Offload.Checks/ChecksMisc.cs @@ -24,6 +24,24 @@ static void ChecksRunner() Check(Runner.Locate("notepad") == null, "программа не из списка известных не ищется вовсе — PATH не используется"); Check(!Runner.ChildEnvironment["PATH"].Split(';').Any(p => p.Contains(@"\Temp", StringComparison.OrdinalIgnoreCase)), "в PATH запускаемых программ нет временных папок"); + // Свои настройки человека не меняют того, что делают запущенные программы: restic не берёт чужой пароль + // вместо введённого в OffLoadAI, Claude Code не платит по ключу API и не уходит в другое облако. + string[] foreign = ["RESTIC_PASSWORD", "RESTIC_REPOSITORY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK"]; + var saved = foreign.Append("DOCKER_CONTEXT").ToDictionary(name => name, Environment.GetEnvironmentVariable); + foreach (var name in foreign) Environment.SetEnvironmentVariable(name, "чужое"); + Environment.SetEnvironmentVariable("DOCKER_CONTEXT", "desktop-linux"); + try + { + var env = Runner.ChildEnvironment; + Check(!foreign.Any(env.ContainsKey), "свои RESTIC_PASSWORD, ANTHROPIC_API_KEY и прочие настройки человека запускаемым программам не передаются"); + Check(env.TryGetValue("DOCKER_CONTEXT", out var context) && context == "desktop-linux", "куда подключаться Docker — передаётся"); + Check(new[] { "SystemRoot", "TEMP", "USERPROFILE", "LOCALAPPDATA" }.All(name => Environment.GetEnvironmentVariable(name) == null || env.ContainsKey(name)), + "каталог Windows, временная папка и профиль — передаются"); + } + finally + { + foreach (var (name, value) in saved) Environment.SetEnvironmentVariable(name, value); + } var abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; Check(FileHasher.Sha256(Encoding.UTF8.GetBytes("abc")) == abc, "SHA-256 по эталону"); @@ -51,6 +69,14 @@ static void ChecksRunner() Check(echoed.Output == hostile + "\n\n" + "второй аргумент\n", $"аргументы не интерпретируются оболочкой: «{echoed.Output}»"); Check(!File.Exists(pwned), "подстановка команды не выполнилась"); Check(Runner.Check("offload-self", ["--cat"], stdin: Encoding.UTF8.GetBytes("секрет")).Output == "секрет", "stdin передаётся программе"); + var password = Environment.GetEnvironmentVariable("RESTIC_PASSWORD"); + Environment.SetEnvironmentVariable("RESTIC_PASSWORD", "чужой пароль"); + try + { + Check(Runner.Check("offload-self", ["--env", "RESTIC_PASSWORD"]).Output == "", "свой RESTIC_PASSWORD человека до запущенной программы не доходит"); + Check(Runner.Check("offload-self", ["--env", "SystemRoot"]).Output.Length > 0, "каталог Windows до запущенной программы доходит"); + } + finally { Environment.SetEnvironmentVariable("RESTIC_PASSWORD", password); } ExpectError("зависшая программа прерывается по таймауту", () => Runner.Run("offload-self", ["--sleep", "10"], timeout: TimeSpan.FromMilliseconds(500)), ex => ex is RunnerException { Kind: RunnerErrorKind.TimedOut }); diff --git a/windows/src/Offload.Checks/Program.cs b/windows/src/Offload.Checks/Program.cs index 216f9d4..f4ac42c 100644 --- a/windows/src/Offload.Checks/Program.cs +++ b/windows/src/Offload.Checks/Program.cs @@ -13,7 +13,7 @@ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // Программа проверок служит и подопытной внешней программой для проверок Runner: печатает аргументы, -// отдаёт stdin, спит или завершается с кодом. +// отдаёт stdin, спит, завершается с кодом или печатает переменную окружения. if (args.Length >= 1 && args[0] == "--echo-args") { using var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)); @@ -35,6 +35,11 @@ return 0; } if (args.Length == 2 && args[0] == "--exit") return int.Parse(args[1]); +if (args.Length == 2 && args[0] == "--env") +{ + Console.Write(Environment.GetEnvironmentVariable(args[1]) ?? ""); + return 0; +} Directory.CreateDirectory(Scratch); Journal.LocalOverride = Path.Combine(Scratch, "history.json"); diff --git a/windows/src/Offload.Core/AssistantClaudeCode.cs b/windows/src/Offload.Core/AssistantClaudeCode.cs index c7c6470..ff8cbc2 100644 --- a/windows/src/Offload.Core/AssistantClaudeCode.cs +++ b/windows/src/Offload.Core/AssistantClaudeCode.cs @@ -17,6 +17,11 @@ public sealed class ClaudeCodeAssistant(string model = "sonnet") : IAssistantPro public string Title => "Claude Code на этом компьютере"; public static readonly TimeSpan Timeout = TimeSpan.FromMinutes(4); + /// Что Claude Code получает сверх окружения Runner: папка его настроек и входа, путь к Git Bash, + /// прокси сети и сертификаты прокси. Ключи API и выбор другого облака (Bedrock, Vertex) сюда не входят. + public static readonly string[] ClaudeVariables = + ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_GIT_BASH_PATH", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY", "NODE_EXTRA_CA_CERTS"]; + public string? Problem() => Runner.Locate("claude") == null ? "Claude Code не найден. Установите его (claude.com/claude-code) и войдите своей учётной записью Claude — затем «Проверить снова»." : null; @@ -37,8 +42,12 @@ public async Task Ask(IReadOnlyList facts, string? qu ], redirectStdin: true); process.StartInfo.WorkingDirectory = scratch; process.StartInfo.StandardOutputEncoding = Encoding.UTF8; - // Вложенный запуск из другой сессии Claude Code и чужой прокси не должны мешать: вход — подписка пользователя. + // Окружение Runner — без чужих переменных: вложенный запуск из другой сессии Claude Code, чужой прокси + // Anthropic, свой ANTHROPIC_API_KEY или Bedrock не должны мешать — вход только подпиской пользователя. + // Нужное самому Claude Code остаётся: где его настройки и вход, где Git Bash, прокси сети и его сертификаты. foreach (var name in new[] { "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "ANTHROPIC_BASE_URL" }) process.StartInfo.Environment.Remove(name); + foreach (var name in ClaudeVariables) + if (Environment.GetEnvironmentVariable(name) is { Length: > 0 } value) process.StartInfo.Environment[name] = value; process.StartInfo.Environment["CLAUDE_CODE_ENTRYPOINT"] = "offloadai"; process.Start(); var output = process.StandardOutput.ReadToEndAsync(cancel); diff --git a/windows/src/Offload.Core/Runner.cs b/windows/src/Offload.Core/Runner.cs index 18a2fbc..4a30fd1 100644 --- a/windows/src/Offload.Core/Runner.cs +++ b/windows/src/Offload.Core/Runner.cs @@ -130,14 +130,31 @@ public static IReadOnlyList ThirdPartyDirectories return null; } - /// Окружение для запускаемых программ: как у OffLoadAI, но PATH — только известные каталоги. + /// + /// Переменные окружения, которые получают запускаемые программы, — как на Mac, только нужное: каталоги Windows, + /// профиль и временная папка, без которых программы не работают или ищут свои файлы не там, и куда подключаться + /// Docker и где лежат машины VirtualBox. Остальные настройки человека не передаются: свой RESTIC_PASSWORD + /// подменил бы пароль, введённый в OffLoadAI, а ANTHROPIC_API_KEY — вход в Claude Code. + /// + public static readonly IReadOnlySet ChildVariables = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT", "TEMP", "TMP", + "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "ProgramData", "ALLUSERSPROFILE", "PUBLIC", + "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432", + "USERNAME", "USERDOMAIN", "USERDOMAIN_ROAMINGPROFILE", "COMPUTERNAME", "LOGONSERVER", "SESSIONNAME", + "OS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER", "PROCESSOR_LEVEL", "PROCESSOR_REVISION", "NUMBER_OF_PROCESSORS", + "PSModulePath", "DriverData", + "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "VBOX_USER_HOME", + }; + + /// Окружение для запускаемых программ: только , а PATH — только известные каталоги. public static Dictionary ChildEnvironment { get { var env = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) - env[(string)entry.Key] = (string?)entry.Value ?? ""; + if (entry.Key is string name && ChildVariables.Contains(name)) env[name] = (string?)entry.Value ?? ""; env["PATH"] = string.Join(';', new[] { System32, SystemRoot, Path.Combine(System32, "Wbem"), Path.Combine(System32, @"WindowsPowerShell\v1.0"), Path.Combine(System32, "OpenSSH") }.Concat(ThirdPartyDirectories)); return env; From 4069a34b50f9c38522fce1ba7d0fc338bf6f73b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:51:26 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=D0=A3=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=89=D0=B8=D0=BA=20Mac:=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=8E=D1=89=D1=83=D1=8E=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D1=83=20=D0=B8=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BD=D0=B5=D1=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше установщик ждал закрытия OffLoadAI 5 секунд и заменял его всё равно: если шло копирование и программа спрашивала, прервать ли его, человек оставался в старой версии. А старую версию он удалял до копирования новой — прерванная установка оставляла без программы. Теперь: - ждёт до минуты и подсказывает ответить в окне программы; не закрылась — установка отменяется, ничего не тронув; - ищет только программы своего пользователя (pgrep -U), чужая сессия установку не блокирует; - новая версия копируется рядом, проверяется подпись копии, и встаёт на место прежней переименованием; сорвалось — прежняя возвращается на место, недокопия убирается. То же в install-local.sh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019g9zXsGLHyDxJzGtUAQUVY --- README.md | 2 +- scripts/install-local.sh | 41 ++++++++++++++++++++++++++++++++++------ scripts/install.sh | 39 +++++++++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8940d78..71da7f8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ curl -fsSL https://raw.githubusercontent.com/audit0/Offload/main/scripts/install.sh | zsh ``` -Команда скачивает последний релиз с GitHub, сверяет SHA-256, целостность подписи и идентификатор приложения, а если установлен `gh` — ещё и подтверждение сборки GitHub (что архив собран workflow этого репозитория). Затем ставит OffLoadAI в «Программы» и запускает. Прежнюю версию, которая называлась Offload, установщик закрывает и убирает; настройки, ключ Pro, журнал и сейф остаются как были. `sudo` не нужен. +Команда скачивает последний релиз с GitHub, сверяет SHA-256, целостность подписи и идентификатор приложения, а если установлен `gh` — ещё и подтверждение сборки GitHub (что архив собран workflow этого репозитория). Затем ставит OffLoadAI в «Программы» и запускает. Открытый OffLoadAI установщик сначала закрывает; если идёт копирование, программа спросит, прервать ли его, а не закрылась — установка отменяется, ничего не тронув. Новая версия копируется рядом и встаёт на место прежней в последний момент, так что прерванная установка оставляет прежнюю версию. Прежнюю версию, которая называлась Offload, установщик закрывает и убирает; настройки, ключ Pro, журнал и сейф остаются как были. `sudo` не нужен. Хотите сначала прочитать установщик — это разумно для любой команды вида `curl | zsh`: diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 7437753..bfe2afc 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -7,20 +7,49 @@ ROOT="${HERE:h}" DEST="${OFFLOAD_DEST:-/Applications}" BUNDLE_ID="io.github.audit0.offload" +fail() { print -u2 -- "⚠️ $1"; exit 1; } + "$HERE/build-app.sh" # До переименования программа звалась Offload: закрываем и её (идентификатор приложения тот же). -if pgrep -xq 'OffLoadAI|Offload'; then +# Во время копирования OffLoadAI сначала спрашивает, прервать ли его, — ждём ответа. Не закрылся — +# установка останавливается: подменить программу под работающей значит оставить человека в старой версии. +running() { pgrep -xq -U "$UID" 'OffLoadAI|Offload'; } +if running; then echo "→ закрываю запущенный OffLoadAI" osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true - for _ in {1..20}; do pgrep -xq 'OffLoadAI|Offload' || break; sleep 0.25; done + for waited in {1..240}; do + running || break + if (( waited == 12 )); then echo " OffLoadAI ещё открыт: если он спрашивает, прервать ли копирование, ответьте в его окне"; fi + sleep 0.25 + done + if running; then + fail "OffLoadAI не закрылся, установка отменена, программа не тронута. Дождитесь конца копирования или закройте OffLoadAI сами и запустите установку снова." + fi fi +# Новая версия сначала копируется рядом и встаёт на место прежней переименованием: если установку +# прервать, остаётся прежняя программа, а не пустое место. echo "→ устанавливаю в $DEST" -rm -rf "$DEST/OffLoadAI.app" -ditto "$ROOT/dist/OffLoadAI.app" "$DEST/OffLoadAI.app" -/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$DEST/OffLoadAI.app" >/dev/null 2>&1 || true -codesign --verify --strict "$DEST/OffLoadAI.app" +TARGET="$DEST/OffLoadAI.app" STAGED="$DEST/.OffLoadAI.app.new-$$" PREVIOUS="$DEST/.OffLoadAI.app.old-$$" +trap "rm -rf ${(q)STAGED}" EXIT +rm -rf "$STAGED" "$PREVIOUS" +if ! ditto "$ROOT/dist/OffLoadAI.app" "$STAGED" || ! codesign --verify --strict "$STAGED"; then + rm -rf "$STAGED" + fail "Не удалось скопировать новую версию в $DEST, прежняя осталась на месте." +fi +if [[ -e "$TARGET" || -L "$TARGET" ]] && ! mv "$TARGET" "$PREVIOUS"; then + rm -rf "$STAGED" + fail "Не удалось заменить $TARGET, прежняя версия осталась на месте." +fi +if ! mv "$STAGED" "$TARGET"; then + if [[ -e "$PREVIOUS" ]] && ! mv "$PREVIOUS" "$TARGET"; then + fail "Не удалось поставить новую версию. Прежняя лежит в $PREVIOUS — переименуйте её в OffLoadAI.app." + fi + fail "Не удалось поставить новую версию, прежняя осталась на месте." +fi +rm -rf "$PREVIOUS" || echo "⚠️ Не удалось удалить прежнюю версию $PREVIOUS — удалите её вручную." >&2 +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$TARGET" >/dev/null 2>&1 || true # Прежняя копия под старым именем — только если это наше приложение; настройки и данные остаются. OLD="$DEST/Offload.app" diff --git a/scripts/install.sh b/scripts/install.sh index 5ffc079..8038c30 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -78,16 +78,45 @@ main() { mkdir -p "$dest" # До переименования программа звалась Offload: закрываем и её (идентификатор приложения тот же). - if pgrep -xq 'OffLoadAI|Offload'; then + # Во время копирования OffLoadAI сначала спрашивает, прервать ли его, — ждём ответа. Не закрылся — + # установка останавливается: подменить программу под работающей значит оставить человека в старой версии. + running() { pgrep -xq -U "$UID" 'OffLoadAI|Offload'; } + if running; then print "→ закрываю запущенный OffLoadAI" osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1 || true - for _ in {1..20}; do pgrep -xq 'OffLoadAI|Offload' || break; sleep 0.25; done + local waited + for waited in {1..240}; do + running || break + if (( waited == 12 )); then print " OffLoadAI ещё открыт: если он спрашивает, прервать ли копирование, ответьте в его окне"; fi + sleep 0.25 + done + if running; then + fail "OffLoadAI не закрылся, установка отменена, программа не тронута. Дождитесь конца копирования или закройте OffLoadAI сами и запустите установку снова." + fi fi + # Новая версия сначала копируется рядом и встаёт на место прежней переименованием: если установку + # прервать, остаётся прежняя программа, а не пустое место. print "→ устанавливаю в $dest" - rm -rf "$dest/OffLoadAI.app" - ditto "$app" "$dest/OffLoadAI.app" - /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$dest/OffLoadAI.app" >/dev/null 2>&1 || true + local target="$dest/OffLoadAI.app" staged="$dest/.OffLoadAI.app.new-$$" previous="$dest/.OffLoadAI.app.old-$$" + trap "rm -rf ${(q)tmp} ${(q)staged}" EXIT + rm -rf "$staged" "$previous" + if ! ditto "$app" "$staged" || ! codesign --verify --strict "$staged" 2>/dev/null; then + rm -rf "$staged" + fail "Не удалось скопировать новую версию в $dest, прежняя осталась на месте." + fi + if [[ -e "$target" || -L "$target" ]] && ! mv "$target" "$previous"; then + rm -rf "$staged" + fail "Не удалось заменить $target, прежняя версия осталась на месте." + fi + if ! mv "$staged" "$target"; then + if [[ -e "$previous" ]] && ! mv "$previous" "$target"; then + fail "Не удалось поставить новую версию. Прежняя лежит в $previous — переименуйте её в OffLoadAI.app." + fi + fail "Не удалось поставить новую версию, прежняя осталась на месте." + fi + rm -rf "$previous" || print -u2 -- "⚠️ Не удалось удалить прежнюю версию $previous — удалите её вручную." + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$target" >/dev/null 2>&1 || true # Прежняя копия под старым именем: удаляем, только если это наше приложение. Настройки, ключ Pro, # журнал и база решений остаются на месте — новая версия читает их сама.