mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-22 12:53:32 +02:00
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/<pid>/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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<pid>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
284
claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js
Normal file
284
claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user