feat(packages): GitHub Releases binary installer#898
Conversation
New runtime source `github:owner/repo[@tag]` for installing Linux CLI binaries from GitHub Releases. Admin-only, SHA256-verified, ELF-validated. Backend: - GitHub API client with 10-min cache + rate-limit mapping - SSRF-guarded streaming downloader (HTTPS + host allowlist, re-validated on every redirect hop, literal-IP rejection) - Checksums.txt / SHA256SUMS lookup with constant-time verify - Archive extract (tar.gz / zip / raw) with path-traversal + zip-bomb guards, symlink skip - ELF magic + 64-bit class + runtime-arch validation - Atomic manifest persistence (temp + rename) HTTP: - POST /v1/packages/install accepts github: spec - GET /v1/packages/github-releases for picker UI (viewer+, arch-filtered) - Extended InstalledPackages response with github field - github-bin runtime probe Infra: - Dockerfile creates /app/data/.runtime/bin (goclaw:goclaw 0755) - docker-entrypoint.sh prepends bin dir to PATH - Env-only config (never config.json): token, max size, org allowlist, bin dir, manifest path UI: - GitHub Binaries section + release picker modal - Dismissable musl/glibc compatibility warning (localStorage) - i18n keys across en/vi/zh Docs: docs/packages-github.md user guide + 14-skills-runtime.md cross-ref. Closes #741
…inel Code review cleanup: - validPkgName regex had `:` added defensively, but github: specs are validated separately via skills.ParseGitHubSpec before reaching this check — the broadening was dead attack surface. - Drop unused ErrUnknownArchive sentinel + the `_ = ErrUnknownArchive` stub in extractRaw.
Cap picker endpoint at 30 req/min/user (burst 10) to protect the shared GitHub API quota. Key is userID (header X-GoClaw-User-Id) or remote IP for anonymous callers. Returns 429 + Retry-After: 60 when tripped. Standalone token-bucket limiter (stale-entry cleanup every 5 min) lives in internal/http rather than importing internal/gateway, which would create a package cycle.
CI pnpm build failed on TS2345: `.split('@')[0]` returns
`string | undefined` under strict index access. Default to empty
string to satisfy the type checker; runtime behaviour unchanged
because the downstream regex rejects empty strings.
|
@claude review |
|
Claude finished @mrgoonie's task in 3m 51s —— View job PR Review: feat(packages): GitHub Releases binary installer
|
Medium - rate limiter: atomic.Int64 lastSeen + amortized sweep replaces goroutine-based cleanup → fixes data race on lastSeen and the goroutine leak when tests swap the package-level limiter. - checksum pipeline: slog.Warn on ReadFile and ParseChecksums failures (previously silent). "asset not listed" stays warn+proceed but is now documented as the publisher's choice — ELF validation remains the final gate. - downloader: drop http.Client.Timeout (30s capped the whole request including body read, aborting large downloads on slow links). Context deadline from install timeout (5 min) is the correct bound. Low / style / UI - extractRaw honors maxUncompressed (ErrFileTooLarge on overflow) so the helper is safe outside the hot path. - cmd/gateway_github_installer.go: drop the explicit cfg.Defaults() call — NewGitHubInstaller already invokes it. - GitHubPackageEntry: remove unpopulated InstalledBy field + document why. - owner regex tightened to 39-char GitHub limit (was 40). - mu lock comment corrected: serializes only the disk-write phase. - UI: shared stripPrefixAndTag helper + owner regex mirrors the backend 39-char cap; destructure-with-default kills the split()[0] ?? "" awkwardness while still satisfying noUncheckedIndexedAccess. Verified: go build (pg + sqliteonly) · go vet · go test -race ./internal/skills ./internal/http · pnpm build.
|
@claude review |
|
Claude finished @mrgoonie's task in 2m 58s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Overall this is a well-executed, security-conscious feature. The architecture is solid and the implementation shows careful attention to the attack surface. Several issues from the previous review pass have been proactively fixed — those are called out in "What Changed" below. Medium Issues1.
|
| Previous finding | Status |
|---|---|
Data race on lastSeen in rate limiter |
Fixed — uses atomic.Int64, no mutex needed |
cleanupLoop goroutine leak in tests |
Fixed — replaced with amortized inline sweep via callCounter%1024 |
30s http.Client.Timeout too tight for large downloads |
Fixed — download client has no Timeout; relies on context deadline |
| Checksum soft-failure silent paths | Fixed — all failure modes now have explicit slog.Warn |
InstalledBy always empty in manifest |
Fixed — field removed entirely, replaced with a clear comment |
Double Defaults() call |
Fixed — comment in cmd/ file explicitly notes this |
mu lock comment misleading |
Fixed — now says "serializes the final disk-write phase" |
| Owner regex allows 40 chars | Fixed — regex correctly caps at 39 |
What's Well Done
- Rate limiter redesign: Amortized inline sweep (
callCounter.Add(1)%1024) instead of a background goroutine is elegant — no goroutine leaks, no data races, no mutex on per-entry state. Theatomic.Int64forlastSeenis the right tool here. - SSRF defense: HTTPS-only + allowlist re-validated per redirect hop + literal-IP rejection. Comprehensive and correct.
sanitizePath: Covers.., absolute, Windows drives, null bytes, backslash normalization, and post-path.Cleanescape check. Hard to slip anything through.- Zip bomb: Both declared-size pre-check and streaming cap in
extractZip;extractRawalso enforcesmaxBytesviaLimitReader. Belt-and-braces is the right call for binary installs. filterAssetsaliasing:in[:0:0]avoids sharing the backing array with the input slice — subtle but correct.- ELF validation: Magic + class + machine before
chmod +x. Exactly the right level of validation. - Atomic manifest writes:
tmp+os.Renameis correct and crash-safe. - Uninstall name resolution:
UninstallPackagecorrectly handlesgithub:name,github:owner/repo@tag, and bareowner/repoforms — resilient to different call sites. - Auth layering: Install/uninstall gated by
adminAuth+requireMasterScope. Picker is viewer+ with per-user rate limiting. Allowlist membership is not enumerable (404 instead of 403). - Test coverage: Parser, asset-select, org allowlist, checksum, archive (path traversal + zip bomb), ELF, SSRF, rate limiter — comprehensive unit surface.
Medium
- validRepoPath now rejects trailing hyphens in the owner segment and
caps at 39 chars, matching gitHubSpecRE exactly. Previously a subtle
drift between the two validators could let `foo-/repo` slip to the
GitHub API and surface as a 502 instead of a clean 400.
- handleGitHubReleases no longer forwards raw err.Error() from the
upstream call. Maps sentinel errors:
ErrGitHubRateLimited → 429 + Retry-After
ErrGitHubNotFound → 404
ErrGitHubUnauthorized → 502 "github authentication failed"
default → 502 "failed to fetch releases"
Avoids leaking rate-limit reset timestamps / server internals to
viewer-tier callers.
Low / UX
- Install response now returns the manifest entry for github: specs
(new lookupGitHubEntry helper; nil-safe fallback to {ok:true}). Lets
the UI display "installed: lazygit v0.42.0" without a list refresh.
- gitHubSpecRE tag segment capped at 1..255 chars (git ref-name bound).
UI isValidFullSpec mirrors the same cap.
|
@claude review |
|
Claude finished @mrgoonie's task in 3m 45s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Previous Review Findings — StatusBoth medium issues from the second review pass have been addressed:
New Findings1. Tag names in API path are not URL-encoded (Medium)
path = fmt.Sprintf("/repos/%s/%s/releases/tags/%s", owner, repo, tag)
Fix: use path = fmt.Sprintf("/repos/%s/%s/releases/tags/%s", owner, repo, url.PathEscape(parsed.Tag))2.
|
| Previous finding | Status |
|---|---|
validRepoPath trailing hyphen |
Fixed |
| Raw GitHub API errors exposed to viewers | Fixed |
Cache keyed by limit (Low) |
Still present — acknowledged, non-issue in practice |
What's Well Done
The feature is in good shape. After three review passes, all high and medium findings from the prior rounds are resolved. The security defense-in-depth (SSRF allowlist per hop, path sanitization, zip bomb at both pre-check and read time, ELF validation, atomic manifest writes, auth + scope gating) is comprehensive and correctly implemented. The rate limiter redesign with amortized inline sweep and atomic lastSeen is clean. No goroutine leaks remain in tests. The newly added entry field in the install response and the proper sentinel error mapping in the picker endpoint are both clear improvements.
The only actionable finding is the URL-encoding issue on tag names (issue #1) — that's worth a one-line fix before merge.
Medium - github_api: URL-encode owner, repo, and tag via url.PathEscape when building API paths. Previously a tag containing '#' would be stripped as a URL fragment and '?' would inject a query parameter, silently hitting the wrong release. Low / polish - Uninstall via full "github:owner/repo[@tag]" spec now falls back to manifest lookup by owner/repo, handling packages whose binary name differs from the repo name (cli/cli → gh). - GitHubClient.cache sweeps expired entries opportunistically when the map grows past 256 entries (prevents theoretical unbounded growth over long uptime). - handleInstall for github: specs now calls GitHubInstaller.Install directly and returns the freshly-created manifest entry, eliminating the double manifest read via List() from the lookupGitHubEntry helper. - pickBinaries comment corrected — actual behavior excludes paths matched by nonBinaryPathRE rather than enforcing a single-depth limit.
|
@claude review |
|
Claude finished @mrgoonie's task in 2m 42s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Previous Round 3 Findings — All Resolved
New / Remaining Findings1.
|
All 4 findings are Low severity:
- github_api.go: replace interface{} with any across the cache type,
cacheGet return, cacheSet param, and doJSON out param.
- doJSON: rename local `url` to `apiURL` to avoid shadowing the
"net/url" package import used by GetRelease/ListReleases.
- Uninstall: save the updated manifest BEFORE removing binaries on
disk. If saveManifest fails we now bail out without leaving a
manifest entry that still claims binaries which have been deleted
(a retried Uninstall would otherwise hit ErrPackageNotInstalled
after the first attempt wiped the files). Disk removal stays
best-effort and warn-on-error, which matches the idempotent intent.
- pickBinaries: inline comment corrected to reflect actual behavior —
depth is not enforced; nonBinaryPathRE filter + downstream ELF
validation are the real gates.
|
@claude review |
|
Claude finished @mrgoonie's task in 3m 28s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 4 Findings — All Resolved
New Findings1.
|
All 3 findings are Low severity: - handleInstall github fast-path now wraps the context with skills.InstallTimeout (5 min) before calling gh.Install and emits the same "skills: installing dep" / "dep installed" / "github install failed" log lines as the generic InstallSingleDep path, so operator-observability is identical between github: and pip:/npm: install flows. - installTimeout promoted to exported InstallTimeout so the http layer shares the single source of truth rather than duplicating the 5-minute constant. - cacheMaxEntries comment clarifies it is a soft sweep trigger, not a hard cap — when every entry is still within TTL the map can briefly exceed the threshold by one insert.
|
@claude review |
|
Claude finished @mrgoonie's task in 4m 12s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 5 Findings — All Resolved
New Findings1.
|
Both findings are Low severity (reviewer marked the PR "ready to merge" already): - github_installer: "no checksum asset available" downgraded from slog.Warn to slog.Info. Many popular upstream releases (jq, fzf, older ripgrep, etc.) ship no checksum file at all — that is publisher policy, not a problem with the install. The suspicious cases (checksum file unreadable, unparseable, or missing this asset) stay at Warn so they stand out. - handleGitHubReleases response now uses a narrow assetPreview DTO (name + size_bytes) instead of embedding the full GitHubAsset type which also carried browser_download_url. The picker UI never rendered the URL; trimming the response keeps the viewer-tier surface minimal. UI AssetPreview interface realigned to match.
|
@claude review |
|
Claude finished @mrgoonie's task in 3m 29s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 6 Findings — All Resolved
New Findings1.
|
Narrow the GET /v1/packages GitHub entry to a viewer-safe projection (repo/tag/binaries/name/installed_at), mirroring the assetPreview fix from round 6. Strips asset_url, sha256, and asset_name from the list response — viewer-level callers no longer see CDN download URLs or checksum metadata for installed packages. UI types realigned; the removed fields were never rendered. Finding #2 (install writes binary before manifest save) left as noted — reviewer confirmed informational only, self-heals on retry, no security impact since binaries pass ELF validation before being written.
|
@claude review |
|
Claude finished @mrgoonie's task in 3m 36s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 7 Findings — Status
New Finding1. GitHub secondary rate limit (HTTP 429) not handled in
|
Map HTTP 429 (GitHub secondary rate limits — abuse detection, unauthenticated bursts, search) to ErrGitHubRateLimited in the API client so the picker endpoint renders 429 "rate limit reached" with Retry-After: 60 instead of falling through to 502 "failed to fetch releases". Primary rate limits (403 + X-RateLimit-Remaining: 0) were already handled; this covers the secondary class documented at https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits
|
@claude review |
|
Claude finished @mrgoonie's task in 4m 22s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 8 Finding — Resolved
New Findings1. Checksum files with
|
Two defensive hardenings flagged as Very Low: - ParseChecksums: strip leading `./` from checksum filenames. `sha256sum ./file` emits `./file` in the name column; the caller looks up by bare asset basename so `./`-prefixed entries would silently miss. Real release checksums almost never use this form, but the guard is essentially free. - doJSON: cap response body at 8 MiB via io.LimitReader before JSON decode. Current GitHub list/release payloads are well under this (~1 MiB at per_page=100). Guards against future call sites or a misbehaving upstream returning an oversized document.
|
@claude review |
|
Claude finished @mrgoonie's task in 2m 44s —— View job PR Review: feat(packages): GitHub Releases binary installerTasks:
Round 9 Findings — All Resolved
New FindingsNone. After reviewing all key files — SummaryThis is the tenth and final review pass. All findings across all prior rounds have been resolved. The feature is ready to merge. The security model as implemented:
Ready to merge. |
`Service.Stop()` closes stopChan but does not wait for the runLoop goroutine to exit. In the test suite, test A's `defer cs.Stop()` can return before the spawned runLoop has reached `ticker := time.NewTicker(runLoopTickInterval)`. If test B then calls `setFastTick()` to mutate the package-level var, the race detector correctly flags it: Read at runLoopTickInterval by goroutine A (runLoop ticker init) Previous write by goroutine B (setFastTick in test B) Fix: snapshot `runLoopTickInterval` inside `Start()` under the mutex before spawning the goroutine, and pass the value as a parameter to `runLoop`. The spawned goroutine no longer reads the package-level var, so the cross-test window is closed. Production behavior unchanged. Verified: `go test -race -count=3 ./internal/cron/...` passes three times in a row; the CI failure on PR #898 reproduced before the fix and is gone after.
|
@viettranx Hey anh Việt, reminder to review this PR when you get a chance — Zuey asked me to flag it so you can proceed with the package updates. Quick context summary: What: Add GitHub Releases binary installer — new runtime package source Key components:
Review status: 5 rounds of Claude review, all medium/low findings resolved. Builds pass (PG + SQLite), Ready to merge — just needs your approval so Zuey can proceed with the package updates. |
P0.1 — UI uninstall 400: parseAndValidatePackage now accepts
github:<bare-name> (manifest Name form, no owner/repo) in addition
to the full spec. UI sends github:${pkg.name} from the manifest;
dispatcher already tolerated bare names — the HTTP validator was
the only gate rejecting them. Install path re-validates strictly
via ParseGitHubSpec and bare-name install returns 400 now (was 500).
P1.1 — ExtractArchive raw-ELF fallback name: add ExtractArchiveAs(
path, fallbackName, max). Installer passes parsed.Repo so raw
(non-archive) ELF assets no longer end up recorded as
/tmp/goclaw-gh-asset-XXXX.bin — that basename would leak into the
manifest Binaries entry and break PATH lookup.
P1.3 — Archive entry count cap: maxArchiveEntries = 10_000 +
ErrTooManyEntries sentinel. Tar: count ALL headers seen (incl.
symlinks/dirs we skip) to block the gzip-bomb-of-headers DoS —
header bytes don't count against maxUncompressed for zero-size
entries. Zip: pre-check via peekZipEntryCount reads the EOCD
record manually and rejects oversized archives BEFORE
zip.OpenReader allocates []*zip.File of declared capacity (this
was a fresh red-team finding; stdlib would otherwise alloc ~1GB
for a crafted 200MB zip claiming 4M entries).
P1.4 — Rate-limit install/uninstall: packagesWriteLimiter
(10/min/user, burst 3). Admin-only mitigates but a compromised
token could otherwise flood upstream (GitHub/pip/npm) or spam
manifest mutations.
P1.6 — Non-Linux early reject: ErrUnsupportedOS guard at the top
of Install(). Windows/macOS hosts no longer waste bandwidth
fetching a Linux asset just to fail at the ELF machine check.
P1.7 — Manifest fsync: OpenFile → Write → Sync → Close → Rename →
dir Sync, with tmp cleanup on every error path. POSIX doesn't
guarantee durability via rename alone; XFS / ext4 with async
journal can reorder.
P2.1 — Belt-and-suspenders zip runtime break when cumulative bytes
reach the cap (pre-declared check already covers it but the
streaming loop now bails immediately).
P2.6 — Binary-name collision warn: slog.Warn when a different repo
already owns the basename we're about to overwrite. Last-writer-
wins unchanged; operator now gets a signal instead of silence.
Hardening — rate-limit key: rateLimitKeyFromRequest prefers
store.UserIDFromContext over the raw X-GoClaw-User-Id header so
an admin can't rotate the header mid-session to dodge the bucket.
Header/IP fallback retained for pre-auth / test callers.
Tests: 9 new cases on parseAndValidatePackage (github full/bare/
empty/traversal/injection/space/leading-hyphen);
TestExtractArchiveAs_RawELFUsesFallbackName;
TestExtractTarGz_EntryCountCap + TestExtractZip_EntryCountCap;
TestPeekZipEntryCount (DoS pre-check path).
Verified: go build ./... && go build -tags sqliteonly ./... &&
go vet ./... && go test -race ./internal/skills/... ./internal/http/...
Review pass — address P0 blocker + P1/P2 fixes + new DoS vectorSpun up adversarial red-team on this PR, then on my own fixes. Pushed Fixes applied
New finding from red-team pass (P0-NEW)
Fix: Hardening — rate-limit key
Tests
Carry-over items NOT addressed (intentional)
One thing to decide
|

Summary
New runtime package source
github:owner/repo[@tag]— install Linux CLI binaries directly from GitHub Releases. Admin-only, SHA256-verified, ELF-validated, with a release-picker UI.Closes #741.
How It Works
End-to-end install flow
sequenceDiagram autonumber actor Admin participant UI as Web UI participant API as HTTP API<br/>(internal/http) participant Inst as GitHubInstaller<br/>(internal/skills) participant GH as GitHub<br/>(api.github.com) participant Obj as github asset CDN<br/>(objects.githubusercontent.com) participant FS as Runtime bin dir<br/>(/app/data/.runtime/bin) Admin->>UI: Enter "cli/cli@v2.45.0" UI->>API: POST /v1/packages/install<br/>{ "package": "github:cli/cli@v2.45.0" } API->>API: adminAuth + requireMasterScope API->>API: parseAndValidatePackage → ParseGitHubSpec API->>Inst: InstallSingleDep(ctx, "github:...") Inst->>Inst: AllowedOrg(owner)? Inst->>GH: GET /repos/cli/cli/releases/tags/v2.45.0<br/>(Bearer token optional) GH-->>Inst: release + assets[] Inst->>Inst: SelectAsset(linux, runtime.GOARCH) Inst->>Obj: DownloadAsset(url, maxBytes) Note over Obj,Inst: SSRF allowlist re-validated<br/>on every redirect hop Obj-->>Inst: asset bytes → temp file + sha256 opt checksums.txt present Inst->>Obj: DownloadAsset(checksum url) Obj-->>Inst: checksum file Inst->>Inst: VerifyChecksum(expected, sha)<br/>(constant-time compare) end Inst->>Inst: ExtractArchive(temp, 2×maxBytes) Note over Inst: sanitizePath rejects ../ abs \\ NUL<br/>cumulative size tracked (zip-bomb) Inst->>Inst: pickBinaries + validateELF<br/>(magic + 64-bit + arch) Inst->>FS: write binary 0755 + manifest (atomic) Inst-->>API: GitHubPackageEntry API-->>UI: 200 OK { ok: true } UI-->>Admin: ✓ installedComponent map
flowchart LR subgraph UI["Web UI (ui/web)"] S[github-binaries-section.tsx] P[Release Picker Modal] W[Musl/glibc Warning] end subgraph HTTP["HTTP Layer (internal/http)"] H1[POST /v1/packages/install] H2[POST /v1/packages/uninstall] H3[GET /v1/packages] H4[GET /v1/packages/github-releases<br/>rate-limited 30/min/user] end subgraph Skills["Skills (internal/skills)"] D[DepInstaller dispatch<br/>prefix: github: / pip: / npm:] I[GitHubInstaller] API[GitHubClient<br/>10-min cache] DL[Downloader<br/>SSRF + SHA256] EX[Archive Extract<br/>tar.gz / zip / raw] CK[Checksum<br/>constant-time cmp] EL[ELF Validator<br/>magic + class + machine] M[(Manifest<br/>github-packages.json)] end subgraph FS["Runtime dir (/app/data/.runtime/bin)"] B1[installed binaries on PATH] end subgraph External["External"] GH[(GitHub API)] CDN[(objects.githubusercontent.com)] end S --> H1 S --> H2 P --> H4 S --> H3 H1 --> D H2 --> D H3 --> I H4 --> API D -->|github:| I I --> API I --> DL I --> EX I --> CK I --> EL I --> M I --> B1 API --> GH DL --> CDN DL --> GHSecurity defense-in-depth
flowchart TD Req[Install request: github:owner/repo@tag] --> A1 A1{Admin +<br/>master scope?} A1 -->|No| Deny1[403] A1 -->|Yes| A2 A2{Valid spec<br/>ParseGitHubSpec?} A2 -->|No| Deny2[400] A2 -->|Yes| A3 A3{Org in allowlist?} A3 -->|No| Deny3[ErrOrgNotAllowed] A3 -->|Yes| A4 A4[GitHub API call] --> A5 A5[Asset download] --> A6 A6{HTTPS + host allowlist?<br/>re-validated per hop} A6 -->|No| Deny4[ErrHostNotAllowed] A6 -->|Yes| A7 A7{Size ≤ maxBytes?} A7 -->|No| Deny5[ErrAssetTooLarge] A7 -->|Yes| A8 A8{checksum match?<br/>constant-time cmp} A8 -->|Mismatch| Deny6[ErrChecksumMismatch] A8 -->|Match or absent| A9 A9{Archive path safe?<br/>reject ../ abs NUL} A9 -->|No| Deny7[ErrUnsafePath] A9 -->|Yes| A10 A10{Total uncompressed<br/>≤ 2×maxBytes?} A10 -->|No| Deny8[ErrZipBomb] A10 -->|Yes| A11 A11{ELF magic +<br/>64-bit + arch match?} A11 -->|No| Deny9[ErrNotELF / ErrELFArchMismatch] A11 -->|Yes| OK[write 0755 + atomic manifest update]What's Included
Backend (
internal/skills/)github_api.go— Releases API client (10-min cache, rate-limit mapping, token auth)github_download.go— HTTPS-only streaming download with SSRF allowlist re-validated on every redirect hop, literal-IP rejection, size cap + SHA256 viaio.TeeReadergithub_checksum.go—checksums.txt/SHA256SUMSlookup +subtle.ConstantTimeComparearchive_extract.go— tar.gz / zip / raw extract, strict path sanitization (rejects.., absolute, Windows drives, null bytes), zip-bomb guard (cumulative size at read time + declared-size pre-check), symlink skipgithub_installer.go— parse spec, asset-select heuristic, ELF magic + 64-bit + arch validation, atomic manifest (tmp+Rename), install/uninstall/list, org allowlistHTTP API
POST /v1/packages/installroutesgithub:specs through installerGET /v1/packages/github-releases?repo=owner/repo&limit=10(viewer+, arch-filtered)GET /v1/packageswithgithubfield +github-binruntime probeInfra
/app/data/.runtime/bin(goclaw:goclaw 0755)GOCLAW_PACKAGES_GITHUB_TOKEN,GOCLAW_PACKAGES_MAX_ASSET_SIZE_MB,GOCLAW_PACKAGES_GITHUB_ALLOWED_ORGS,GOCLAW_PACKAGES_GITHUB_BIN_DIR,GOCLAW_PACKAGES_GITHUB_MANIFESTUI
en/vi/zhDocs
docs/packages-github.md— user-facing install guide, admin config, security posture, troubleshooting (musl/glibc, rate limit, checksum mismatch)docs/14-skills-runtime.mdcross-refSecurity Posture
sanitizePathrejects.., absolute, Windows drives, null bytes, backslash-normalizedchmod +xsubtle.ConstantTimeCompare; fail-hard on mismatchadminAuth+requireMasterScopetoken_set=bool, never valueTest Plan
go build ./...(PG backend)go build -tags sqliteonly ./...(desktop/lite)go vet ./...go test ./internal/skills/...— parser, asset-select, checksum, archive (path traversal + zip bomb), ELF, SSRF, GitHub API mock, rate-limitgo test ./internal/http/...— picker rate limiter + full packages handlerpnpm build(UI strict TS check)github:jesseduffield/lazygit@v0.42.0→ binary in/app/data/.runtime/bin/lazygit+ manifest entrygithub:evil/toolwith allowlist → org-not-allowed error/app/data/.runtime/binin PATHKnown Limitations (Phase 1)
Unresolved