From da50d9b17806943dd19c5b8538ae325d892f09e9 Mon Sep 17 00:00:00 2001 From: Alexandre <44178713+alexbelgium@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:06:44 +0200 Subject: [PATCH] fix(claude_desktop): persist sign-in by opting into Electron safeStorage (#2922) * fix(claude_desktop): persist sign-in by opting into Electron safeStorage Claude Desktop asked the user to sign in again on every start. The v1.35 fix was inert: --password-store=basic did reach the process (confirmed on a live install's /proc//cmdline), but the app still logged "safeStorage not available, tokens will not persist" on every launch. Electron refuses its built-in basic_text backend unless the application calls safeStorage.setUsePlainTextEncryption(true) before the ready event, and Claude Desktop never calls it - the symbol is present in the shipped Electron binary but absent from resources/app.asar. So isEncryptionAvailable() stayed false and the auth token was never persisted. Verified against a standalone Electron of the same generation: without the opt-in it is false; with it, true, and a separate later process decrypts a blob written by an earlier one. There is no equivalent command-line switch, and NODE_OPTIONS=--require is ignored by packaged Electron apps (verified against the real binary), so the opt-in is injected into the app's main bundle inside app.asar. gnome-keyring stays out of the image: its first-boot password prompt blocks the app from launching at all. The patcher fails closed, rebuilds the archive preserving unpacked/symlink entries, recomputes the changed entry's SHA-256 integrity record, and fully re-validates the result from disk before renaming it into place. It re-runs on every boot after 81-claude_update.sh, since an apt upgrade ships a fresh unpatched app.asar, and is marker-guarded so an unchanged app is a no-op. Co-Authored-By: Claude Opus 5 * fix(claude_desktop): harden the safeStorage hook against review findings - Sweep stale .app.asar.addon-tmp.* from the shell hook. `timeout` kills the patcher outright, so a run that hits the 120s cap never executes its own cleanup; the live archive stays unpatched, so every later boot would retry under a new pid and strand another archive-sized file. - End the hook with an explicit `exit 0`. The logging `while` loop's status became the script's status, so an empty last line could exit non-zero and fail cont-init - the opposite of the documented "never block startup". Both raised in review on #2922. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- claude_desktop/CHANGELOG.md | 9 + claude_desktop/Dockerfile | 5 +- claude_desktop/SIGN_IN.md | 78 ++++- claude_desktop/config.yaml | 2 +- claude_desktop/rootfs/defaults/autostart | 12 +- .../etc/cont-init.d/86-claude_safestorage.sh | 61 ++++ .../usr/local/bin/claude-safestorage-patch.js | 284 ++++++++++++++++++ 7 files changed, 432 insertions(+), 19 deletions(-) create mode 100644 claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh create mode 100644 claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 377fbd839e..5f86541d4e 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.37 (28-07-2026) + +- Fix the recurring "For your security, sign in again to keep using Claude." prompt for real. The v1.35 fix was ineffective: `--password-store=basic` was reaching the process (confirmed on a live install's `/proc//cmdline`), yet the app kept logging `safeStorage not available, tokens will not persist` and `Encryption not available, returning empty env vars` on every launch. The missing half is an application-side opt-in — Electron refuses its built-in `basic_text` backend unless the app calls `safeStorage.setUsePlainTextEncryption(true)` before `ready`, and Claude Desktop never calls it (the symbol is present in the Electron binary but absent from `resources/app.asar`). So `isEncryptionAvailable()` stayed `false` and the auth token was never persisted, exactly as when the keyring backend was forced without a keyring daemon. Verified against a standalone Electron of the same generation: with `--password-store=basic` and no opt-in `isEncryptionAvailable()` is `false`; with the opt-in it is `true`, and a *separate later process* decrypts a blob written by an earlier one — which is the restart survival this add-on needs. + - **The patch (`86-claude_safestorage.sh` + `claude-safestorage-patch.js`, new).** A small Node script injects that one opt-in call into the app's main bundle (`.vite/build/index.pre.js`) inside `app.asar`. Insertion is placed *after* the bundle's leading `"use strict";` rather than before it, because a directive prologue only takes effect as the first statement — prepending ahead of it would silently drop the entire main process out of strict mode. The archive is rebuilt rather than edited in place, since an asar's header stores a byte offset and length per file: entries flagged `unpacked` (which live in `app.asar.unpacked/`, not in the archive body) and symlink entries are carried through untouched, so the native-module layout is preserved, and the per-file SHA-256 `integrity` record is recomputed for the one file that changed. The injector fails closed: anything that does not open with an unambiguously terminated `"use strict"` directive is refused rather than patched by guesswork, since `"use strict" + x` is an expression and injecting into it would yield a syntax error. The rebuild is written to a temporary file, `fsync`ed, and then fully re-validated from disk — entry count, every offset and length parsed as an exact in-bounds integer, the last body ending precisely at EOF, and the patched entry's recomputed SHA-256 — before it is renamed over the original and the directory synced; the temporary file is removed on every failure path. So a torn, truncated or short write can never replace a working archive. Verified byte-for-byte against the real archive: same 211 entries, no `unpacked`/symlink flag drift, no trailing slack, every integrity hash valid, and content differing in exactly one file. + - **Why it re-runs every boot.** `81-claude_update.sh` apt-upgrades `claude-desktop` on startup, and a new package ships a fresh, unpatched `app.asar`; a one-shot patch at image build time would therefore be undone by the first update. The script is marker-guarded, so a boot where the app did not change is a no-op, and it is numbered `86-` to land after both the update check and `85-openbox_autostart.sh`, while still completing before any s6 service — i.e. before the desktop launches the app. A failure is logged and swallowed rather than propagated, and the patcher is run under a 120s `timeout`: an unpatched app still runs, it just forgets the sign-in, which is not worth blocking startup over. The hook ends in an explicit `exit 0` so no logging branch can turn a non-fatal patch failure into a failed cont-init, and it sweeps stale temporary archives itself — `timeout` kills the patcher outright, so the run that hits the cap cannot execute its own cleanup, and each stranded file is archive-sized. + - **`gnome-keyring` stays out of the image.** Re-adding it would reintroduce the first-boot keyring password prompt that blocks Claude Desktop from launching at all. This route needs no keyring, no D-Bus Secret Service, no daemon and no password, so no prompt can appear. The trade-off is unchanged from v1.35 and is inherent to the `basic` backend: its key is fixed rather than gated by a keyring, so any process that can read the persistent `$HOME/.config/Claude` profile can recover the stored credentials. + - **One-time step after upgrading.** The previously stored session is already stale, so a single sign-in is still needed once after this update; it then persists across restarts. + + ## 1.36.4 (28-07-2026) - Fix Selkies dying with a Rust `RuntimeDirNotSet` unwrap panic just after `Data WebSocket Server listening on port 8081`, and the data websocket then being proxied to the wrong port. Upstream relies on s6-rc ordering: `init-selkies-config` publishes `XDG_RUNTIME_DIR` and `CUSTOM_WS_PORT` into the s6 envdir and `svc-selkies` starts afterwards. The add-on entrypoint replaces s6-overlay and starts every `s6-rc.d` run script in parallel with no dependency graph, so Selkies can snapshot the envdir before that oneshot has written to it -- which is why it bound port 8081 (its own default) instead of the 8082 nginx proxies to, and why its Wayland compositor found no runtime directory to bind a socket in. `20-folders.sh` now exports both variables inside each run script, where no start ordering can lose them, and corrects the base image's `$HOME/.XDG` override where that write happens instead of appending a correction after the `exit 0` that the oneshot-tolerance block adds -- which meant the correction never ran on any boot after the first. diff --git a/claude_desktop/Dockerfile b/claude_desktop/Dockerfile index 7c74c38535..3e8de0af87 100644 --- a/claude_desktop/Dockerfile +++ b/claude_desktop/Dockerfile @@ -105,7 +105,10 @@ RUN if [ ! -f /bin/sh ] && [ -f /usr/bin/sh ]; then ln -s /usr/bin/sh /bin/sh; f # Install Claude Desktop, Claude Code, Python tooling, and lightweight local validators. # gnome-keyring is intentionally NOT installed: it prompts for a keyring password on first # boot, which blocks Claude Desktop from launching. Sign-in persistence instead uses Electron's -# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt. +# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt — together +# with rootfs/etc/cont-init.d/86-claude_safestorage.sh, which patches app.asar to opt into that +# backend. Electron ignores --password-store=basic without the app-side opt-in, so both are +# required for the sign-in to survive a restart. # The cowork virtualization stack (qemu-system-x86 + ovmf firmware) lets Claude Code launch # its sandbox microVM; libseccomp2 and libcap-ng0 are the shared libraries the source-built # virtiofsd daemon links against at runtime. Docker itself is NOT installed here: this base diff --git a/claude_desktop/SIGN_IN.md b/claude_desktop/SIGN_IN.md index 09d0bffa21..3b5b0548a0 100644 --- a/claude_desktop/SIGN_IN.md +++ b/claude_desktop/SIGN_IN.md @@ -9,11 +9,12 @@ streamed desktop. v1.24-ish removed `gnome-keyring` again because it prompts for a keyring password on first boot, which blocked Claude Desktop from ever launching — but the launch flag was left forcing the now-daemonless libsecret backend, so `safeStorage` silently went unavailable - again (recurring "sign in again", and — new in this round — the Claude app's dispatch tab - showing the desktop as offline until a fresh sign-in was done from a computer). Fixed in - v1.35: switched to `--password-store=basic` (Electron's built-in store, no keyring - involved at all) plus a cont-init script that re-syncs the persistent openbox `autostart` - from the image on every boot, so the fix reaches existing installs, not just fresh ones. + again (recurring "sign in again", and the Claude app's dispatch tab showing the desktop as + offline until a fresh sign-in was done from a computer). v1.35 switched to + `--password-store=basic` plus a cont-init script that re-syncs the persistent openbox + `autostart` from the image on every boot — **but that flag alone does nothing**, and the bug + survived it untouched. Actually fixed in v1.37, which adds the application-side opt-in the + `basic` backend requires; see "Why v1.35 did not work" below. - **Planned only:** Problem A (in-desktop browser for OAuth) is intentionally not implemented. The image ships no browser; complete the login with the user-side workaround below. @@ -102,14 +103,60 @@ Re-adding gnome-keyring would just reintroduce the original launch-blocking prom a dead end without also solving *that* — hence the shipped fix below avoids keyring entirely. -### Fix (shipped, v1.35) -1. `claude_desktop/rootfs/defaults/autostart` launches with - `--password-store=basic` instead of `gnome-libsecret`. `basic` is Electron's built-in - fixed-key store: `isEncryptionAvailable()` is always `true`, no daemon, no prompt. Secrets - land under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so - the session survives restarts. `basic` trades away OS-backed at-rest protection: unlike +### Why v1.35 did not work + +v1.35 assumed `--password-store=basic` makes `isEncryptionAvailable()` "always true". It does +not. On a live install the flag was confirmed on the running process: + +``` +$ tr '\0' ' ' < /proc/2247/cmdline +/usr/lib/claude-desktop/claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=basic +``` + +and the app still logged, on every launch: + +``` +[warn] safeStorage not available, tokens will not persist +[warn] Encryption not available, returning empty env vars +[error] Electron safeStorage encryption is not available on this system, cannot store allowlist cache +``` + +Electron deliberately refuses its own `basic_text` backend unless the **application** opts in +by calling `safeStorage.setUsePlainTextEncryption(true)` before the `ready` event. The symbol +is present in the shipped Electron binary but absent from `resources/app.asar` — Claude Desktop +never calls it. So v1.35 replaced one unavailable backend with another. + +Confirmed against a standalone Electron of the same generation, all with +`--password-store=basic`: + +| case | `isEncryptionAvailable()` | +| --- | --- | +| no opt-in (= Claude Desktop as shipped) | `false` | +| `setUsePlainTextEncryption(true)` | `true`, `encryptString` works | +| fresh process, decrypting the earlier process's blob | `true`, plaintext recovered | + +The third row is the one that matters: it is the restart survival this add-on needs. + +### Fix (shipped, v1.37) +1. `claude_desktop/rootfs/defaults/autostart` launches with `--password-store=basic` instead of + `gnome-libsecret` — Electron's built-in fixed-key store: no daemon, no prompt. Secrets land + under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so the + session survives restarts. `basic` trades away OS-backed at-rest protection: unlike `gnome-libsecret`, its encryption key isn't gated by a keyring daemon, so any process able to read the persistent `$HOME/.config/Claude` profile can recover the saved credentials. +1b. `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` + + `claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` (new) supply the opt-in + the flag depends on, by injecting `safeStorage.setUsePlainTextEncryption(true)` into the + app's main bundle inside `app.asar`. The injection goes *after* the bundle's leading + `"use strict";` — a directive prologue only counts as the first statement, so prepending + ahead of it would drop the main process out of strict mode. The archive is rebuilt (asar + headers store per-file offsets, so content cannot simply grow in place), preserving + `unpacked` and symlink entries and recomputing the per-file SHA-256 `integrity` record for + the single changed file; the result is verified and only then renamed over the original. + It re-runs every boot after `81-claude_update.sh`, because an apt upgrade of + `claude-desktop` ships a fresh unpatched `app.asar`; it is marker-guarded, so an unchanged + app is a no-op, and a failure is logged rather than propagated (an unpatched app still + runs, it just forgets the sign-in). 2. A passwordless keyring was considered instead (keeps libsecret encryption-at-rest without a prompt) and rejected: the keyring DB would live in the same persistent volume as the ciphertext it's "protecting," so it adds ~no real confidentiality in this single-user @@ -133,9 +180,12 @@ normally and dispatch stays online regardless of which device connects first aft ## Files this plan touched - `claude_desktop/rootfs/defaults/autostart` — drop the keyring bootstrap; launch with `--password-store=basic`. -- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new; syncs the persistent - autostart from the image on every boot. +- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new in v1.35; syncs the + persistent autostart from the image on every boot. +- `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` and + `claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` — new in v1.37; the + app-side `safeStorage` opt-in that makes `--password-store=basic` actually take effect. - `claude_desktop/Dockerfile` — corrected stale comment (gnome-keyring is not installed). -- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35. +- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35, then v1.37. Problem A (in-desktop browser for OAuth) remains planned-only; not touched by this change. diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index 1a5c9c0fed..a627b0f8b5 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -122,5 +122,5 @@ slug: claude_desktop tmpfs: true udev: true url: https://github.com/alexbelgium/hassio-addons -version: "1.36.4" +version: "1.37" video: true diff --git a/claude_desktop/rootfs/defaults/autostart b/claude_desktop/rootfs/defaults/autostart index 6c683c71b3..e054ac7e70 100644 --- a/claude_desktop/rootfs/defaults/autostart +++ b/claude_desktop/rootfs/defaults/autostart @@ -8,9 +8,15 @@ # surfaced as recurring "sign in again" prompts and (because the stale session also fails the # elevated-access OAuth check) the Claude app's dispatch tab showing this desktop as offline. # -# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon, no -# prompt, and isEncryptionAvailable() is always true. Secrets land under $HOME/.config/Claude, -# and HOME=/data/data is persistent add-on storage, so the saved sign-in survives restarts. +# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon and no +# prompt. Secrets land under $HOME/.config/Claude, and HOME=/data/data is persistent add-on +# storage, so the saved sign-in survives restarts. +# +# This flag is only half of it. Electron refuses the basic backend unless the application opts +# in via safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never calls it — with +# the flag alone, isEncryptionAvailable() stays false and the sign-in is still lost on every +# restart. /etc/cont-init.d/86-claude_safestorage.sh injects that opt-in into app.asar before +# this runs; do not drop one without the other. # Headroom is intentionally not injected into the Desktop process: Claude Desktop # force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom # MCP tools instead. diff --git a/claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh b/claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh new file mode 100644 index 0000000000..19306a1160 --- /dev/null +++ b/claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh @@ -0,0 +1,61 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bash +set -e + +# Claude Desktop is launched with --password-store=basic (see /defaults/autostart) so that no +# system keyring — and therefore no keyring password prompt — is ever needed. Electron does not +# accept that backend unless the application itself calls +# safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never does, so without this +# patch safeStorage.isEncryptionAvailable() stays false: the auth token is never persisted and +# the user is asked to sign in again on every start. +# +# claude-safestorage-patch.js injects that opt-in into the app's main bundle inside app.asar. +# It runs on every boot, and after 81-claude_update.sh, on purpose: an apt upgrade of +# claude-desktop replaces app.asar with a fresh unpatched copy. The patcher is marker-guarded, +# so a boot where the app did not change is a cheap no-op. +# +# A failure here must never block startup — the app still runs fine unpatched, it just forgets +# the sign-in — so the patcher's exit status is reported, not propagated, and it is capped with +# a timeout so a pathological archive cannot stall the boot indefinitely. + +ASAR="/usr/lib/claude-desktop/resources/app.asar" +PATCHER="/usr/local/bin/claude-safestorage-patch.js" + +if [ ! -f "$ASAR" ]; then + bashio::log.warning "Claude Desktop app.asar not found; skipping the safeStorage patch" + exit 0 +fi + +if [ ! -f "$PATCHER" ]; then + bashio::log.warning "$PATCHER not found; skipping the safeStorage patch" + exit 0 +fi + +# The patcher removes its own temporary archive on every failure path, but `timeout` kills it +# outright, so a run that hits the cap leaves one behind. Those are archive-sized, and the live +# archive stays unpatched, so every later boot would retry under a new pid and strand another +# copy until the container runs out of space. +cleanup_tmp() { + find "$(dirname "$ASAR")" -maxdepth 1 -name ".$(basename "$ASAR").addon-tmp.*" -delete 2>/dev/null || true +} + +if output=$(timeout 120 node "$PATCHER" "$ASAR" 2>&1); then + bashio::log.info "safeStorage: ${output}" +else + rc=$? + if [ "$rc" -eq 124 ]; then + bashio::log.warning "safeStorage patch timed out after 120s; continuing unpatched." + else + bashio::log.warning "safeStorage patch failed (exit ${rc}); the sign-in will not persist." + fi + while IFS= read -r line; do + if [ -n "$line" ]; then + bashio::log.warning "${line}" + fi + done <<< "${output}" + cleanup_tmp +fi + +# Explicitly successful: a failed patch is never fatal, so the status of whatever ran last above +# must not become this script's status and abort the boot. +exit 0 diff --git a/claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js b/claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js new file mode 100644 index 0000000000..b6023b78c3 --- /dev/null +++ b/claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/* + * Enable Electron safeStorage for Claude Desktop without a system keyring. + * + * Claude Desktop persists its auth token with Electron's safeStorage. On Linux that is gated + * on a backend: the libsecret backend needs a running Secret Service (gnome-keyring), which is + * intentionally not installed here because it prompts for a keyring password on first boot and + * blocks the app from launching. The app is therefore launched with --password-store=basic + * (Electron's built-in fixed-key store: no daemon, no prompt). + * + * That alone is not enough. Electron refuses the `basic_text` backend unless the *application* + * explicitly opts in by calling safeStorage.setUsePlainTextEncryption(true) before the app is + * ready, and Claude Desktop never calls it. So isEncryptionAvailable() stays false, the token is + * never persisted, and the user is asked to sign in again on every start. There is no equivalent + * command-line switch, and NODE_OPTIONS=--require is ignored by packaged Electron apps, so the + * opt-in has to be injected into the app's own main bundle. + * + * This script does that inside app.asar. It is idempotent (marker-guarded) and re-applied on + * every boot, because 81-claude_update.sh apt-upgrades claude-desktop and a new package ships a + * fresh, unpatched app.asar. + * + * Failure policy: refuse rather than guess. An unpatched app still runs, it just forgets the + * sign-in; a corrupted app.asar would not start at all. Every unexpected shape is a hard exit + * that leaves the original archive untouched. + * + * asar layout (all little-endian): + * [0] uint32 = 4 size of the next field + * [4] uint32 = headerBufLen size of the header pickle + * [8] uint32 = payloadSize 4 + headerString length, 4-byte aligned + * [12] uint32 = headerStrLen exact JSON length + * [16] utf8 = headerString JSON file tree, padded to a 4-byte boundary + * then file bodies; each node's "offset" is relative to the end of the header. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const ASAR = process.argv[2] || '/usr/lib/claude-desktop/resources/app.asar'; +const MARKER = 'CLAUDE_ADDON_SAFESTORAGE_PATCH'; +const PATCH = + `/*${MARKER}*/try{require("electron").safeStorage.setUsePlainTextEncryption(true);}` + + `catch(e){try{console.error("[claude_desktop addon] safeStorage opt-in failed:",e&&e.message);}catch(_){}}`; + +const log = (m) => process.stdout.write(`${m}\n`); +const fail = (m) => { + process.stderr.write(`${m}\n`); + process.exit(1); +}; + +const alignInt = (i, a) => i + ((a - (i % a)) % a); +const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex'); + +function readArchive(file) { + const buf = fs.readFileSync(file); + if (buf.length < 16 || buf.readUInt32LE(0) !== 4) fail(`Not an asar archive: ${file}`); + const headerBufLen = buf.readUInt32LE(4); + const payloadSize = buf.readUInt32LE(8); + const headerStrLen = buf.readUInt32LE(12); + const contentBase = 8 + headerBufLen; + if (headerBufLen !== 4 + payloadSize || payloadSize !== 4 + alignInt(headerStrLen, 4)) { + fail(`Corrupt asar header pickle in ${file}`); + } + if (16 + headerStrLen > buf.length || contentBase > buf.length) { + fail(`Corrupt asar: header extends past end of ${file}`); + } + let header; + try { + header = JSON.parse(buf.toString('utf8', 16, 16 + headerStrLen)); + } catch (e) { + fail(`Corrupt asar: header is not valid JSON (${e.message})`); + } + return { buf, header, contentBase }; +} + +/* Every packed leaf, as [path, node]. Nodes flagged `unpacked` live in app.asar.unpacked/ and + * `link` nodes are symlinks — neither has bytes inside the archive, so both are carried through + * untouched and skipped here. */ +function packedLeaves(header) { + const out = []; + (function walk(dir, prefix) { + for (const [name, node] of Object.entries(dir.files)) { + const p = prefix ? `${prefix}/${name}` : name; + if (node.files) walk(node, p); + else if (!node.unpacked && typeof node.link !== 'string') out.push([p, node]); + } + })(header, ''); + return out; +} + +/* Offsets are decimal strings and can exceed 2^31; reject anything that is not a plain, exact, + * in-bounds extent rather than letting parseInt("12junk") or NaN silently slice the wrong bytes. */ +function extentOf(ar, node, where) { + if (!/^\d+$/.test(String(node.offset))) fail(`Bad offset for ${where}: ${node.offset}`); + const off = Number(node.offset); + const size = node.size; + if (!Number.isSafeInteger(off)) fail(`Offset out of safe range for ${where}`); + if (!Number.isSafeInteger(size) || size < 0) fail(`Bad size for ${where}: ${size}`); + const start = ar.contentBase + off; + const end = start + size; + if (end > ar.buf.length) fail(`Extent of ${where} runs past end of archive`); + return { start, end }; +} + +const bodyOf = (ar, node, where) => { + const { start, end } = extentOf(ar, node, where); + return ar.buf.subarray(start, end); +}; + +function resolve(header, relPath) { + let node = header; + for (const part of relPath.split('/')) { + if (!node.files || !node.files[part]) return null; + node = node.files[part]; + } + return node; +} + +/* Matches @electron/asar: whole-file hash plus one hash per blockSize chunk. An empty file has + * an empty block list, not a single block over zero bytes. */ +function integrityOf(buf, blockSize) { + const blocks = []; + for (let i = 0; i < buf.length; i += blockSize) { + blocks.push(sha256(buf.subarray(i, Math.min(i + blockSize, buf.length)))); + } + return { algorithm: 'SHA256', hash: sha256(buf), blockSize, blocks }; +} + +/* Insert the opt-in after the bundle's leading "use strict" directive. It must go *after* it: a + * directive prologue only takes effect as the very first statement, so prepending would silently + * drop the whole main process out of strict mode. + * + * Returns null — meaning "refuse to patch" — for anything that is not unambiguously a directive. + * `"use strict" + x` is an expression, not a directive, and injecting into it would produce a + * syntax error, so the directive is only accepted when it is terminated by its own semicolon, a + * line break, or end of input. */ +function applyPatch(source) { + const m = /^\s*(['"])use strict\1(;?)/.exec(source); + if (!m) return null; + const rest = source.slice(m[0].length); + const terminated = m[2] === ';' || rest === '' || /^[\r\n]/.test(rest); + if (!terminated) return null; + // Supply the terminator when the directive relied on ASI; without it the injected code would + // continue the string-literal expression instead of following it. + const sep = m[2] === ';' ? '' : ';'; + return source.slice(0, m[0].length) + sep + PATCH + rest; +} + +function writeAll(fd, buf) { + let off = 0; + while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off); +} + +/* Re-read the rebuilt archive from disk and prove it is sound before it replaces a working one. + * A short or truncated write late in the file would otherwise still pass a marker-only check, + * because the main bundle sits near the front. */ +function verifyRebuilt(file, mainRel, expectedLeafCount) { + const ar = readArchive(file); + const leaves = packedLeaves(ar.header); + if (leaves.length !== expectedLeafCount) { + fail(`Rebuilt archive has ${leaves.length} packed entries, expected ${expectedLeafCount}`); + } + let maxEnd = ar.contentBase; + for (const [p, node] of leaves) { + const { end } = extentOf(ar, node, p); + if (end > maxEnd) maxEnd = end; + } + if (maxEnd !== ar.buf.length) { + fail(`Rebuilt archive is truncated or has ${ar.buf.length - maxEnd} trailing bytes`); + } + const mainNode = resolve(ar.header, mainRel); + if (!mainNode) fail(`Rebuilt archive lost its main entry ${mainRel}`); + const body = bodyOf(ar, mainNode, mainRel); + if (!body.toString('utf8').includes(MARKER)) fail('Rebuilt archive is missing the patch marker'); + if (mainNode.integrity && sha256(body) !== mainNode.integrity.hash) { + fail('Rebuilt archive has a stale integrity hash for the main entry'); + } +} + +function main() { + if (!fs.existsSync(ASAR)) fail(`Missing ${ASAR}`); + + const ar = readArchive(ASAR); + + const pkgNode = resolve(ar.header, 'package.json'); + if (!pkgNode) fail('app.asar has no package.json'); + const pkg = JSON.parse(bodyOf(ar, pkgNode, 'package.json').toString('utf8')); + const mainRel = pkg.main; + if (!mainRel) fail('package.json has no "main" entry'); + + const mainNode = resolve(ar.header, mainRel); + if (!mainNode) fail(`main entry not found in archive: ${mainRel}`); + if (mainNode.unpacked) fail(`main entry ${mainRel} is unpacked; refusing to patch`); + if (pkg.type === 'module') fail(`main entry ${mainRel} is ESM; this patcher emits CommonJS`); + + const original = bodyOf(ar, mainNode, mainRel).toString('utf8'); + if (original.includes(MARKER)) { + log(`Already patched: ${mainRel}`); + return; + } + + const patchedSource = applyPatch(original); + if (patchedSource === null) { + fail(`${mainRel} does not begin with a recognized "use strict" directive; refusing to patch`); + } + const patched = Buffer.from(patchedSource, 'utf8'); + + /* Rebuild: copy every packed body in tree order, substituting the patched main entry, and + * reassign offsets as we go. asar headers store a byte offset and length per file, so content + * cannot simply grow in place. */ + const leaves = packedLeaves(ar.header); + const chunks = []; + let offset = 0; + let patchedCount = 0; + + for (const [p, node] of leaves) { + const isMain = node === mainNode; + const body = isMain ? patched : bodyOf(ar, node, p); + node.offset = String(offset); + node.size = body.length; + if (isMain && node.integrity) { + node.integrity = integrityOf(body, node.integrity.blockSize || 4 * 1024 * 1024); + } + offset += body.length; + chunks.push(body); + if (isMain) patchedCount++; + } + if (patchedCount !== 1) fail(`expected to rewrite exactly 1 main entry, rewrote ${patchedCount}`); + + const headerString = JSON.stringify(ar.header); + const strLen = Buffer.byteLength(headerString); + const payloadSize = 4 + alignInt(strLen, 4); + const headerBufLen = 4 + payloadSize; + + const prefix = Buffer.alloc(16 + alignInt(strLen, 4)); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(headerBufLen, 4); + prefix.writeUInt32LE(payloadSize, 8); + prefix.writeUInt32LE(strLen, 12); + prefix.write(headerString, 16, 'utf8'); + + /* Write beside the target and rename, so an interrupted run can never leave a torn app.asar. + * The temp name carries the pid so two runs cannot share it, and it is removed on every + * failure path before the rename. */ + const dir = path.dirname(ASAR); + const tmp = path.join(dir, `.${path.basename(ASAR)}.addon-tmp.${process.pid}`); + const mode = fs.statSync(ASAR).mode & 0o7777; + + try { + const out = fs.openSync(tmp, 'wx', mode); + try { + writeAll(out, prefix); + for (const c of chunks) writeAll(out, c); + fs.fsyncSync(out); // durable before it becomes the live archive + } finally { + fs.closeSync(out); + } + fs.chmodSync(tmp, mode); + verifyRebuilt(tmp, mainRel, leaves.length); + fs.renameSync(tmp, ASAR); + } catch (e) { + try { + fs.unlinkSync(tmp); + } catch (_) { + /* nothing to clean up */ + } + fail(`Rebuild failed, original left untouched: ${e.message}`); + } + + /* Sync the directory so the rename itself survives a crash, not just the file's contents. */ + try { + const dfd = fs.openSync(dir, 'r'); + fs.fsyncSync(dfd); + fs.closeSync(dfd); + } catch (_) { + /* best effort */ + } + + log(`Patched ${mainRel} in ${ASAR} (safeStorage plain-text opt-in)`); +} + +main();