Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/install-preflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: install-preflight

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

permissions:
contents: read

# Cancel superseded runs on the same ref. Keeps resource use linear
# with the number of open PRs, not the number of pushes per PR.
concurrency:
group: install-preflight-${{ github.ref }}
cancel-in-progress: true

jobs:
install-preflight:
# Security: the job installs ~450 npm packages (~54 MB across 7,500
# files) and takes a few minutes per run. On a public repo that
# makes it an attractive DoS vector via fork PRs. Skip the job
# when the PR originates from a fork — a maintainer can instead
# run `bun run install-preflight` locally against the fork's code
# (or merge into a same-repo branch) to exercise the real check.
# Same-repo branches and direct pushes are unaffected.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# Hard wall-time cap so a hung npm install or stuck openclaw CLI
# can't sit on a runner. Normal runs finish in ~1–2 minutes.
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

# Pin Node 24 (current LTS) because openclaw@latest requires Node
# >=22.14 — GitHub's ubuntu-latest runners still ship Node 20 by
# default, so without this step the openclaw CLI refuses to run
# ("Node.js v22.12+ is required"). Node 24 also matches the
# wider KiloCode ecosystem engines field and covers any near-term
# openclaw bumps. bun has its own runtime and is independent.
- uses: actions/setup-node@v4
with:
node-version: "24"

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dev dependencies
run: bun install --frozen-lockfile

# Packs this plugin, installs openclaw@latest in a throwaway
# dir, and runs `openclaw plugins install <tarball>` — the same
# real path end users hit. Exits non-zero on any scanner /
# denylist rejection. Never add bypass flags (--dangerously-
# force-unsafe-install, --force, etc.) here or in the script; a
# rejection means the plugin source needs fixing, not the check.
- name: OpenClaw install preflight
run: bun run install-preflight
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- CI `install-preflight` workflow that packs this plugin into its
real publish shape (`bun pm pack`) and runs it through
`openclaw plugins install <tarball>` against the current
`openclaw@latest` from npm. Exercises the same install path an
end user hits, including the real scanner and dependency denylist
— no regex reimplementation and no force/bypass flags. Drift
against upstream rule changes surfaces on the next PR automatically.
Runs on push to `main`, pull requests, and manual dispatch.
Locally reproducible via `bun run install-preflight`.

### Changed

- Extracted `resolveEnvToken()` and `resolveApiBase()` out of
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,json,md,yml}\"",
"format:check": "prettier --check \"**/*.{ts,json,md,yml}\"",
"install-preflight": "bun run script/install-preflight.ts",
"scan": "bun run script/scan.ts",
"test": "bun test"
},
Expand Down
124 changes: 124 additions & 0 deletions script/install-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bun

/**
* CI preflight that runs @kilocode/shell-security through the REAL
* OpenClaw install path — the same code path an end user hits when
* they type `openclaw plugins install @kilocode/shell-security`.
*
* Steps:
* 1. Pack this plugin into a tarball (strips `private: true` the
* same way script/publish.ts does, then restores it).
* 2. Install `openclaw@latest` into a throwaway node_modules.
* 3. Run `openclaw plugins install <tarball>` against a throwaway
* OPENCLAW_STATE_DIR.
* 4. Exit with whatever exit code `openclaw plugins install` gave
* us. Non-zero = scanner/denylist rejection = CI fails.
*
* NO bypass flags. Never add `--dangerously-force-unsafe-install`,
* `--force`, `--skip-scan`, or any other "ignore the safety gate"
* option to this script. The whole point is to exercise the real
* gate. If the gate rejects the plugin, the fix is in the plugin
* source (see src/env.ts for the project's env-isolation convention)
* — never in this script.
*
* `OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1` below is NOT a
* bypass flag. It skips openclaw's postinstall step that sets up
* compat sidecars for bundled channel plugins (Telegram, Slack,
* Matrix, etc.) used by the gateway itself — none of which is
* relevant to `plugins install`. The scanner code path this script
* exercises is unaffected. Removing it just makes CI slower.
*
* Run locally: `bun run install-preflight`
* CI: `.github/workflows/install-preflight.yml`
*/

import { $ } from "bun";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = fileURLToPath(new URL("..", import.meta.url));
process.chdir(repoRoot);

const pkgPath = path.join(repoRoot, "package.json");
const originalPkgText = await fs.readFile(pkgPath, "utf8");

// The finally block restores package.json and cleans up artifacts. It
// is critical that ANY mutation of pkgPath happens inside the try so
// the restore is always armed, and that we NEVER call `process.exit()`
// inside the try — that would terminate the process immediately and
// skip finally, leaving `private: true` stripped permanently (which
// is exactly the publish-safety regression this script must not cause).
// Instead we set `process.exitCode` at the bottom and let the event
// loop drain normally.
let tarball: string | undefined;
let ciTmp: string | undefined;
let openclawExit = 0;
try {
// --- Pack --------------------------------------------------------
const pkg = JSON.parse(originalPkgText);
delete pkg.private;
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");

// `bun pm pack --quiet` emits only the tarball filename.
tarball = (await $`bun pm pack --quiet`.text()).trim();
if (!tarball) throw new Error("bun pm pack produced no tarball");
console.log(`Packed: ${tarball}`);

// --- Install openclaw --------------------------------------------
ciTmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-preflight-"));
const stateDir = path.join(ciTmp, "state");
const nodeRoot = path.join(ciTmp, "node");
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(nodeRoot, { recursive: true });

const latest = (await $`npm view openclaw dist-tags.latest`.text()).trim();
console.log(`Resolved openclaw@${latest}`);

// Minimal scratch package so npm has somewhere to land node_modules.
await fs.writeFile(
path.join(nodeRoot, "package.json"),
JSON.stringify(
{ name: "openclaw-preflight-scratch", private: true },
null,
2,
) + "\n",
);

process.env.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL = "1";
await $`npm install --prefix ${nodeRoot} --no-save openclaw@${latest}`;

// --- Run the real install ----------------------------------------
process.env.OPENCLAW_STATE_DIR = stateDir;
const tarballAbs = path.resolve(repoRoot, tarball);
const cli = path.join(nodeRoot, "node_modules", ".bin", "openclaw");
console.log(`Running: ${cli} plugins install ${tarballAbs}`);
const result = await $`${cli} plugins install ${tarballAbs}`.nothrow();

if (result.exitCode !== 0) {
console.error(
`\nFAIL: openclaw plugins install exited ${result.exitCode}. See output above.`,
);
console.error(
"If this is an env-harvesting or other scanner rejection, the fix is in the plugin source (see src/env.ts for the project's env-isolation convention). Do NOT add a bypass flag to this script.",
);
openclawExit = result.exitCode;
} else {
console.log("\nOK: openclaw plugins install succeeded");
}
} finally {
// Always restore package.json (restores `private: true`), always
// clean up the tarball, always clean up the scratch openclaw install
// dir so repeated local runs don't accumulate ~400MB of node_modules
// under os.tmpdir().
await fs.writeFile(pkgPath, originalPkgText);
if (tarball) {
await $`rm -f ${tarball}`.nothrow();
}
if (ciTmp) {
await $`rm -rf ${ciTmp}`.nothrow();
}
}

process.exitCode = openclawExit;
Loading