Compare commits

..

5 Commits

Author SHA1 Message Date
Tam Nguyen Duc 1ba8c8943e Bump go toolchain to 1.26.5 (#71) 2026-07-11 10:53:28 +07:00
Tam Nguyen Duc 35860d5aaa Drop the embedded leakless binary that trips antivirus (#69)
* Drop the embedded leakless binary that trips antivirus (#68)

go-rod's launcher imports github.com/ysmood/leakless, which base64/gzip
embeds a prebuilt leakless.exe for every target. On Windows that helper is
linked straight into kage.exe, and Windows Defender flags its signature and
quarantines a fresh scoop install before kage ever runs.

kage already launches Chrome with leakless disabled (browser/leakless.go),
so the guard was never doing anything, only adding the flagged bytes. This
adds an API-compatible stub for the package under third_party/leakless with
no embedded binary and points a replace directive at it. The Windows build
loses about 1.28 MB of packed executable and no longer carries the payload
that antivirus reacts to. Support() returns false, so go-rod skips the
leakless path even if a caller re-enabled it.

* Document the leakless antivirus fix for v0.3.9

Add the release-notes and changelog entries for the leakless helper removal
(#68), so the docs site and CHANGELOG explain why the Windows build shrank and
what the earlier virus warning was.

* Drop the now-unused leakless module hash from go.sum

The replace points leakless at a local directory, so go mod tidy no longer
needs the upstream module's checksum. Keeps the tidy CI check green.
2026-07-08 19:45:16 +07:00
Tam Nguyen Duc 7483efd2a9 Dequarantine the brew cask binary on install (#67)
Homebrew quarantines cask artifacts and Gatekeeper SIGKILLs quarantined
binaries that are only ad-hoc signed, which cross-compiled Go binaries
are. shirabe hit this first: brew install worked but the binary died
with exit 137 until the attribute was stripped by hand. Add the same
postflight xattr hook here so casks from the next release run first
try.
2026-07-07 08:39:54 +07:00
Tam Nguyen Duc 320b21a2ba Pin release actions and cosign to fixed versions (#63)
Bump the release workflow to the action versions the channel repos already
run and pin cosign to the 2.x line so signing keeps its detached .sig and
.pem outputs instead of floating to a breaking cosign 3.x.
2026-06-29 19:11:59 +07:00
Tam Nguyen Duc 2dabb93a78 Run the container as root so a bind-mounted /out works (issue #7) (#53)
The image dropped to a fixed non-root user (uid 10001) and pointed HOME at
/out. On native Linux Docker a bind-mounted /out is owned by whoever created
it on the host, so uid 10001 cannot write into it. Two things then failed:
kage's output and resume state under $HOME/data/kage hit "mkdir /out:
permission denied", and Chrome launched chrome_crashpad_handler with an empty
crash database path, which aborts the whole browser with
"chrome_crashpad_handler: --database is required" and fails every render.

The earlier attempt set HOME=/out, but that only helps when /out is writable,
which it is not for a non-root uid against a host-owned mount. The crash-reporter
flags in the launcher did not help either: they do not stop Chrome from spawning
the handler, so the abort stayed.

Run as root instead. Container root writes a host-owned bind mount whatever its
ownership, so both /out and HOME stay writable and the documented one-liner just
works. This does not loosen the sandbox: Chrome's sandbox is already off inside
any container (kage drops it on container detection), so root here changes
nothing that was holding.

Verified end to end in an Alpine + chromium container: the non-root image
reproduces both the crashpad abort and the permission-denied exactly as
reported, and the root image clones example.com cleanly, writing index.html and
resume state into a host-owned mounted volume.
2026-06-23 15:20:31 +07:00
10 changed files with 115 additions and 22 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
check-latest: true
@@ -55,7 +55,7 @@ jobs:
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
check-latest: true
+9
View File
@@ -159,6 +159,15 @@ homebrew_casks:
commit_author:
name: Duc-Tam Nguyen
email: tamnd87@gmail.com
# Homebrew quarantines cask artifacts, and Gatekeeper kills quarantined
# binaries that are only ad-hoc signed (which cross-compiled Go binaries
# are). Strip the attribute at install so the binary runs first try.
hooks:
post:
install: |
if system_command("/usr/bin/xattr", args: ["-h"]).exit_status.zero?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kage"]
end
scoops:
# Scoop manifest for Windows, pushed to the bucket repository. It installs the
+11 -1
View File
@@ -6,6 +6,15 @@ All notable changes to kage are recorded here. The format follows
## [Unreleased]
## [0.3.9] - 2026-07-08
### Fixed
- The Windows build no longer embeds the leakless watchdog binary that Windows Defender flags as `Trojan:Win32/Kepavll!rfn`, which made a fresh `scoop install` fail with a virus warning on `leakless.exe` ([#68](https://github.com/tamnd/kage/issues/68)).
go-rod's launcher imports [leakless](https://github.com/ysmood/leakless), which base64/gzip-embeds a prebuilt helper for every platform and links the Windows one into `kage.exe`.
kage already launches Chrome with leakless disabled, so the helper never ran, only added the flagged bytes.
A `replace` directive now points the package at an API-compatible stub under `third_party/leakless` that carries no embedded binary, dropping about 1.28 MB from the Windows build.
## [0.3.6] - 2026-06-19
### Fixed
@@ -255,7 +264,8 @@ can browse offline, with every script stripped out.
a multi-arch container image on GHCR (Chromium bundled), checksums, SBOMs, and
a cosign signature, all cut from one version tag by GoReleaser.
[Unreleased]: https://github.com/tamnd/kage/compare/v0.3.4...HEAD
[Unreleased]: https://github.com/tamnd/kage/compare/v0.3.9...HEAD
[0.3.9]: https://github.com/tamnd/kage/compare/v0.3.8...v0.3.9
[0.3.4]: https://github.com/tamnd/kage/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/tamnd/kage/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/tamnd/kage/compare/v0.3.1...v0.3.2
+13 -9
View File
@@ -17,24 +17,28 @@ ARG TARGETPLATFORM
# chromium for rendering; ca-certificates for HTTPS; tzdata for sane timestamps;
# the font package so rendered pages have glyphs to lay out.
RUN apk add --no-cache chromium ca-certificates tzdata font-noto \
&& adduser -D -H -u 10001 kage \
&& mkdir -p /out \
&& chown kage:kage /out
&& mkdir -p /out
COPY $TARGETPLATFORM/kage /usr/bin/kage
USER kage
WORKDIR /out
# Point kage at the bundled Chromium and write mirrors under /out by default:
#
# docker run -v "$PWD/out:/out" ghcr.io/tamnd/kage clone example.com
#
# The kage user has no home directory of its own, so HOME points at the mounted
# /out volume. That keeps two things writable: kage's default output and resume
# state (it lands under $HOME/data/kage), and Chrome's profile and crash
# database. Without this both fail with a permission error in the container
# (issue #7), and the mounted volume captures nothing.
# The container runs as root, and that is deliberate (issue #7). A bind-mounted
# /out is owned by whoever created it on the host, so only root can reliably
# write into it; a fixed non-root uid cannot, and both kage's output and resume
# state (under $HOME/data/kage) then fail with "mkdir /out: permission denied".
# The same unwritable HOME also breaks Chrome: it launches chrome_crashpad_handler
# with an empty crash database path, which aborts the whole browser with
# "chrome_crashpad_handler: --database is required" and fails every render.
# Running as root keeps /out and HOME writable whatever the host owns, so the
# one-liner above just works. This costs nothing in the sandbox: Chrome's sandbox
# is already off inside any container (kage drops it on container detection), so
# root here does not loosen a boundary that was holding. HOME points at /out so
# the default output and Chrome's writable state both land in the mounted volume.
ENV KAGE_CHROME=/usr/bin/chromium-browser \
HOME=/out
+7 -6
View File
@@ -183,13 +183,14 @@ func (p *Pool) getBrowser() (*rod.Browser, error) {
// In a container, the default /dev/shm is only 64 MB, too small for
// Chrome's renderer on large pages, so steer it to a temp file instead.
// Outside a container /dev/shm is roomy and faster, so leave it alone.
// Chrome's crashpad handler also aborts with "--database is required" in a
// minimal container, which fails the whole launch (issue #7), so turn the
// crash reporter off there. kage never uploads Chrome crash dumps anyway.
//
// The "chrome_crashpad_handler: --database is required" abort seen in
// containers (issue #7) is not fixed here: the crash-reporter flags do not
// stop Chrome from spawning the handler. Its real cause is an unwritable
// HOME, which leaves the crash database path empty; the image keeps HOME
// writable instead (see the Dockerfile).
if inContainer() {
l = l.Set("disable-dev-shm-usage", "").
Set("disable-crash-reporter", "").
Set("disable-breakpad", "")
l = l.Set("disable-dev-shm-usage", "")
}
if bin := p.chromeBin(); bin != "" {
+6
View File
@@ -6,6 +6,12 @@ weight: 40
The authoritative, commit-level history lives in [`CHANGELOG.md`](https://github.com/tamnd/kage/blob/main/CHANGELOG.md) and on the [releases page](https://github.com/tamnd/kage/releases). This page summarises each version.
## v0.3.9
A fix for the antivirus warning some Windows users hit when installing kage.
- **The Windows build no longer ships the leakless helper antivirus flags.** kage renders pages with [go-rod](https://github.com/go-rod/rod), whose launcher pulls in [leakless](https://github.com/ysmood/leakless), a small watchdog that force-kills Chrome if kage exits. leakless carries a prebuilt helper binary for every platform and links the Windows one straight into `kage.exe`. Windows Defender recognises that helper as `Trojan:Win32/Kepavll!rfn` and quarantines it, so a fresh `scoop install` failed with a virus warning on `leakless.exe` ([#68](https://github.com/tamnd/kage/issues/68)). kage already launches Chrome with leakless switched off, so the helper never ran anyway. It is now replaced with a stub that carries no embedded binary, which drops about 1.28 MB from the Windows build and clears the warning. Thanks to John Pywtorak for the report. `go install`, unaffected before, stays clean.
## v0.3.4
Two community fixes: a clean stop for `kage serve`, and pages with heavy JavaScript that used to be dropped.
+9 -1
View File
@@ -1,6 +1,6 @@
module github.com/tamnd/kage
go 1.26.4
go 1.26.5
require (
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
@@ -54,3 +54,11 @@ require (
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
// go-rod's launcher imports github.com/ysmood/leakless, which base64/gzip-embeds
// a prebuilt leakless.exe into the Windows build. Antivirus engines flag that
// embedded helper as malware and quarantine kage on install (issue #68). kage
// always launches Chrome with leakless disabled (browser/leakless.go), so the
// guard is dead weight; this replace swaps in an API-compatible stub that
// carries no embedded binary.
replace github.com/ysmood/leakless => ./third_party/leakless
-3
View File
@@ -107,9 +107,6 @@ github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+3
View File
@@ -0,0 +1,3 @@
module github.com/ysmood/leakless
go 1.26.4
+55
View File
@@ -0,0 +1,55 @@
// Package leakless is kage's drop-in replacement for
// github.com/ysmood/leakless, wired in through a replace directive in the root
// go.mod.
//
// The upstream package guards a child process by extracting a small helper
// executable that force-kills the child when the parent dies. It ships that
// helper by base64/gzip-embedding a prebuilt binary for every target
// (bin_amd64_windows.go and friends), so the packed leakless.exe ends up linked
// into any program that imports the package, kage included. Antivirus engines
// flag that embedded Windows helper as malware, so a fresh install of kage got
// quarantined before it ever ran (issue #68).
//
// kage already launches Chrome with leakless disabled (see
// browser/leakless.go), so the guard is never used. This stub keeps the exact
// public surface go-rod's launcher depends on (New, Support, LockPort, and the
// Launcher type's Command/Pid/Err) while carrying no embedded binary, which
// removes the false positive entirely. Support reports no guard is available,
// so go-rod's launcher never takes the leakless path even if a caller asked
// for it.
package leakless
import "os/exec"
// Launcher mirrors the upstream type. The channel is left unbuffered and is
// never written to, matching the "may never receive the pid" contract go-rod
// already tolerates.
type Launcher struct {
pid chan int
}
// New returns a Launcher. It allocates nothing beyond the pid channel.
func New() *Launcher {
return &Launcher{pid: make(chan int)}
}
// Command builds the command without a guard wrapper. Because Support returns
// false, go-rod never calls this in practice; if some other caller did, running
// the target directly is the correct no-guard behaviour.
func (l *Launcher) Command(name string, arg ...string) *exec.Cmd {
return exec.Command(name, arg...)
}
// Pid returns the (never-signalled) pid channel.
func (l *Launcher) Pid() chan int { return l.pid }
// Err returns the guard error, always empty here since there is no guard.
func (l *Launcher) Err() string { return "" }
// Support reports whether a guard binary is available. It always returns false
// so callers skip leakless entirely.
func Support() bool { return false }
// LockPort is the cross-process mutex the upstream guard uses to serialise
// extraction. With no guard there is nothing to serialise, so it is a no-op.
func LockPort(port int) func() { return func() {} }