Compare commits

...
Author SHA1 Message Date
Kevin Codex 1fb753bd40 fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs
- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
  includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
  npm view failure (or a bootstrap publish with no latest tag) cannot abort
  the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
  with no asset (windows-arm64 and unknown combos) skip the spawn instead of
  attempting it every run; keep deliberate retry-on-failure for supported
  platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
  emulation (README, README_ZH, INSTALL.md incl. the release-target list).
2026-07-10 16:29:56 +08:00
Kevin Codex 1e31e98087 feat(npm): ship the native binary as platform optionalDependencies
Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.

The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.

scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.

See docs/NPM_PACKAGING.md for the architecture and publishing rules.
2026-07-10 09:29:33 +08:00
Gautam ManchandaniandGautam Manchandani fdddb05ba8 fix: harden MCP credential boundaries (#597)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-10 09:00:17 +08:00
euxaristia b6117af86d fix(exec): stop false INCOMPLETE downgrades on conversational final messages (#608)
* fix(agent): scope self-reported incompletion to sentences about the current objective

The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.

* feat(exec): add --no-completion-gate for conversational callers

A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.

* fix(exec): address review findings on quote stripping and --use-spec combination

stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.

--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
2026-07-10 08:59:33 +08:00
28 changed files with 2115 additions and 278 deletions

No files matched your search

+69 -11
View File
@@ -97,16 +97,21 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
name: Publish npm package
needs: package
@@ -156,10 +161,11 @@ jobs:
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. This also
# blocks publishing while the repo is still private: private release
# assets 404 without auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
@@ -173,12 +179,64 @@ jobs:
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: zero-*
path: dist/artifacts
merge-multiple: true
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+7
View File
@@ -235,6 +235,13 @@ Servers are merged from user and project configs (project wins on conflicts). To
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
Project MCP config remains supported for shared team servers, but a project layer
that changes an existing server's target (`url`, `command`, or `args`) does not
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
or use a new server name. OAuth tokens are bound to the resolved server identity,
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
### As a server — expose Zero's tools to another agent
```bash
+9 -28
View File
@@ -49,34 +49,15 @@ npm install -g @gitlawb/zero
zero
```
The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
The npm package is a small wrapper whose platform build (Linux and macOS on
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
installs as an optional dependency straight from the npm registry — no install
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
no trust or approval steps. Installs that skip optional dependencies
(`--omit=optional`) still work: the wrapper fetches the binary from the
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
### Install scripts
+1 -19
View File
@@ -38,25 +38,7 @@ npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
### 安装脚本
+76 -23
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
return null;
}
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
// installs vendor the helpers inside the platform package instead (a helpers/
// directory next to the binary, found by the Go side on its own), so this
// manifest is usually empty — it only matters for a downloaded-binary install
// whose wrapper still carries helper dependencies.
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
@@ -49,38 +55,85 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
} catch {
return null;
}
const candidate = join(dirname(manifestPath), zeroBinaryName());
return existsSync(candidate) ? candidate : null;
}
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
// Mirrors the release matrix (internal/release/release.go): the platforms a
// GitHub Release asset exists for. android maps to the linux asset; there is
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
function releaseAssetAvailable() {
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
process.platform
];
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
if (!platform || !arch) return false;
return !(platform === 'windows' && arch === 'arm64');
}
// Fallback for installs without a platform package (--omit=optional, package
// managers that skip optionalDependencies): fetch the binary next to the
// wrapper with the same checksum-verified downloader that used to run as
// postinstall. Deliberately NOT cached on failure — a transient network error
// self-heals on the next run — so this retries on every invocation until a
// binary is in place. Platforms with no release asset skip the attempt.
function downloadMissingBinary(packageRoot) {
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
console.error(
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
);
spawnSync(process.execPath, [downloadScript], {
stdio: ['ignore', 'inherit', 'inherit'],
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
const downloaded = join(packageRoot, zeroBinaryName());
if (existsSync(downloaded)) return downloaded;
downloadMissingBinary(packageRoot);
return existsSync(downloaded) ? downloaded : null;
}
const nativePath = resolveNativeBinary();
if (!nativePath) {
console.error(
'[zero] No native binary is available for this install.\n' +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
);
process.exit(1);
}
const env = { ...process.env };
const localControlHelpers = localControlHelperManifest(packageRoot);
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
+21 -48
View File
@@ -6,8 +6,9 @@ Zero is distributed as:
- release archives on GitHub Releases
- source builds with Go 1.25+
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
## npm
@@ -16,59 +17,32 @@ npm install -g @gitlawb/zero
zero
```
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
The package supports Linux and macOS on x64 and arm64, and Windows on x64
(Windows on ARM runs the x64 build under emulation). It installs a small
`zero` wrapper plus, as an optional dependency, a platform payload with the
native binary and the bundled browser/terminal control helpers. There are no
install scripts and nothing is downloaded from outside the npm registry, so
the install is silent and works identically under npm, Bun, pnpm, and yarn —
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
package architecture.
Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
- network access to npm
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
If the install skipped optional dependencies (`npm install --omit=optional`,
or a package manager configured to do so), the wrapper falls back to
downloading the binary from the matching GitHub Release, with checksum
verification. The fetch is retried on each run until a binary is in place (a
transient network failure heals itself; an install directory the current user
cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
@@ -252,8 +226,7 @@ Supported targets:
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64`
- `windows-arm64`
- `windows-x64` (Windows on ARM runs this build under emulation)
Each archive must have a matching `.sha256` file. The install scripts download
both files, verify the checksum, and then copy the binary into the install
+162
View File
@@ -0,0 +1,162 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
`release-artifacts.yml`.
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
install zero without prompts, trust ceremonies, or broken binaries.
- No network fetches outside the npm registry at install time. GitHub being
down or rate-limited must not break `npm install`.
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
of the box.
## Architecture
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
The alias suffixes use Node's `process.platform`/`process.arch` names so
`bin/zero.js` can derive its platform package directly.
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
them and skips the rest.
- A platform payload is assembled from the platform's release archive by
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
the platform's sandbox helpers, and the vendored `helpers/` tree (see
below). It has no `bin`, no scripts, and no dependencies — it is inert on
its own; the wrapper execs the binary out of it.
- The published wrapper has **no scripts and no dependencies** — the
assembly script strips both from the repo `package.json` and injects the
exact-version `optionalDependencies`. (The repo `package.json` keeps
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
the vendored helpers tree; they are never installed by consumers.)
- There is **no windows-arm64 build** (matches the release matrix); Windows on
ARM runs the x64 build under emulation via the first-run fallback.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
3. If neither exists (`--omit=optional`, package managers that skip optional
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
verified against the release's own checksum file, no zip-slip), invoked by
the wrapper itself. Failures are deliberately not cached — the fetch
retries on every run until a binary is in place, so a transient network
error self-heals. Platforms with no release asset (anything outside the
matrix above, including windows-arm64) skip the attempt entirely rather
than probing the network each run.
4. If the download is impossible too, print build-from-source guidance.
There is deliberately **no `scripts.postinstall`** in any published
`package.json`. The downloader exists only as a first-run fallback.
### Vendored helpers (`helpers/`)
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
**vendored binaries/packages inside the platform payload**, not npm
dependencies of the wrapper:
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
every installer. As a vendored tree it produces none, because npm never
resolves it.
- `zero-release package` already stages the helpers tree into every release
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
`package.json` pins + lockfile on the native builder). The assembly script
reuses that staged tree, so npm installs and `install.sh` installs get
identical helpers.
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
wrapper involvement, no configuration.
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
other symlink, so the vendored tree survives publishing. (Verified
empirically; nested `node_modules` under `helpers/` IS packed — only the
package-root `node_modules` is always ignored.)
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
platform (7 total). The assembly script keeps only the payload's own
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
non-`latest` dist-tag would clobber `latest` and users would install a
platform payload as the CLI. The workflow publishes platform versions with
`--tag platform` and asserts `latest` survived before the wrapper publish,
then asserts `latest` equals the wrapper version after it. Both lookups
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
The vendored helper versions are pinned by the repo `package.json`
`dependencies` + `package-lock.json`; the release build vendors exactly what
the lockfile resolves. No `^range` surprises ship: a release carries what its
lockfile pinned at build time.
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
ship with the next release.
- Security response: if a vendored helper publishes a security fix, the bump
is release-worthy on its own — cut a patch release of zero carrying only the
pin change.
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
vendored package directory (`helpers/node_modules/agent-browser/`), which
satisfies redistribution attribution.
## History / rationale
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
`agent-browser`/`tuistory` as regular dependencies. That produced three
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
silently under Bun's default script blocking, and coupled `npm install` to
GitHub Releases availability. The platform-package model removes the warnings
structurally — there is nothing left in the tree for a package manager to warn
about — rather than asking users to approve or suppress them.
+45 -10
View File
@@ -1,8 +1,8 @@
# npm Wrapper Smoke Checklist
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
wrapper.
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
```
## End-To-End Assembly Smoke
Build a real archive for the host platform and assemble the npm payloads from
it (this is exactly what the publish job does):
```bash
go run ./cmd/zero-release package
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
```
Then simulate an installed layout and run the wrapper through each resolution
path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
archive's SHA-256, and extracts only the known binary basenames into place.
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
platform/arch is a non-fatal skip.
- The wrapper binary resolves through the package `bin` entry and
`node_modules/.bin` in a package-install smoke test.
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
matrix.
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
plan), verifies the downloaded archive's SHA-256, and extracts only the
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
(exit 0) and an unsupported platform/arch is a non-fatal skip.
- The wrapper prefers the platform package binary over a previously downloaded
one, and `agent-browser`/`tuistory` run from the platform package's
`helpers/node_modules/.bin` shims.
- The built binary exits 0 for `zero --version` or `zero --help`.
- `zero --version` reports `zero <package.json version>`.
- Release packaging still emits the expected archive and checksum names when
+20
View File
@@ -72,6 +72,26 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
{"I replaced the placeholder value with the computed key.", false},
{"The retry uses exponential backoff as a fallback.", false},
{"Without proper error handling the function would crash, so I added it.", false},
// real false positive (conversational recap): retelling a PAST exchange is
// not an admission about the current objective — the sentence carries its
// own narrative context ("You asked …", "at the time"):
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
{"Last session I was unable to build the project; that is what we should retry now.", false},
// quoted admissions (the model citing its own earlier message or a log
// line) must NOT be flagged:
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
// current-objective admissions must STILL be flagged, including past tense
// and multi-sentence finals whose other sentences are narrative:
{"I couldn't complete the refactor.", true},
{"I hit an error I could not resolve, so the migration is unfinished.", true},
{"You asked me to port the parser. I couldn't finish the second half.", true},
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
// spans are stripped), so an admission after a stray quote still fires:
{"The log ends with a stray \" and I couldn't finish the migration.", true},
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
}
for _, c := range cases {
got := selfReportedIncompletion(c.text) != ""
+97 -20
View File
@@ -183,35 +183,112 @@ var successNegationTails = []string{
"reproduce", "spot any", "locate any",
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
func selfReportedIncompletion(text string) string {
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
// reporting the outcome of the current objective, so an inability admission in
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
// conversational recap ("You asked if I could work autonomously … I was honest
// that my sandbox had no repo … so I couldn't actually do it at the time") was
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
// current turn's objective (summarize where we left off) was fully met. Every
// marker is second-person or explicitly past-referential, so a first-person
// admission about the current task ("I couldn't complete the refactor") is
// never masked.
var narrativeMarkers = []string{
"you asked", "you said", "you wanted", "you mentioned",
"we discussed", "we talked", "we covered",
"when we last", "last time", "last session", "previous session",
"previous conversation", "earlier session", "earlier conversation",
"at the time", "back then",
}
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
// backticks, so an admission the model merely QUOTES — its own earlier message,
// a log line, an error string — cannot fire the detector. Only BALANCED spans
// are removed: an opening delimiter that never closes is kept as literal text,
// so a stray quote cannot swallow the rest of the message (and with it a
// genuine admission the detector must see). Single quotes are left alone: they
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
// delimiters would swallow the text between two contractions.
func stripQuoted(s string) string {
var b strings.Builder
var span strings.Builder // pending text since the open delimiter, kept if it never closes
open := rune(0)
for _, r := range s {
switch {
case open != 0:
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
open = 0
span.Reset()
continue
}
span.WriteRune(r)
case r == '"' || r == '“' || r == '`':
open = r
span.WriteRune(r)
default:
b.WriteRune(r)
}
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(lower[start:], stem)
if rel < 0 {
break
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
return b.String()
}
// admissionSentences splits lowered text into sentence-ish fragments so the
// detector judges each claim in its own context. Newlines split too (markdown
// bullets are separate claims); the exact boundaries only need to keep an
// admission next to its own narrative/negation context, not be grammatical.
func admissionSentences(lower string) []string {
return strings.FieldsFunc(lower, func(r rune) bool {
return r == '.' || r == '!' || r == '?' || r == '\n'
})
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
// Matching is per sentence, after dropping quoted spans, and a sentence that
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
func selfReportedIncompletion(text string) string {
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
if containsAny(sentence, narrativeMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
}
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(sentence[start:], stem)
if rel < 0 {
break
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
}
start = abs + len(stem)
}
}
return ""
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func selfReportReason(marker string) string {
return `the final message admits the objective was not met ("` + marker + `")`
}
+2
View File
@@ -1347,6 +1347,8 @@ Flags:
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
downgrade (for conversational callers with an operator present)
`)
return err
}
+14 -2
View File
@@ -110,6 +110,16 @@ type execOptions struct {
notifyMode string
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
noNotify bool
// noCompletionGate turns off the headless completion gate
// (agent.Options.RequireCompletionSignal) for this run. It exists for
// CONVERSATIONAL exec callers — a chat frontend driving exec with an operator
// present to reply (e.g. zeroclaw chat). There, a final message that hands
// the turn back ("the sandbox blocks network egress, approve it and I'll
// continue") is a COMPLETE answer, not an unfinished task, so the gate's
// self-report admission check and INCOMPLETE downgrade (exit 4) would only
// mislabel honest blocker reports. Default off: plain `zero exec` keeps the
// gate, preserving CI/cron semantics.
noCompletionGate bool
// addDirs holds directories passed via --add-dir that should be allowed as
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
@@ -556,8 +566,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
// if the model keeps stalling. The interactive TUI leaves this off, and
// --no-completion-gate lets conversational exec callers (a chat frontend
// with an operator present) opt out the same way.
RequireCompletionSignal: !options.noCompletionGate,
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
+7
View File
@@ -30,6 +30,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.selfCorrect = true
case arg == "--no-notify":
options.noNotify = true
case arg == "--no-completion-gate":
options.noCompletionGate = true
case arg == "--notify":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -402,6 +404,11 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
// rather than pretend it took effect.
return options, false, execUsageError{"--self-correct cannot be combined with --use-spec."}
}
if options.useSpec && options.noCompletionGate {
// Same reasoning as --self-correct above: the spec-draft path never consults
// the completion gate, so the flag would be silently ignored.
return options, false, execUsageError{"--no-completion-gate cannot be combined with --use-spec."}
}
if !options.useSpec && options.specModel != "" {
return options, false, execUsageError{"--spec-model requires --use-spec."}
}
@@ -0,0 +1,34 @@
package cli
import "testing"
// --no-completion-gate exists for conversational exec callers (a chat frontend
// with an operator present): an honest blocker report that hands the turn back
// must not be downgraded to INCOMPLETE there. The flag maps to
// agent.Options.RequireCompletionSignal = false; default keeps the gate on.
func TestParseExecArgsNoCompletionGate(t *testing.T) {
options, _, err := parseExecArgs([]string{"--prompt", "hi", "--no-completion-gate"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if !options.noCompletionGate {
t.Fatal("--no-completion-gate must set noCompletionGate")
}
options, _, err = parseExecArgs([]string{"--prompt", "hi"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if options.noCompletionGate {
t.Fatal("noCompletionGate must default to false (gate on)")
}
}
// The spec-draft path never consults the completion gate, so the combination
// must be rejected up front instead of silently ignoring the flag (mirrors the
// existing --self-correct + --use-spec check).
func TestParseExecArgsNoCompletionGateRejectsUseSpec(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--prompt", "hi", "--use-spec", "--no-completion-gate"}); err == nil {
t.Fatal("--no-completion-gate with --use-spec must error")
}
}
+2 -2
View File
@@ -86,7 +86,7 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := store.Save(serverName, token); err != nil {
if err := store.SaveForServer(server, token); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -116,7 +116,7 @@ func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps a
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
removed, err := store.Delete(serverName)
removed, err := store.DeleteForServerName(serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
+34 -17
View File
@@ -88,6 +88,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
if err := store.SaveForServer(mcp.Server{Name: "remote", Identity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, mcp.StoredToken{AccessToken: "identity", RefreshToken: "ir"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
@@ -101,6 +104,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
if _, ok, _ := store.Load("remote"); ok {
t.Fatal("token still present after logout")
}
if statuses, err := store.Status(); err != nil || len(statuses) != 0 {
t.Fatalf("status after logout = %#v err=%v, want no tokens", statuses, err)
}
}
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
@@ -122,23 +128,24 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
}
cwd := t.TempDir()
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
},
}}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
},
}}, nil
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return mcpConfig, nil
},
now: time.Now,
}
@@ -173,9 +180,13 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
token, ok, err := store.Load("remote")
servers, err := mcp.NormalizeConfig(mcpConfig)
if err != nil || len(servers) != 1 {
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
}
token, ok, err := store.LoadForServer(servers[0])
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
t.Fatalf("stored token = %#v", token)
@@ -483,7 +494,13 @@ func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
if gotExclude {
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
}
token, ok, err := store.Load("remote")
servers, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": projectServer,
}})
if err != nil || len(servers) != 1 {
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
}
token, ok, err := store.LoadForServer(servers[0])
if err != nil || !ok {
t.Fatalf("token must be stored on trusted login; ok=%v err=%v", ok, err)
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"fmt"
"slices"
"strings"
)
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
if len(src.Servers) == 0 {
return nil
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
base := dst.Servers[name]
candidate := mergeMCPServer(base, server)
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
}
candidate.ProjectConfigured = true
dst.Servers[name] = candidate
}
return nil
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
if strings.TrimSpace(next.Command) != "" {
base.Command = next.Command
}
if next.Args != nil {
base.Args = append([]string{}, next.Args...)
}
if next.Env != nil {
base.Env = copyMCPStringMap(next.Env)
}
if strings.TrimSpace(next.URL) != "" {
base.URL = next.URL
}
if next.Headers != nil {
base.Headers = copyMCPStringMap(next.Headers)
}
if strings.TrimSpace(next.Auth) != "" {
base.Auth = next.Auth
}
if next.OAuth != nil {
base.OAuth = next.OAuth
}
if next.disabledSet || next.Disabled {
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
}
if next.ProjectConfigured {
base.ProjectConfigured = true
}
return base
}
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
return true
}
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
return true
}
if strings.TrimSpace(next.Command) != "" && strings.TrimSpace(next.Command) != strings.TrimSpace(base.Command) {
return true
}
if next.Args != nil && !slices.Equal(trimMCPArgs(next.Args), trimMCPArgs(base.Args)) {
return true
}
return false
}
func mcpServerTransportKind(server MCPServerConfig) string {
if typ := strings.TrimSpace(strings.ToLower(server.Type)); typ != "" {
return typ
}
if strings.TrimSpace(server.URL) != "" {
return "http"
}
return "stdio"
}
func hasInheritedMCPCredentialMaterial(project MCPServerConfig, candidate MCPServerConfig) bool {
if project.Headers == nil && hasMCPStringMapMaterial(candidate.Headers) {
return true
}
if project.Env == nil && hasMCPStringMapMaterial(candidate.Env) {
return true
}
if project.OAuth == nil && hasMCPOAuthMaterial(candidate.OAuth) {
return true
}
return false
}
func hasMCPStringMapMaterial(values map[string]string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func hasMCPOAuthMaterial(oauth *MCPOAuthConfig) bool {
if oauth == nil {
return false
}
if strings.TrimSpace(oauth.ClientID) != "" ||
strings.TrimSpace(oauth.ClientSecret) != "" ||
strings.TrimSpace(oauth.AuthorizationEndpoint) != "" ||
strings.TrimSpace(oauth.TokenEndpoint) != "" ||
strings.TrimSpace(oauth.RegistrationEndpoint) != "" ||
strings.TrimSpace(oauth.IssuerURL) != "" {
return true
}
for _, scope := range oauth.Scopes {
if strings.TrimSpace(scope) != "" {
return true
}
}
return false
}
func trimMCPArgs(values []string) []string {
if values == nil {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
trimmed = append(trimmed, value)
}
}
return trimmed
}
func copyMCPStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
+201
View File
@@ -0,0 +1,201 @@
package config
import (
"strings"
"testing"
)
func TestResolveRejectsProjectMCPURLOverrideWithInheritedHeaders(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"url": "https://attacker.example/mcp"}
}
}
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want inherited MCP credential rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked header secret: %q", err.Error())
}
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
}
}
func TestResolveMCPRejectsProjectURLOverrideWithInheritedHeaders(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcpServers": {
"docs": {"url": "https://attacker.example/mcp"}
}
}`)
_, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err == nil {
t.Fatal("ResolveMCP() error = nil, want inherited MCP credential rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked header secret: %q", err.Error())
}
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
}
}
func TestResolveRejectsProjectMCPStdioOverrideWithInheritedEnv(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--user"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"args": ["--project"]}
}
}
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want inherited MCP env rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked env secret: %q", err.Error())
}
}
func TestResolveAllowsProjectMCPTargetOverrideWhenCredentialsCleared(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"web": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
},
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--user"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"web": {"url": "https://project.example/mcp", "headers": {}},
"docs": {"args": ["--project"], "env": {}}
}
}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
web := resolved.MCP.Servers["web"]
if web.URL != "https://project.example/mcp" || len(web.Headers) != 0 || !web.ProjectConfigured {
t.Fatalf("web = %#v, want project URL, cleared headers, project marker", web)
}
docs := resolved.MCP.Servers["docs"]
if got := strings.Join(docs.Args, " "); got != "--project" {
t.Fatalf("docs.Args = %q, want --project", got)
}
if len(docs.Env) != 0 || !docs.ProjectConfigured {
t.Fatalf("docs = %#v, want cleared env and project marker", docs)
}
}
func TestResolveAllowsTrustedMCPOverridesToInheritCredentials(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
Env: map[string]string{},
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{
"docs": {URL: "https://cli.example/mcp"},
}}},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
docs := resolved.MCP.Servers["docs"]
if docs.URL != "https://cli.example/mcp" || docs.Headers["Authorization"] != "Bearer user-secret" || docs.ProjectConfigured {
t.Fatalf("docs = %#v, want trusted override to inherit headers without project marker", docs)
}
}
func TestResolveMCPProjectArgsTargetCompareUsesNormalizedArgs(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"args": [" --port ", "7777", ""]}
}
}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if !resolved.MCP.Servers["docs"].ProjectConfigured {
t.Fatalf("docs = %#v, want project marker", resolved.MCP.Servers["docs"])
}
}
+17 -69
View File
@@ -163,22 +163,25 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
if path == "" {
continue
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) stdio MCP servers. Fail-closed:
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
if options.ExcludeProject && path == options.ProjectConfigPath {
continue
}
fileConfig, err := loadConfigFile(path)
if options.UserConfigPath != "" {
fileConfig, err := loadConfigFile(options.UserConfigPath)
if err != nil {
return MCPConfig{}, err
}
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
if options.ProjectConfigPath != "" && !options.ExcludeProject {
fileConfig, err := loadConfigFile(options.ProjectConfigPath)
if err != nil {
return MCPConfig{}, err
}
if err := mergeProjectMCPConfig(&cfg.MCP, fileConfig.MCP); err != nil {
return MCPConfig{}, err
}
}
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
return cfg.MCP, nil
}
@@ -261,7 +264,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
}
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -834,63 +839,6 @@ func validateSTTConfig(cfg STTConfig) error {
return nil
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
if strings.TrimSpace(next.Command) != "" {
base.Command = next.Command
}
if next.Args != nil {
base.Args = append([]string{}, next.Args...)
}
if next.Env != nil {
base.Env = copyMCPStringMap(next.Env)
}
if strings.TrimSpace(next.URL) != "" {
base.URL = next.URL
}
if next.Headers != nil {
base.Headers = copyMCPStringMap(next.Headers)
}
if strings.TrimSpace(next.Auth) != "" {
base.Auth = next.Auth
}
if next.OAuth != nil {
base.OAuth = next.OAuth
}
if next.disabledSet || next.Disabled {
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
}
return base
}
func copyMCPStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
func hasProviderFields(profile ProviderProfile) bool {
return HasProviderProfile(profile)
}
+13 -10
View File
@@ -440,16 +440,19 @@ type MCPConfig struct {
}
type MCPServerConfig struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled bool `json:"disabled,omitempty"`
disabledSet bool
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// ProjectConfigured marks servers touched by project config. It is runtime
// metadata, not persisted config.
ProjectConfigured bool `json:"-"`
disabledSet bool
// configured is true when the user's config JSON declared an object for
// this server at all (i.e. UnmarshalJSON ran for it), regardless of which
// fields it set or what values they hold. A built-in default seeded by
+4
View File
@@ -31,6 +31,9 @@ type Server struct {
Auth string
OAuth *OAuthConfig
Identity string
// ProjectConfigured is true when project config touched this server. Runtime
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
@@ -88,6 +91,7 @@ func normalizeServer(name string, raw config.MCPServerConfig) (Server, error) {
Headers: copyStringMap(raw.Headers),
Auth: auth,
OAuth: normalizeOAuthConfig(raw.OAuth),
ProjectConfigured: raw.ProjectConfigured,
UnconfiguredDefault: config.IsUnconfiguredDefault(name, raw),
}
+3 -3
View File
@@ -847,7 +847,7 @@ func (source *storeTokenSource) config() OAuthConfig {
}
func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error) {
token, ok, err := source.store.Load(source.server.Name)
token, ok, err := source.store.LoadForServer(source.server)
if err != nil {
return "", err
}
@@ -858,7 +858,7 @@ func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error)
}
func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
token, ok, err := source.store.Load(source.server.Name)
token, ok, err := source.store.LoadForServer(source.server)
if err != nil {
return "", err
}
@@ -869,7 +869,7 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
if err := source.store.Save(source.server.Name, refreshed); err != nil {
if err := source.store.SaveForServer(source.server, refreshed); err != nil {
return "", err
}
return refreshed.AccessToken, nil
+132 -3
View File
@@ -15,6 +15,9 @@ import (
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
// retained so migration can recognize a file it understands.
const tokenStoreSchemaVersion = 1
const mcpServerIdentityLength = 32
const maxOAuthTokenKeySegmentLength = 128
const maxMCPServerNameForIdentityToken = maxOAuthTokenKeySegmentLength - 1 - mcpServerIdentityLength
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
// for a single MCP server. The token fields are sensitive: they are tagged so
@@ -32,6 +35,7 @@ type StoredToken struct {
// omits the access and refresh token material so it can be printed by the CLI.
type TokenStatus struct {
ServerName string `json:"serverName"`
ServerIdentity string `json:"serverIdentity,omitempty"`
HasToken bool `json:"hasToken"`
HasRefreshToken bool `json:"hasRefreshToken"`
TokenType string `json:"tokenType,omitempty"`
@@ -148,6 +152,17 @@ func (store *TokenStore) Save(serverName string, token StoredToken) error {
return store.store.Save(key, storedToOAuth(token))
}
// SaveForServer persists a token under the server's target identity. Identity
// binding prevents a project config with the same server name from reusing a
// token issued for a different MCP endpoint.
func (store *TokenStore) SaveForServer(server Server, token StoredToken) error {
key, err := mcpIdentityKey(server)
if err != nil {
return err
}
return store.store.Save(key, storedToOAuth(token))
}
// Load returns the stored token for a server. The second return value is false
// when no token has been stored for the server.
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
@@ -162,6 +177,30 @@ func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
return tokenToStored(token), true, nil
}
// LoadForServer returns the token matching the server's target identity. Legacy
// name-only tokens are migrated only for servers untouched by project config.
func (store *TokenStore) LoadForServer(server Server) (StoredToken, bool, error) {
key, err := mcpIdentityKey(server)
if err != nil {
return StoredToken{}, false, err
}
token, ok, err := store.store.Load(key)
if err != nil || ok {
return tokenToStored(token), ok, err
}
if server.ProjectConfigured {
return StoredToken{}, false, nil
}
legacy, ok, err := store.Load(server.Name)
if err != nil || !ok {
return StoredToken{}, ok, err
}
if err := store.store.Save(key, storedToOAuth(legacy)); err != nil {
return StoredToken{}, false, err
}
return legacy, true, nil
}
// Delete removes the stored token for a server. It reports whether an entry was
// present before deletion.
func (store *TokenStore) Delete(serverName string) (bool, error) {
@@ -172,6 +211,36 @@ func (store *TokenStore) Delete(serverName string) (bool, error) {
return store.store.Delete(key)
}
// DeleteForServerName removes both legacy name-only and identity-bound tokens
// for a server name.
func (store *TokenStore) DeleteForServerName(serverName string) (bool, error) {
name := strings.TrimSpace(serverName)
if err := ValidateServerName(name); err != nil {
return false, err
}
removed, err := store.Delete(name)
if err != nil {
return false, err
}
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
if err != nil {
return removed, err
}
for _, status := range statuses {
segment := strings.TrimPrefix(status.Key, oauth.KeyPrefixMCP)
parsedName, _, ok := splitMCPIdentityKeySegment(segment)
if !ok || parsedName != name {
continue
}
deleted, err := store.store.Delete(status.Key)
if err != nil {
return removed, err
}
removed = removed || deleted
}
return removed, nil
}
// Status returns a redaction-safe summary of every stored MCP token, sorted by
// server name. It never includes the token material.
func (store *TokenStore) Status() ([]TokenStatus, error) {
@@ -181,8 +250,10 @@ func (store *TokenStore) Status() ([]TokenStatus, error) {
}
out := make([]TokenStatus, 0, len(statuses))
for _, s := range statuses {
serverName, serverIdentity, _ := splitMCPIdentityKeySegment(strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP))
out = append(out, TokenStatus{
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
ServerName: serverName,
ServerIdentity: serverIdentity,
HasToken: s.HasToken,
HasRefreshToken: s.HasRefreshToken,
TokenType: s.TokenType,
@@ -250,16 +321,69 @@ func (store *TokenStore) migrateLegacy(legacyPath string) error {
// mcpKey builds and validates the unified store key for an MCP server token.
func mcpKey(serverName string) (string, error) {
if err := ValidateServerName(serverName); err != nil {
name := strings.TrimSpace(serverName)
if err := ValidateServerName(name); err != nil {
return "", err
}
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
if hasMCPIdentitySuffix(name) {
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
}
key := oauth.KeyPrefixMCP + name
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
func mcpIdentityKey(server Server) (string, error) {
name := strings.TrimSpace(server.Name)
if err := ValidateServerName(name); err != nil {
return "", err
}
if hasMCPIdentitySuffix(name) {
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
}
identity := strings.TrimSpace(server.Identity)
if len(name) > maxMCPServerNameForIdentityToken {
return "", fmt.Errorf("MCP OAuth server name %q is too long for identity-bound token storage", name)
}
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
return "", fmt.Errorf("MCP OAuth server %s has invalid identity %q", name, identity)
}
key := oauth.KeyPrefixMCP + name + "." + identity
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
func splitMCPIdentityKeySegment(segment string) (string, string, bool) {
index := strings.LastIndex(segment, ".")
if index <= 0 || index == len(segment)-1 {
return segment, "", false
}
identity := segment[index+1:]
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
return segment, "", false
}
return segment[:index], identity, true
}
func hasMCPIdentitySuffix(value string) bool {
_, _, ok := splitMCPIdentityKeySegment(value)
return ok
}
func isMCPServerIdentity(value string) bool {
for _, ch := range value {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
continue
}
return false
}
return value != ""
}
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
// inverse of tokenToStored). MCP never sets the oauth Account field.
func storedToOAuth(s StoredToken) oauth.Token {
@@ -284,6 +408,11 @@ func FormatTokenStatuses(statuses []TokenStatus) string {
builder.WriteByte('\n')
}
builder.WriteString(status.ServerName)
if status.ServerIdentity != "" {
builder.WriteString(" [")
builder.WriteString(status.ServerIdentity)
builder.WriteString("]")
}
builder.WriteString(": ")
if !status.HasToken {
builder.WriteString("no token")
+198
View File
@@ -1,9 +1,11 @@
package mcp
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
@@ -43,6 +45,191 @@ func TestTokenStoreRoundTrip(t *testing.T) {
}
}
func TestTokenStoreIdentityBoundRoundTrip(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
if err := store.SaveForServer(server, StoredToken{AccessToken: "identity-token"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
loaded, ok, err := store.LoadForServer(server)
if err != nil || !ok {
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if loaded.AccessToken != "identity-token" {
t.Fatalf("access token = %q", loaded.AccessToken)
}
if _, ok, err := store.LoadForServer(otherTarget); err != nil || ok {
t.Fatalf("LoadForServer(other target) ok=%v err=%v, want no token", ok, err)
}
}
func TestStoreTokenSourceRequiresMatchingIdentity(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
if err := store.SaveForServer(server, StoredToken{AccessToken: "access-a"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
source := &storeTokenSource{server: server, store: store}
token, err := source.AccessToken(context.Background())
if err != nil {
t.Fatalf("AccessToken() error = %v", err)
}
if token != "access-a" {
t.Fatalf("token = %q, want access-a", token)
}
source.server = otherTarget
if _, err := source.AccessToken(context.Background()); err == nil {
t.Fatal("AccessToken() error = nil for mismatched identity")
}
}
func TestTokenStoreProjectConfiguredDoesNotLoadLegacyToken(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)
_, ok, err := store.LoadForServer(server)
if err != nil {
t.Fatalf("LoadForServer() error = %v", err)
}
if ok {
t.Fatal("LoadForServer() ok = true for project-configured legacy token")
}
}
func TestTokenStoreMigratesLegacyForUserOnlyServer(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
loaded, ok, err := store.LoadForServer(server)
if err != nil || !ok {
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if loaded.AccessToken != "legacy-token" {
t.Fatalf("access token = %q", loaded.AccessToken)
}
if _, err := store.Delete("demo"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
loaded, ok, err = store.LoadForServer(server)
if err != nil || !ok || loaded.AccessToken != "legacy-token" {
t.Fatalf("identity-bound migrated token missing: loaded=%#v ok=%v err=%v", loaded, ok, err)
}
}
func TestTokenStoreRejectsLongIdentityBoundServerName(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
name := strings.Repeat("a", maxMCPServerNameForIdentityToken+1)
err = store.SaveForServer(testOAuthServer(name, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "x"})
if err == nil {
t.Fatal("SaveForServer() error = nil for long server name")
}
if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" is too long for identity-bound token storage`) {
t.Fatalf("error = %q, want long-name message", err.Error())
}
}
func TestTokenStoreRejectsAmbiguousIdentitySuffixServerName(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
name := "demo.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if err := store.SaveForServer(testOAuthServer(name, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "x"}); err == nil {
t.Fatal("SaveForServer() error = nil for ambiguous server name")
} else if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" cannot end with .<32 hex chars>`) {
t.Fatalf("SaveForServer() error = %q, want ambiguous-name rejection", err.Error())
}
if err := store.Save(name, StoredToken{AccessToken: "legacy"}); err == nil {
t.Fatal("Save() error = nil for ambiguous legacy server name")
}
}
func TestTokenStoreDeleteForServerNameRemovesLegacyAndIdentityTokens(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "a"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "b"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("other", "cccccccccccccccccccccccccccccccc", false), StoredToken{AccessToken: "c"}); err != nil {
t.Fatal(err)
}
removed, err := store.DeleteForServerName("demo")
if err != nil {
t.Fatalf("DeleteForServerName() error = %v", err)
}
if !removed {
t.Fatal("DeleteForServerName() removed = false")
}
statuses, err := store.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if len(statuses) != 1 || statuses[0].ServerName != "other" {
t.Fatalf("statuses after delete = %#v, want only other", statuses)
}
}
func TestTokenStoreStatusParsesIdentityAndRedacts(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
identity := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if err := store.SaveForServer(testOAuthServer("demo", identity, false), StoredToken{AccessToken: "secret-access", RefreshToken: "secret-refresh"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
statuses, err := store.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if len(statuses) != 1 || statuses[0].ServerName != "demo" || statuses[0].ServerIdentity != identity {
t.Fatalf("statuses = %#v, want parsed identity", statuses)
}
output := FormatTokenStatuses(statuses)
if !strings.Contains(output, "demo ["+identity+"]") {
t.Fatalf("status output = %q, want identity label", output)
}
if contains(output, "secret-access") || contains(output, "secret-refresh") {
t.Fatalf("status output leaked token: %s", output)
}
}
func TestTokenStoreFileIs0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file permissions are not enforced on Windows")
@@ -268,6 +455,17 @@ func TestResolveTokenStorePathUsesXDG(t *testing.T) {
}
}
func testOAuthServer(name string, identity string, projectConfigured bool) Server {
return Server{
Name: name,
Type: ServerTypeHTTP,
URL: "https://example.com/mcp",
Auth: ServerAuthOAuth,
Identity: identity,
ProjectConfigured: projectConfigured,
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (haystack == needle || indexOf(haystack, needle) >= 0)
}
+260
View File
@@ -0,0 +1,260 @@
package npmwrapper
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
// assembly script against a fixture linux-x64 archive shaped like the output
// of `zero-release package` and checks the npm publish payloads it emits:
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
// dependencies, full optionalDependencies alias matrix).
func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
if platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
}
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
}
if len(platformPkg.Scripts) != 0 {
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
}
info, err := os.Stat(filepath.Join(platformDir, "zero"))
if err != nil {
t.Fatalf("platform package binary missing: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("platform package binary is not executable: %v", info.Mode())
}
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
}
}
// npm only ships a license file it finds in the packed directory, so the
// assembly must place the repo LICENSE into every payload.
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
t.Fatalf("platform package missing LICENSE: %v", err)
}
// npm pack drops symlinks, so the shim must now be a regular executable file.
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
shimInfo, err := os.Lstat(shimPath)
if err != nil {
t.Fatalf("agent-browser shim missing: %v", err)
}
if shimInfo.Mode()&os.ModeSymlink != 0 {
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
}
if shimInfo.Mode()&0o111 == 0 {
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
}
shim, err := os.ReadFile(shimPath)
if err != nil {
t.Fatalf("ReadFile shim: %v", err)
}
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
}
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
t.Fatalf("tuistory shim missing: %v", err)
}
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
entries, err := os.ReadDir(prunedBinDir)
if err != nil {
t.Fatalf("ReadDir pruned bin: %v", err)
}
got := []string{}
for _, entry := range entries {
got = append(got, entry.Name())
}
want := map[string]bool{
"agent-browser.js": true,
"agent-browser-linux-x64": true,
"agent-browser-linux-musl-x64": true,
}
if len(got) != len(want) {
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
}
for _, name := range got {
if !want[name] {
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
}
}
wrapperDir := filepath.Join(outDir, "wrapper")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
Optional map[string]string `json:"optionalDependencies"`
}
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
if wrapperPkg.Version != version {
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
}
if len(wrapperPkg.Scripts) != 0 {
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
}
if len(wrapperPkg.Dependencies) != 0 {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
}
for alias, spec := range wantAliases {
if wrapperPkg.Optional[alias] != spec {
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
}
}
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
t.Fatalf("wrapper payload missing %s: %v", file, err)
}
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("Unmarshal %s: %v", path, err)
}
}
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
t.Helper()
mustMkdirAllFixture(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if mode&0o111 != 0 {
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
}
func mustMkdirAllFixture(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", path, err)
}
}
func requireCommand(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s is not available", name)
}
return path
}
+142 -10
View File
@@ -41,16 +41,13 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if pkg.Module != "bin/zero.js" {
t.Fatalf("module = %q, want bin/zero.js", pkg.Module)
}
// Only a postinstall hook (which downloads the prebuilt binary) is allowed.
// Repository build scripts (build/prepare/prepack/…) must not ship in the
// published package — the tarball has no Go source to build from.
if pkg.Scripts["postinstall"] != "node scripts/postinstall.mjs" {
t.Fatalf("scripts.postinstall = %q, want node scripts/postinstall.mjs", pkg.Scripts["postinstall"])
}
// The published package must carry NO lifecycle scripts at all: the native
// binary arrives as a platform optionalDependency, with scripts/postinstall.mjs
// kept only as a first-run fallback the wrapper invokes itself. Any script
// here would resurrect the npm/Bun/pnpm install-script warnings the platform
//-package model exists to eliminate (see docs/NPM_PACKAGING.md).
for name := range pkg.Scripts {
if name != "postinstall" {
t.Fatalf("package.json scripts contains %q; only a postinstall hook is allowed (no repository build scripts)", name)
}
t.Fatalf("package.json scripts contains %q; the published package must have no lifecycle scripts", name)
}
if pkg.License == "" {
t.Fatalf("package.json license is empty; set it (ties to the pending LICENSE file) so npm publish is not unlicensed")
@@ -256,11 +253,146 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
if !strings.Contains(string(output), "No native binary found next to the npm wrapper") {
if !strings.Contains(string(output), "No native binary is available for this install") {
t.Fatalf("missing-native output = %q", string(output))
}
}
func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// A stale downloaded binary next to the wrapper must lose to the platform
// package: the platform version is pinned to the wrapper release, the
// downloaded copy is whatever a previous fallback fetched.
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
if err := os.MkdirAll(platformDir, 0o755); err != nil {
t.Fatalf("MkdirAll platform package: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile platform package.json: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile platform binary: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching platform binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "platform-zero --version" {
t.Fatalf("wrapper output = %q, want platform-zero --version", got)
}
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching downloaded binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "downloaded-zero" {
t.Fatalf("wrapper output = %q, want downloaded-zero", got)
}
}
func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// Give the fixture the real downloader so the wrapper's first-run path
// executes it; ZERO_SKIP_DOWNLOAD keeps the test offline, so the download
// "succeeds" without producing a binary and the wrapper must exit 1 with
// guidance.
scriptsDir := filepath.Join(root, "scripts")
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
t.Fatalf("MkdirAll scripts: %v", err)
}
postinstall, err := os.ReadFile(filepath.Join(repoRoot(t), "scripts", "postinstall.mjs"))
if err != nil {
t.Fatalf("ReadFile postinstall: %v", err)
}
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
t.Fatalf("WriteFile postinstall fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(command.Env, "ZERO_SKIP_DOWNLOAD=1")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out on first-run download path: %v; output: %s", ctx.Err(), output)
}
exitErr, ok := err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
text := string(output)
if !strings.Contains(text, "fetching the native binary from the GitHub Release") {
t.Fatalf("output missing fallback download notice: %q", text)
}
if !strings.Contains(text, "skipping native binary download") {
t.Fatalf("output shows the downloader did not run: %q", text)
}
if !strings.Contains(text, "No native binary is available for this install") {
t.Fatalf("output missing guidance: %q", text)
}
}
func nodePlatformName() string {
switch runtime.GOOS {
case "darwin":
return "darwin"
case "windows":
return "win32"
default:
return runtime.GOOS
}
}
func nodeArchName() string {
switch runtime.GOARCH {
case "amd64":
return "x64"
default:
return runtime.GOARCH
}
}
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
-3
View File
@@ -7,9 +7,6 @@
"bin": {
"zero": "bin/zero.js"
},
"scripts": {
"postinstall": "node scripts/postinstall.mjs"
},
"files": [
"bin/zero.js",
"scripts/postinstall.mjs"
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
// build-platform-packages.mjs: assemble the npm publish payloads for a release
// from the per-platform archives built by `zero-release package`.
//
// Layout written under --out-dir (default dist/npm):
// wrapper/ @gitlawb/[email protected] — bin/zero.js, the
// first-run fallback downloader, and a
// package.json with NO scripts and NO
// dependencies, only optionalDependencies
// aliasing the platform versions below.
// platforms/zero-<platform>-<arch>/
// @gitlawb/[email protected]<platform>-<arch> —
// the zero binary, sandbox helpers, and the
// vendored local-control helpers/ tree,
// copied straight out of the release archive.
//
// The platform "packages" are versions of the SAME @gitlawb/zero package at
// suffixed (semver-prerelease) versions, referenced from the wrapper through
// npm: aliases. One package name keeps a single npm trusted-publisher config.
// See docs/NPM_PACKAGING.md for the publishing rules this feeds.
//
// Archives are verified against their .sha256 files before extraction. All
// five platforms are required unless --only limits the set (testing only —
// CI must always assemble the full matrix, and the wrapper's
// optionalDependencies always reference the full matrix regardless).
//
// Usage:
// node scripts/npm/build-platform-packages.mjs \
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { basename, dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const repoRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
// Node platform/arch names (they name the alias suffixes so bin/zero.js can
// derive its platform package from process.platform/process.arch) mapped to
// the release asset naming from internal/release/release.go. windows-arm64 is
// deliberately absent — the release matrix does not build it.
const MATRIX = [
{ platform: 'darwin', arch: 'arm64', release: 'macos-arm64', ext: 'tar.gz' },
{ platform: 'darwin', arch: 'x64', release: 'macos-x64', ext: 'tar.gz' },
{ platform: 'linux', arch: 'arm64', release: 'linux-arm64', ext: 'tar.gz' },
{ platform: 'linux', arch: 'x64', release: 'linux-x64', ext: 'tar.gz' },
{ platform: 'win32', arch: 'x64', release: 'windows-x64', ext: 'zip' },
];
// Files staged into release archives for the shell installers / humans that
// must NOT ship in a platform npm package (the wrapper package owns them).
const PLATFORM_PACKAGE_EXCLUDES = ['package.json', 'README.md', 'VERSION', 'bin'];
function fail(message) {
console.error(`[build-platform-packages] ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
i += 1;
if (i >= argv.length) fail(`missing value for ${arg}`);
return argv[i];
};
switch (arg) {
case '--artifacts-dir':
args.artifactsDir = next();
break;
case '--out-dir':
args.outDir = next();
break;
case '--only':
args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean));
break;
default:
fail(`unknown argument ${arg}`);
}
}
if (!args.artifactsDir) fail('--artifacts-dir is required');
return args;
}
function readWrapperPackage() {
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
if (!pkg.version) fail('package.json version is empty');
return pkg;
}
function platformVersion(version, entry) {
return `${version}-${entry.platform}-${entry.arch}`;
}
function aliasName(entry) {
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
}
function optionalDependencies(version) {
const aliases = {};
for (const entry of MATRIX) {
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
}
return aliases;
}
// Same anchored sha256sum parsing as scripts/postinstall.mjs: when a line
// carries a filename it must match, so a checksum file cannot misattribute a
// digest.
function parseSha256(text, wantName) {
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-fA-F0-9]{64})(?:\s+\*?(.+))?$/);
if (!match) continue;
const [, hex, name] = match;
if (!name || name.trim() === wantName) {
return hex.toLowerCase();
}
}
fail(`could not find a SHA-256 digest for ${wantName} in its .sha256 file`);
}
function verifyChecksum(archivePath, assetName) {
const checksumPath = `${archivePath}.sha256`;
if (!existsSync(checksumPath)) fail(`missing checksum file ${checksumPath}`);
const expected = parseSha256(readFileSync(checksumPath, 'utf8'), assetName);
const actual = createHash('sha256').update(readFileSync(archivePath)).digest('hex');
if (actual !== expected) {
fail(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
}
}
function extractArchive(archivePath, ext, destDir) {
mkdirSync(destDir, { recursive: true });
const commands =
ext === 'zip'
? [
['unzip', ['-q', archivePath, '-d', destDir]],
// bsdtar (macOS / Windows tar.exe) extracts zips; GNU tar does not,
// hence unzip first.
['tar', ['-xf', archivePath, '-C', destDir]],
]
: [['tar', ['-xzf', archivePath, '-C', destDir]]];
let lastError = '';
for (const [command, commandArgs] of commands) {
const result = spawnSync(command, commandArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
if (result.status === 0) return;
lastError = result.error ? result.error.message : (result.stderr || '').toString().trim();
}
fail(`failed to extract ${archivePath}: ${lastError}`);
}
// zero-release archives carry their payload at the archive root; also accept
// a single zero-vX.Y.Z-<platform>-<arch>/ prefix directory (the shape
// install.sh tolerates) so fixtures and future layout changes both work.
function extractedPackageDir(extractDir, packageName, binaryName) {
if (existsSync(join(extractDir, binaryName))) return extractDir;
const exact = join(extractDir, packageName);
if (existsSync(join(exact, binaryName))) return exact;
const entries = readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
if (entries.length === 1 && existsSync(join(extractDir, entries[0].name, binaryName))) {
return join(extractDir, entries[0].name);
}
fail(`archive ${packageName} did not contain the ${binaryName} binary at a recognizable root`);
}
// npm pack silently drops symlinks from the tarball, and the staged helpers
// tree relies on them: npm's own .bin shims are symlinks on POSIX. Replace
// every symlink with something pack-safe — .bin links become relocatable
// `sh` exec shims, anything else becomes a dereferenced copy — so the
// vendored helpers survive publishing intact.
function materializeSymlinks(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isSymbolicLink()) {
let target;
try {
target = realpathSync(full);
} catch {
fail(`broken symlink in staged helpers tree: ${full}`);
}
const targetInfo = statSync(target);
rmSync(full);
if (basename(dir) === '.bin' && !targetInfo.isDirectory()) {
// relative() must compare like with like: target came out of
// realpathSync, so resolve dir the same way (macOS tmp dirs reach the
// same tree via /var and /private/var, which would otherwise produce a
// ../..-through-the-root monstrosity).
const relTarget = relative(realpathSync(dir), target).split(sep).join('/');
writeFileSync(
full,
'#!/bin/sh\n' +
'here="$(cd "$(dirname "$0")" && pwd)"\n' +
`exec "$here/${relTarget}" "$@"\n`,
);
chmodSync(full, 0o755);
// Direct exec relies on the target's own shebang and executable bit;
// npm pack preserves modes, so set it here.
chmodSync(target, 0o755);
} else {
cpSync(target, full, { recursive: true });
}
} else if (entry.isDirectory()) {
materializeSymlinks(full);
}
}
}
// agent-browser's npm package ships one native binary per supported platform
// (~11 MB each); its bin/agent-browser.js launcher picks the right one from
// process.platform/process.arch at runtime. A platform payload only ever runs
// on its own platform, so keep just the matching binary (plus the musl
// variant on linux — the launcher detects musl at runtime) and drop the rest.
function pruneAgentBrowserBinaries(packageDir, entry) {
const binDir = join(packageDir, 'helpers', 'node_modules', 'agent-browser', 'bin');
if (!existsSync(binDir)) return;
const keep = new Set(
entry.platform === 'linux'
? [`linux-${entry.arch}`, `linux-musl-${entry.arch}`]
: [`${entry.platform}-${entry.arch}`],
);
for (const name of readdirSync(binDir)) {
const match = name.match(/^agent-browser-(.+?)(\.exe)?$/);
if (!match || match[1] === '') continue;
if (!keep.has(match[1])) rmSync(join(binDir, name));
}
const kept = readdirSync(binDir).filter((name) => /^agent-browser-.+/.test(name));
if (kept.length === 0) {
fail(`pruning agent-browser binaries for ${entry.platform}-${entry.arch} left none behind`);
}
}
// Mirrors verifyStagedLocalControlHelpers in internal/release/release.go: the
// payload must carry runnable shims for both vendored helpers, or browser /
// terminal control silently degrades to a PATH lookup for every npm user.
function verifyHelperShims(packageDir, entry, assetName) {
const binDir = join(packageDir, 'helpers', 'node_modules', '.bin');
for (const helper of ['agent-browser', 'tuistory']) {
const shimNames =
entry.platform === 'win32' ? [`${helper}.cmd`, `${helper}.exe`, helper] : [helper];
if (!shimNames.some((name) => existsSync(join(binDir, name)))) {
fail(`${assetName} helpers tree is missing an executable ${helper} shim`);
}
}
}
function buildPlatformPackage(entry, version, artifactsDir, outDir) {
const assetName = `zero-v${version}-${entry.release}.${entry.ext}`;
const archivePath = join(artifactsDir, assetName);
if (!existsSync(archivePath)) fail(`missing release archive ${archivePath}`);
verifyChecksum(archivePath, assetName);
const binaryName = entry.platform === 'win32' ? 'zero.exe' : 'zero';
const tempDir = mkdtempSync(join(tmpdir(), 'zero-npm-'));
try {
extractArchive(archivePath, entry.ext, tempDir);
const sourceDir = extractedPackageDir(tempDir, `zero-v${version}-${entry.release}`, binaryName);
const packageDir = join(outDir, 'platforms', `zero-${entry.platform}-${entry.arch}`);
rmSync(packageDir, { recursive: true, force: true });
mkdirSync(packageDir, { recursive: true });
for (const item of readdirSync(sourceDir)) {
if (PLATFORM_PACKAGE_EXCLUDES.includes(item)) continue;
// verbatimSymlinks keeps relative link targets relative; the default
// rewrites them to absolute paths into this soon-to-be-deleted temp dir.
cpSync(join(sourceDir, item), join(packageDir, item), { recursive: true, verbatimSymlinks: true });
}
const binaryPath = join(packageDir, binaryName);
if (!existsSync(binaryPath)) fail(`${assetName} did not contain ${binaryName}`);
if (entry.platform !== 'win32') chmodSync(binaryPath, 0o755);
materializeSymlinks(packageDir);
pruneAgentBrowserBinaries(packageDir, entry);
verifyHelperShims(packageDir, entry, assetName);
// npm only ships a license file it finds in the packed directory;
// package.json's license field alone does not include the text.
cpSync(join(repoRoot, 'LICENSE'), join(packageDir, 'LICENSE'));
const wrapperPkg = readWrapperPackage();
const manifest = {
name: '@gitlawb/zero',
version: platformVersion(version, entry),
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
os: [entry.platform],
cpu: [entry.arch],
license: wrapperPkg.license,
repository: wrapperPkg.repository,
// No bin, no scripts, no dependencies: this payload is inert on its own;
// the wrapper package execs the binary out of it.
};
writeFileSync(join(packageDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n');
return packageDir;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function buildWrapperPackage(version, outDir) {
const wrapperDir = join(outDir, 'wrapper');
rmSync(wrapperDir, { recursive: true, force: true });
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
mkdirSync(join(wrapperDir, 'scripts'), { recursive: true });
cpSync(join(repoRoot, 'bin', 'zero.js'), join(wrapperDir, 'bin', 'zero.js'));
cpSync(join(repoRoot, 'scripts', 'postinstall.mjs'), join(wrapperDir, 'scripts', 'postinstall.mjs'));
cpSync(join(repoRoot, 'README.md'), join(wrapperDir, 'README.md'));
// npm auto-includes LICENSE in the tarball only when the file is present in
// the directory being packed (the files whitelist cannot exclude it).
cpSync(join(repoRoot, 'LICENSE'), join(wrapperDir, 'LICENSE'));
chmodSync(join(wrapperDir, 'bin', 'zero.js'), 0o755);
const pkg = readWrapperPackage();
// The published wrapper must stay warning-free on every package manager:
// no lifecycle scripts and no regular dependencies (the repo's dependencies
// entries exist only to pin the vendored helpers/ tree that zero-release
// stages into the platform payloads — see internal/release/release.go).
delete pkg.scripts;
delete pkg.dependencies;
pkg.optionalDependencies = optionalDependencies(version);
writeFileSync(join(wrapperDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
return wrapperDir;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const version = readWrapperPackage().version;
const selected = MATRIX.filter(
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
);
if (selected.length === 0) fail('--only matched no platforms');
if (args.only) {
for (const key of args.only) {
if (!MATRIX.some((entry) => `${entry.platform}-${entry.arch}` === key)) {
fail(`--only lists unknown platform ${key}`);
}
}
}
const built = [];
for (const entry of selected) {
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
}
const wrapperDir = buildWrapperPackage(version, args.outDir);
process.stdout.write(
JSON.stringify(
{
version,
wrapperDir,
platformDirs: built,
platformVersions: selected.map((entry) => platformVersion(version, entry)),
},
null,
2,
) + '\n',
);
}
main();