diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d86b7e8..2e9105d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,23 @@ jobs: - name: Context pruning suite run: pnpm test:runtime + npm-launcher: + name: npm launcher + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile --filter @lockintime/headless + - run: pnpm --filter @lockintime/headless test + - name: Verify published package contents + working-directory: packages/headless-npm + run: npm pack --dry-run + protocol: name: Protocol suite runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f87224..9b02264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,8 @@ on: - "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift" - "apps/headless/VERSION" - "apps/headless/VersionSupport/**" + - "packages/headless-npm/**" + - "pnpm-lock.yaml" workflow_dispatch: inputs: dry_run: @@ -59,6 +61,11 @@ jobs: echo "Release tag $version does not match apps/headless/VERSION $expected" >&2 exit 64 fi + npm_version="$(node -p "require('./packages/headless-npm/package.json').version")" + if [[ "$version" != "$npm_version" ]]; then + echo "Release tag $version does not match npm launcher $npm_version" >&2 + exit 64 + fi echo "publish=true" >> "$GITHUB_OUTPUT" else if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$DRY_RUN" != "true" ]]; then @@ -70,6 +77,22 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" + npm-launcher: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile --filter @lockintime/headless + - run: pnpm --filter @lockintime/headless test + - name: Verify published package contents + working-directory: packages/headless-npm + run: npm pack --dry-run + macos: needs: version runs-on: macos-latest @@ -314,7 +337,7 @@ jobs: ./.github/scripts/container-smoke.sh "$CONTAINER_IMAGE@$DIGEST" "${{ needs.version.outputs.version }}" publish: - needs: [version, macos, linux-amd64, linux-arm64, container] + needs: [version, npm-launcher, macos, linux-amd64, linux-arm64, container] if: >- github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && @@ -354,6 +377,7 @@ jobs: - **macOS:** Unzip and run `Headless.app`, or install with `brew install --cask LockInTime/headless/headless` after the tap sync completes. The app is universal, Developer ID signed, notarized, and stapled. CLI: `Headless.app/Contents/Resources/bin/headless`. - **Linux:** `curl -fsSL https://github.com/LockInTime/headless/releases/latest/download/install.sh | sh`. The installer verifies the selected tarball against `SHA256SUMS`. Chromium and FFmpeg remain system dependencies. - **Container:** `docker pull ${{ env.CONTAINER_IMAGE }}:${{ needs.version.outputs.version }}`. The image includes Chromium and FFmpeg and runs as uid 10001. + - **npm:** `npx @lockintime/headless help`. The launcher verifies the matching GitHub release asset and provides both `headless` and `headless-mcp` shims. files: | dist/Headless-${{ needs.version.outputs.version }}-macos.zip dist/headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz @@ -361,3 +385,31 @@ jobs: dist/SHA256SUMS dist/install.sh fail_on_unmatched_files: true + + publish-npm: + needs: [version, publish] + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/v') && + needs.version.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - name: Publish verified launcher + working-directory: packages/headless-npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + [[ -n "$NODE_AUTH_TOKEN" ]] || { + echo "NPM_TOKEN is required to publish @lockintime/headless" >&2 + exit 64 + } + npm publish --access public --provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 7415e69..7e052e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ Cutting that release is tracked in - Tagged macOS releases now ship a universal Apple Silicon/Intel app through a checksum-pinned Homebrew cask, with Developer ID signing, hardened runtime, notarization, stapling, and Gatekeeper validation enforced by release CI. +- A checksum-verifying `@lockintime/headless` npm launcher now provides the + CLI and MCP adapter to JavaScript-centric agent harnesses through `npx`. - Tagged releases now publish a smoke-tested, non-root amd64/arm64 production image to GHCR with SemVer and commit-SHA tags, provenance, and an SBOM. - A checksum-verifying Linux bootstrap installer now selects the correct diff --git a/README.md b/README.md index 3f39bce..59593da 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,21 @@ installer that checks Chromium and FFmpeg before copying the binaries. The Docker image is the supported self-contained Linux runtime and uses Debian's Chromium binary at `/usr/lib/chromium/chromium`. +### npm / npx + +JavaScript-based agent harnesses can run the verified launcher without a +global install: + +```sh +npx @lockintime/headless help +npx -p @lockintime/headless headless-mcp +``` + +The launcher selects the matching macOS or Linux release, verifies it against +the release `SHA256SUMS`, validates its archive and embedded product version, +and caches it privately for subsequent commands. Its download origin is fixed +to this repository. + Tagged releases publish a non-root amd64/arm64 image with Chromium and FFmpeg: ```sh diff --git a/apps/headless/docs/P3.md b/apps/headless/docs/P3.md index 9e0a0fb..0e6208a 100644 --- a/apps/headless/docs/P3.md +++ b/apps/headless/docs/P3.md @@ -41,6 +41,20 @@ binaries. The GHCR production image contains both architectures, runs as uid 10001, exposes no ports, and must be anonymously pullable by digest before the GitHub release is created. +## npm launcher + +`@lockintime/headless` provides `headless` and `headless-mcp` binary shims for +JavaScript-centric tools. The package contains no product binary. On first use +it selects the macOS or Linux release asset matching the package version and +machine architecture, downloads only from GitHub's release hosts, verifies the +asset against the exact `SHA256SUMS` entry, rejects unsafe archive shapes, and +checks the embedded `headless --version` before atomically populating a private +per-user cache. Concurrent invocations share a bounded installation lock. + +The launcher does not support Windows natively and points Windows callers to +the GHCR image. `HEADLESS_NPM_CACHE` may select an absolute cache directory; +the release origin deliberately has no environment override. + ## Release operator secrets Tagged macOS releases require these GitHub Actions secrets: @@ -50,6 +64,8 @@ Tagged macOS releases require these GitHub Actions secrets: - `APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD`: PKCS#12 password. - `APPLE_NOTARY_KEY_BASE64`: base64 App Store Connect API private key. - `APPLE_NOTARY_KEY_ID` and `APPLE_NOTARY_ISSUER_ID`: notary API identifiers. +- `NPM_TOKEN`: automation token allowed to publish the public + `@lockintime/headless` package. The Developer ID certificate and notary key must belong to the same Apple Developer team responsible for `com.headless.app`. Secrets are written only to @@ -61,6 +77,7 @@ uploaded as artifacts. Before a tag is published, the release graph requires protocol/security tests, real WKWebView and Chromium E2E, universal architecture checks, strict signing checks, notarization and stapling, package checksums, native and multi-platform -container smoke tests, and Homebrew cask input validation. The tap independently -verifies the published `SHA256SUMS` entry and runs Homebrew style validation -before committing a cask. No check uses `continue-on-error`. +container smoke tests, npm launcher download/checksum/cache tests, npm package +contents, and Homebrew cask input validation. The tap independently verifies +the published `SHA256SUMS` entry and runs Homebrew style validation before +committing a cask. No check uses `continue-on-error`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 821b530..0629d97 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,9 +110,10 @@ Honest snapshot, so newcomers know what is real: - ~~No CI on pull requests or `main`~~ — PR CI landed (`.github/workflows/ci.yml`, backlog §D1/§D3). Correctness fixes in §A are still outstanding, and the macOS E2E is nightly/label-gated rather than a per-PR gate. -- The npm wrapper and distro-native Linux packages remain unimplemented. - Homebrew, notarized ZIP, Linux bootstrap, checksums, and GHCR automation are - implemented but are not user-visible until the next tag. +- The verified npm launcher, Homebrew cask, notarized universal macOS ZIP, + Linux bootstrap, release checksums, and GHCR publication paths are + implemented but remain unreleased. Distro-native Linux packages remain + unimplemented. - The latest features (capture formats, context pruning) are **unreleased** — no tag since v1.0.2 (2026-07-19). - No `CLAUDE.md`/`AGENTS.md`; the skill is not auto-discovered by Claude Code. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 13a1494..549efbc 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -389,7 +389,11 @@ Owner-decided scope: package managers, no hosted service. sums; releases don't.~~ **Done:** the publish job requires all three named regular package files, generates `SHA256SUMS` atomically, verifies it, and attaches the manifest to the release. Cosign remains optional future work. -- **E5.** ([#43](https://github.com/LockInTime/headless/issues/43)) npm wrapper package (binary download shim) for `npx` reach. +- **E5.** [x] ([#43](https://github.com/LockInTime/headless/issues/43)) ~~npm wrapper package (binary download shim) for `npx` reach.~~ + **Done:** `@lockintime/headless` exposes the CLI and MCP adapter, downloads + only its matching official release, verifies the exact `SHA256SUMS` entry, + rejects unsafe archives, validates the embedded version, and reuses a + private per-user cache. CI locks the installer and packed npm contents. - **E6.** [x] ([#44](https://github.com/LockInTime/headless/issues/44)) Version unification + `headless --version` + CHANGELOG + release automation (architecture §12). `package.json` says 0.0.0, tags say 1.0.x, default `HEADLESS_VERSION` is 1.0.0. diff --git a/package.json b/package.json index f3445a6..8757aa8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "pnpm --filter @headless/web dev", "start": "pnpm --filter @headless/web start", "test": "pnpm --filter @headless/app test", + "test:npm": "pnpm --filter @lockintime/headless test", "test:runtime": "pnpm --filter @headless/app test:runtime", "test:e2e:mac": "pnpm --filter @headless/app test:e2e:mac", "test:e2e:linux": "pnpm --filter @headless/app test:e2e:linux" diff --git a/packages/headless-npm/README.md b/packages/headless-npm/README.md new file mode 100644 index 0000000..50ab6e8 --- /dev/null +++ b/packages/headless-npm/README.md @@ -0,0 +1,17 @@ +# `@lockintime/headless` + +Verified npm launcher for the [Headless agent browser](https://github.com/LockInTime/headless). + +```sh +npx @lockintime/headless help +npx -p @lockintime/headless headless-mcp +``` + +The launcher downloads the release matching its own package version from the +official GitHub repository, verifies the exact asset against `SHA256SUMS`, +validates the archive shape and embedded product version, and caches it in a +private per-user directory. It supports macOS 13+ on Apple Silicon and Intel, +plus Linux x86_64 and arm64. Windows users should use the published GHCR image. + +Set `HEADLESS_NPM_CACHE` to an absolute directory to move the verified cache. +The release download origin is fixed and cannot be overridden. diff --git a/packages/headless-npm/bin/headless-mcp.mjs b/packages/headless-npm/bin/headless-mcp.mjs new file mode 100755 index 0000000..92905ac --- /dev/null +++ b/packages/headless-npm/bin/headless-mcp.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { launch } from "../lib/launcher.mjs"; + +await launch("headless-mcp", process.argv.slice(2)); diff --git a/packages/headless-npm/bin/headless.mjs b/packages/headless-npm/bin/headless.mjs new file mode 100755 index 0000000..b798745 --- /dev/null +++ b/packages/headless-npm/bin/headless.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { launch } from "../lib/launcher.mjs"; + +await launch("headless", process.argv.slice(2)); diff --git a/packages/headless-npm/lib/installer.mjs b/packages/headless-npm/lib/installer.mjs new file mode 100644 index 0000000..9568cb4 --- /dev/null +++ b/packages/headless-npm/lib/installer.mjs @@ -0,0 +1,390 @@ +import { createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + stat, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const RELEASE_ORIGIN = "https://github.com"; +const RELEASE_REPOSITORY = "LockInTime/headless"; +const MANIFEST_LIMIT = 256 * 1024; +const ASSET_LIMIT = 512 * 1024 * 1024; +const LOCK_WAIT_MS = 30_000; +const LOCK_STALE_MS = 20 * 60_000; +const REDIRECT_LIMIT = 5; +const MANIFEST_TIMEOUT_MS = 30_000; +const ASSET_TIMEOUT_MS = 120_000; +const ALLOWED_DOWNLOAD_HOSTS = new Set([ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]); +const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export class InstallError extends Error { + constructor(message, exitCode = 69) { + super(message); + this.name = "InstallError"; + this.exitCode = exitCode; + } +} + +function sleep(milliseconds) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +} + +function packageRoot() { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export async function packageVersion() { + const document = JSON.parse(await readFile(join(packageRoot(), "package.json"), "utf8")); + if (typeof document.version !== "string" || !SEMVER.test(document.version)) { + throw new InstallError("the npm package has an invalid product version", 70); + } + return document.version; +} + +export function platformRelease(version, platform = process.platform, architecture = process.arch) { + if (!SEMVER.test(version)) throw new InstallError(`invalid product version: ${version}`, 64); + if (platform === "linux" && architecture === "x64") { + return { + asset: `headless-${version}-linux-amd64.tar.gz`, + kind: "tar.gz", + executable: "headless", + hostExecutable: "headless-host", + mcpExecutable: "headless-mcp", + key: "linux-amd64", + }; + } + if (platform === "linux" && architecture === "arm64") { + return { + asset: `headless-${version}-linux-arm64.tar.gz`, + kind: "tar.gz", + executable: "headless", + hostExecutable: "headless-host", + mcpExecutable: "headless-mcp", + key: "linux-arm64", + }; + } + if (platform === "darwin" && (architecture === "arm64" || architecture === "x64")) { + const prefix = "Headless.app/Contents/Resources/bin"; + return { + asset: `Headless-${version}-macos.zip`, + kind: "zip", + executable: `${prefix}/headless`, + hostExecutable: "Headless.app/Contents/MacOS/Headless", + mcpExecutable: `${prefix}/headless-mcp`, + key: `macos-${architecture}`, + }; + } + throw new InstallError( + `Headless does not publish an npm binary for ${platform}/${architecture}. ` + + "Use the GHCR image on Windows, or install a supported macOS/Linux package.", + 69, + ); +} + +export function defaultCacheRoot(platform = process.platform, environment = process.env) { + if (environment.HEADLESS_NPM_CACHE) { + if (!isAbsolute(environment.HEADLESS_NPM_CACHE)) { + throw new InstallError("HEADLESS_NPM_CACHE must be an absolute path", 64); + } + return resolve(environment.HEADLESS_NPM_CACHE); + } + if (platform === "darwin") return join(homedir(), "Library", "Caches", "headless-npm"); + const base = environment.XDG_CACHE_HOME && isAbsolute(environment.XDG_CACHE_HOME) + ? environment.XDG_CACHE_HOME + : join(homedir(), ".cache"); + return join(base, "headless-npm"); +} + +function validateDownloadURL(url, allowedHosts, allowHTTP, allowCustomPort) { + if (!(url instanceof URL)) throw new InstallError("invalid release URL", 70); + if (url.username || url.password || (url.port && !allowCustomPort)) { + throw new InstallError("release URL must not contain credentials or a custom port", 70); + } + if (url.protocol !== "https:" && !(allowHTTP && url.protocol === "http:")) { + throw new InstallError("release downloads require HTTPS", 70); + } + if (!allowedHosts.has(url.hostname)) { + throw new InstallError(`release download redirected to an untrusted host: ${url.hostname}`, 70); + } +} + +async function trustedFetch(url, options) { + const { allowedHosts, allowHTTP, allowCustomPort, fetchImpl, timeoutMilliseconds } = options; + let current = new URL(url); + for (let redirects = 0; redirects <= REDIRECT_LIMIT; redirects += 1) { + validateDownloadURL(current, allowedHosts, allowHTTP, allowCustomPort); + const response = await fetchImpl(current, { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMilliseconds), + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) throw new InstallError("release download returned a redirect without a location"); + current = new URL(location, current); + continue; + } + if (!response.ok) { + throw new InstallError(`release download failed with HTTP ${response.status}: ${current}`); + } + return response; + } + throw new InstallError("release download exceeded the redirect limit"); +} + +async function boundedText(response, maximumBytes) { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maximumBytes) { + throw new InstallError("release manifest is too large"); + } + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.byteLength; + if (size > maximumBytes) throw new InstallError("release manifest is too large"); + chunks.push(chunk); + } + return Buffer.concat(chunks, size).toString("utf8"); +} + +export function checksumFromManifest(manifest, asset) { + const matches = []; + for (const line of manifest.split("\n")) { + const match = /^([0-9a-f]{64}) ([^\r\n]+)$/.exec(line); + if (match && match[2] === asset) matches.push(match[1]); + } + if (matches.length !== 1) { + throw new InstallError(`release manifest must contain exactly one checksum for ${asset}`, 65); + } + return matches[0]; +} + +async function downloadAsset(response, destination, expectedChecksum) { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && (declared <= 0 || declared > ASSET_LIMIT)) { + throw new InstallError("release asset has an unsafe size", 65); + } + const handle = await open(destination, "wx", 0o600); + const hash = createHash("sha256"); + let size = 0; + try { + for await (const chunk of response.body) { + size += chunk.byteLength; + if (size > ASSET_LIMIT) throw new InstallError("release asset is too large", 65); + hash.update(chunk); + let offset = 0; + while (offset < chunk.byteLength) { + const { bytesWritten } = await handle.write( + chunk, + offset, + chunk.byteLength - offset, + ); + if (bytesWritten <= 0) throw new InstallError("release asset write made no progress", 74); + offset += bytesWritten; + } + } + } finally { + await handle.close(); + } + if (size === 0) throw new InstallError("release asset is empty", 65); + const actual = hash.digest("hex"); + if (actual !== expectedChecksum) throw new InstallError("release asset checksum mismatch", 65); +} + +function run(command, argumentsList, options = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, argumentsList, { stdio: options.stdio ?? "pipe" }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { stdout += chunk; }); + child.stderr?.on("data", (chunk) => { stderr += chunk; }); + child.on("error", rejectPromise); + child.on("close", (code, signal) => { + if (code === 0) resolvePromise({ stdout, stderr }); + else rejectPromise(new InstallError( + `${basename(command)} failed${signal ? ` with ${signal}` : ` with status ${code}`}: ${stderr.trim()}`, + 65, + )); + }); + }); +} + +export function validateArchiveEntries(text, kind) { + const entries = text.split("\n").filter(Boolean); + if (entries.length === 0 || entries.length > 1_000) { + throw new InstallError("release archive has an unsafe entry count", 65); + } + const seen = new Set(); + for (const entry of entries) { + const canonical = entry.endsWith("/") ? entry.slice(0, -1) : entry; + const components = canonical.split("/"); + if (!canonical || entry.includes("\0") || entry.includes("\\") || entry.startsWith("/") + || /^[A-Za-z]:/.test(entry) + || components.some((component) => !component || component === "." || component === "..")) { + throw new InstallError(`release archive contains an unsafe path: ${entry}`, 65); + } + if (seen.has(canonical)) throw new InstallError(`release archive contains a duplicate path: ${entry}`, 65); + seen.add(canonical); + } + if (kind === "tar.gz") { + for (const required of [ + "headless", + "headless-host", + "headless-mcp", + "Headless_HeadlessProtocol.resources/AgentRuntime.js", + ]) { + if (!seen.has(required)) throw new InstallError(`release archive is missing ${required}`, 65); + } + } else if (kind === "zip") { + for (const required of [ + "Headless.app/Contents/MacOS/Headless", + "Headless.app/Contents/Resources/bin/headless", + "Headless.app/Contents/Resources/bin/headless-mcp", + ]) { + if (!seen.has(required)) throw new InstallError(`release archive is missing ${required}`, 65); + } + if (!entries.every((entry) => entry === "Headless.app" || entry.startsWith("Headless.app/"))) { + throw new InstallError("release archive contains files outside Headless.app", 65); + } + } + return entries; +} + +async function rejectLinks(root) { + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const metadata = await lstat(path); + if (metadata.isSymbolicLink()) throw new InstallError(`release archive contains a symbolic link: ${entry.name}`, 65); + if (metadata.isDirectory()) pending.push(path); + else if (!metadata.isFile()) throw new InstallError(`release archive contains a non-regular file: ${entry.name}`, 65); + } + } +} + +async function extractArchive(archive, staging, release) { + if (release.kind === "tar.gz") { + const listing = await run("/usr/bin/tar", ["-tzf", archive]); + validateArchiveEntries(listing.stdout, release.kind); + await run("/usr/bin/tar", ["-xzf", archive, "-C", staging, "--no-same-owner", "--no-same-permissions"]); + } else { + const listing = await run("/usr/bin/unzip", ["-Z1", archive]); + validateArchiveEntries(listing.stdout, release.kind); + await run("/usr/bin/unzip", ["-q", archive, "-d", staging]); + } + await rejectLinks(staging); +} + +async function isUsableInstall(directory, release, version) { + try { + for (const relative of [release.executable, release.hostExecutable, release.mcpExecutable]) { + const metadata = await lstat(join(directory, relative)); + if (!metadata.isFile() || metadata.isSymbolicLink()) return false; + } + const result = await run(join(directory, release.executable), ["--version"]); + return result.stdout.trim() === `headless ${version}`; + } catch { + return false; + } +} + +async function acquireLock(lockPath) { + const deadline = Date.now() + LOCK_WAIT_MS; + while (Date.now() < deadline) { + try { + await mkdir(lockPath, { mode: 0o700 }); + return; + } catch (error) { + if (error.code !== "EEXIST") throw error; + try { + const metadata = await stat(lockPath); + if (Date.now() - metadata.mtimeMs > LOCK_STALE_MS) { + await rm(lockPath, { recursive: true }); + continue; + } + } catch (statError) { + if (statError.code !== "ENOENT") throw statError; + } + await sleep(100); + } + } + throw new InstallError("timed out waiting for another Headless npm installation", 75); +} + +export async function ensureInstalled(options = {}) { + const version = options.version ?? await packageVersion(); + const platform = options.platform ?? process.platform; + const architecture = options.architecture ?? process.arch; + const release = platformRelease(version, platform, architecture); + const cacheRoot = resolve(options.cacheRoot ?? defaultCacheRoot(platform)); + const installParent = join(cacheRoot, `v${version}`); + const installDirectory = join(installParent, release.key); + const lockPath = `${installDirectory}.lock`; + await mkdir(installParent, { recursive: true, mode: 0o700 }); + await chmod(cacheRoot, 0o700).catch(() => {}); + await chmod(installParent, 0o700); + + if (await isUsableInstall(installDirectory, release, version)) { + return { directory: installDirectory, release }; + } + + await acquireLock(lockPath); + try { + if (await isUsableInstall(installDirectory, release, version)) { + return { directory: installDirectory, release }; + } + await rm(installDirectory, { recursive: true, force: true }); + const temporary = await mkdtemp(join(lockPath, "install-")); + const archive = join(temporary, release.asset); + const staging = join(temporary, "payload"); + await mkdir(staging, { mode: 0o700 }); + + const defaultBase = `${RELEASE_ORIGIN}/${RELEASE_REPOSITORY}/releases/download/v${version}`; + const baseURL = options.releaseBaseURL ?? defaultBase; + const allowHTTP = options.allowHTTP === true; + const allowedHosts = options.allowedHosts ?? ALLOWED_DOWNLOAD_HOSTS; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const fetchOptions = { + allowedHosts, + allowHTTP, + allowCustomPort: options.allowCustomPort === true, + fetchImpl, + }; + const manifestResponse = await trustedFetch(`${baseURL}/SHA256SUMS`, { + ...fetchOptions, timeoutMilliseconds: MANIFEST_TIMEOUT_MS, + }); + const manifest = await boundedText(manifestResponse, MANIFEST_LIMIT); + const checksum = checksumFromManifest(manifest, release.asset); + const assetResponse = await trustedFetch(`${baseURL}/${release.asset}`, { + ...fetchOptions, timeoutMilliseconds: ASSET_TIMEOUT_MS, + }); + await downloadAsset(assetResponse, archive, checksum); + await extractArchive(archive, staging, release); + await chmod(join(staging, release.executable), 0o755); + await chmod(join(staging, release.hostExecutable), 0o755); + await chmod(join(staging, release.mcpExecutable), 0o755); + if (!(await isUsableInstall(staging, release, version))) { + throw new InstallError("downloaded Headless package failed its version check", 65); + } + await rename(staging, installDirectory); + return { directory: installDirectory, release }; + } finally { + await rm(lockPath, { recursive: true, force: true }); + } +} diff --git a/packages/headless-npm/lib/launcher.mjs b/packages/headless-npm/lib/launcher.mjs new file mode 100644 index 0000000..ac0c0ee --- /dev/null +++ b/packages/headless-npm/lib/launcher.mjs @@ -0,0 +1,31 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { ensureInstalled, InstallError } from "./installer.mjs"; + +export async function launch(command, argumentsList) { + try { + const { directory, release } = await ensureInstalled(); + const relative = command === "headless-mcp" ? release.mcpExecutable : release.executable; + const child = spawn(join(directory, relative), argumentsList, { + env: process.env, + stdio: "inherit", + }); + const forwardSIGINT = () => { if (!child.killed) child.kill("SIGINT"); }; + const forwardSIGTERM = () => { if (!child.killed) child.kill("SIGTERM"); }; + process.once("SIGINT", forwardSIGINT); + process.once("SIGTERM", forwardSIGTERM); + child.once("error", (error) => { + console.error(`headless npm launcher: ${error.message}`); + process.exitCode = 70; + }); + child.once("exit", (code, signal) => { + process.removeListener("SIGINT", forwardSIGINT); + process.removeListener("SIGTERM", forwardSIGTERM); + process.exitCode = code ?? (signal === "SIGINT" ? 130 : 143); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`headless npm launcher: ${message}`); + process.exitCode = error instanceof InstallError ? error.exitCode : 70; + } +} diff --git a/packages/headless-npm/package.json b/packages/headless-npm/package.json new file mode 100644 index 0000000..82140c3 --- /dev/null +++ b/packages/headless-npm/package.json @@ -0,0 +1,34 @@ +{ + "name": "@lockintime/headless", + "version": "1.0.2", + "description": "Verified npm launcher for the Headless agent browser", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/LockInTime/headless.git", + "directory": "packages/headless-npm" + }, + "homepage": "https://github.com/LockInTime/headless", + "bugs": "https://github.com/LockInTime/headless/issues", + "type": "module", + "bin": { + "headless": "bin/headless.mjs", + "headless-mcp": "bin/headless-mcp.mjs" + }, + "files": [ + "bin", + "lib", + "README.md" + ], + "scripts": { + "test": "node --test test/*.test.mjs", + "prepack": "npm test" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/headless-npm/test/installer.test.mjs b/packages/headless-npm/test/installer.test.mjs new file mode 100644 index 0000000..8c22571 --- /dev/null +++ b/packages/headless-npm/test/installer.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { after, before, test } from "node:test"; +import { + checksumFromManifest, + defaultCacheRoot, + ensureInstalled, + InstallError, + platformRelease, + validateArchiveEntries, +} from "../lib/installer.mjs"; + +const root = mkdtempSync(join(tmpdir(), "headless-npm-test.")); +const fixture = join(root, "fixture"); +const version = "9.8.7"; +const asset = `headless-${version}-linux-amd64.tar.gz`; +const archive = join(root, asset); +let server; +let baseURL; +let requestCount = 0; +let servedManifest; + +before(async () => { + mkdirSync(join(fixture, "Headless_HeadlessProtocol.resources"), { recursive: true }); + for (const executable of ["headless", "headless-host", "headless-mcp", "install-linux.sh"]) { + const body = executable === "headless" + ? `#!/bin/sh\nif [ "$1" = --version ]; then echo 'headless ${version}'; else echo wrapper-ok; fi\n` + : "#!/bin/sh\nexit 0\n"; + writeFileSync(join(fixture, executable), body, { mode: 0o755 }); + chmodSync(join(fixture, executable), 0o755); + } + writeFileSync(join(fixture, "Headless_HeadlessProtocol.resources", "AgentRuntime.js"), "// fixture\n"); + const packed = spawnSync("/usr/bin/tar", [ + "-czf", + archive, + "-C", + fixture, + "headless", + "headless-host", + "headless-mcp", + "install-linux.sh", + "Headless_HeadlessProtocol.resources", + ], { encoding: "utf8" }); + assert.equal(packed.status, 0, packed.stderr); + const archiveBytes = readFileSync(archive); + const checksum = createHash("sha256").update(archiveBytes).digest("hex"); + servedManifest = `${checksum} ${asset}\n`; + server = createServer((request, response) => { + requestCount += 1; + if (request.url.endsWith("/SHA256SUMS")) { + response.end(servedManifest); + } else if (request.url.endsWith(`/${asset}`)) { + response.end(archiveBytes); + } else { + response.statusCode = 404; + response.end(); + } + }); + await new Promise((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise)); + baseURL = `http://127.0.0.1:${server.address().port}/v${version}`; +}); + +after(async () => { + await new Promise((resolvePromise) => server.close(resolvePromise)); + rmSync(root, { recursive: true, force: true }); +}); + +test("maps only supported release platforms", () => { + assert.equal(platformRelease(version, "linux", "x64").asset, asset); + assert.equal(platformRelease(version, "linux", "arm64").key, "linux-arm64"); + assert.equal(platformRelease(version, "darwin", "arm64").kind, "zip"); + assert.throws(() => platformRelease(version, "win32", "x64"), InstallError); + assert.throws(() => platformRelease("../bad", "linux", "x64"), InstallError); +}); + +test("requires an absolute cache override", () => { + assert.throws( + () => defaultCacheRoot("linux", { HEADLESS_NPM_CACHE: "relative" }), + /absolute path/, + ); +}); + +test("parses one exact manifest entry", () => { + const digest = "a".repeat(64); + assert.equal(checksumFromManifest(`${digest} ${asset}\n`, asset), digest); + assert.throws(() => checksumFromManifest("", asset), /exactly one/); + assert.throws( + () => checksumFromManifest(`${digest} ${asset}\n${digest} ${asset}\n`, asset), + /exactly one/, + ); +}); + +test("rejects archive traversal and missing runtime files", () => { + assert.throws(() => validateArchiveEntries("../escape\n", "tar.gz"), /unsafe path/); + assert.throws(() => validateArchiveEntries("safe//file\n", "tar.gz"), /unsafe path/); + assert.throws(() => validateArchiveEntries("safe/file\nsafe/file/\n", "tar.gz"), /duplicate path/); + assert.throws(() => validateArchiveEntries("headless\n", "tar.gz"), /missing headless-host/); + assert.throws( + () => validateArchiveEntries("Headless.app/Contents/MacOS/Headless\noutside\n", "zip"), + /missing Headless.app\/Contents\/Resources\/bin\/headless/, + ); +}); + +test("rejects a release whose checksum does not match", async () => { + const validManifest = servedManifest; + servedManifest = `${"0".repeat(64)} ${asset}\n`; + try { + await assert.rejects( + ensureInstalled({ + version, + platform: "linux", + architecture: "x64", + cacheRoot: join(root, "bad-checksum-cache"), + releaseBaseURL: baseURL, + allowedHosts: new Set(["127.0.0.1"]), + allowHTTP: true, + allowCustomPort: true, + }), + /checksum mismatch/, + ); + } finally { + servedManifest = validManifest; + } +}); + +test("downloads, verifies, installs, and reuses the cached release", async () => { + const cacheRoot = join(root, "cache"); + const options = { + version, + platform: "linux", + architecture: "x64", + cacheRoot, + releaseBaseURL: baseURL, + allowedHosts: new Set(["127.0.0.1"]), + allowHTTP: true, + allowCustomPort: true, + }; + const first = await ensureInstalled(options); + assert.equal(first.release.asset, asset); + const command = spawnSync(join(first.directory, first.release.executable), [], { encoding: "utf8" }); + assert.equal(command.status, 0); + assert.equal(command.stdout.trim(), "wrapper-ok"); + const afterFirst = requestCount; + const second = await ensureInstalled(options); + assert.equal(second.directory, first.directory); + assert.equal(requestCount, afterFirst, "a valid cache entry must not redownload"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b14a0ad..a6e4da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,8 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/headless-npm: {} + packages: '@alloc/quick-lru@5.2.0':