Files
ruflo/scripts/external-plugins.mjs
1af3abedd7 feat(marketplace): install Agentic QE (agentic-qe-fleet) from the ruflo marketplace (#3950)
* feat(marketplace): list Agentic QE (agentic-qe-fleet) as an external, pinned plugin

The ruflo marketplace now lists agentic-qe-fleet by a git-subdir source pinned
to agentic-qe v3.15.0 (1363bc7d), so `/plugin install agentic-qe-fleet@ruflo`
installs the plugin from its own repository: 11 QE agents, 9 skills, 9 commands,
the agentic-qe MCP server pinned to 3.15.0, and the aqe-mod guard.

Tooling that assumed every entry is a local ./plugins folder now handles an
external one:
- validate-marketplace.yml checks an external entry (git-subdir, https url,
  path without .., full sha, description) instead of failing on it
- smoke-all-plugins checks out each external entry at its sha and runs its own
  scripts/smoke.sh (--skip-external to omit); the workflow also runs when
  marketplace.json changes
- ruflo-console 0.40.9: plugin-map line (Dev Tools), the coverage test requires
  a line for every external entry, and the Plugin Catalog reads an external
  plugin from its install path in installed_plugins.json (EXT badge)
- README and plugins/README rows; ADR-484 records the decision and evidence

* docs(adr-484): record what Claude Code before 2.1.69 does with the external entry, and main's existing floor

* fix(ci): fence the external-plugin smoke: github-only sources, realpath containment, scrubbed env and cwd, read-only token

Review findings 1 and 2 on #3950:
- scripts/external-plugins.mjs: sources must be https://github.com/<owner>/<repo>(.git);
  the plugin folder, its manifest and smoke.sh must stay inside the checkout after realpath;
  the external smoke.sh runs with only PATH, LANG, TERM and CI kept (no GITHUB_TOKEN,
  ACTIONS_*, *_TOKEN, *_KEY, *SECRET*), a temporary HOME, and its checkout (in a temp
  folder outside the workspace) as cwd; git fetch runs with the same env
- validate-marketplace.yml applies the same github-only allowlist and path rule
- all-plugins-smoke.yml: permissions contents: read, persist-credentials: false,
  runs tests/external-plugins.test.mjs
- tests/external-plugins.test.mjs: non-github host and lookalikes refused by both
  validators, symlink escapes refused, child env has no GITHUB_TOKEN and cwd is outside
  the workspace

* ci(all-plugins-smoke): raise the smoke-all timeout from 10 to 15 minutes

Review finding 4 on #3950: green runs on main take 9m40-9m50s, so the job sat on the
10-minute cap and was cancelled at 10m16s on this PR.

* test(console): an external plugin's description, version, source line and docs are stripped of escapes and bidi controls

Review finding 3 on #3950: the catalog reader already passes every external manifest
string through plain() (ESC/CSI/OSC sequences, C0/C1 controls, U+202A-202E and
U+2066-2069 removed); this test holds it to that.

* ci(test-ratchet): tests/external-plugins.test.mjs runs under node --test, not the root vitest

all-plugins-smoke.yml runs it with node --test; vitest reported "No test suite found".

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.1 (4f0f8412)

agentic-qe v3.15.1 changes only the aqe-mod guard (#920: hardened, still tighten-only,
same engine API and events as 3.15.0) and pins agentic-qe@3.15.1 in .mcp.json.

* docs(adr-484): install evidence re-run at the v3.15.1 pin

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.2 (9db36edf)

agentic-qe v3.15.2: aqe-mod reports its real version, keeps guard totals and the last
refusal across sessions (additive totals/lastRefusal in status.json), and never reads or
writes its status file through a symlink (adds $.fs.read and $.fs.stat); guard judging
unchanged. .mcp.json pins agentic-qe@3.15.2. ADR-484 evidence re-run at this pin.

* fix(marketplace): plain-name paths for external plugins, pin-review note, one marketplace for agentic-qe-fleet

- both validators (scripts/external-plugins.mjs, validate-marketplace.yml) require the
  plugin path to match ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$ with no . or .. segment:
  %2e%2e, a\..\b, a//b and a trailing slash are refused; tests cover both validators
- ADR-484: every pin bump needs a review of the pinned scripts/smoke.sh
- READMEs: install agentic-qe-fleet from the agentic-qe or the ruflo marketplace, not both

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.3 (d7e5ffa7)

The plugin at v3.15.3 differs from v3.15.2 only in its version and its MCP
pin (agentic-qe@3.15.3). ADR-484's evidence records the install re-run at
the new pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.4 (bec02b13)

The plugin at v3.15.4 differs from v3.15.3 only in its version and its MCP
pin (agentic-qe@3.15.4). ADR-484's evidence records the install re-run at
the new pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.5 (d65ede14)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(console): leave the console version bump to the maintainer at merge

The console version and CHANGELOG entry collided with every console release on
main (four rebases on 2026-10-12, each only plugins/ruflo-console/CHANGELOG.md).
plugin.json, hooks/version.ts, scripts/smoke.sh and CHANGELOG.md now match
main; ADR-484 and its index row say 'next console release'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.6 (0fa34d24)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.7 (7f2d091d)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.8 (d9ce98f7)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.9 (d1c33d2b)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.10 (e5814a2f)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: rUv <ruv@ruv.net>
2026-10-19 22:55:58 -04:00

103 lines
5.4 KiB
JavaScript

// external-plugins — the checks and the sandboxing smoke-all-plugins applies to an external marketplace entry (ADR-484):
// a plugin listed by a git-subdir source pinned to a commit, maintained in another repository. Its smoke.sh is third-party
// code, so it runs with a scrubbed environment, a temporary HOME, and a working directory that holds only its own checkout.
// This is not a sandbox: the script can still read absolute paths. The workflow therefore also drops the job token's write
// scopes and keeps no credentials in the workspace (permissions: contents: read, persist-credentials: false).
//
// Tested by tests/external-plugins.test.mjs (node --test).
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
/** github.com repositories only: owner and repo are plain names; `.` and `..` are not names. */
const GITHUB_REPO_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
/** A relative folder of plain names: no leading or trailing slash, no empty segment, no `%`, `\\` or other punctuation. */
const PLUGIN_PATH = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
/** True for `https://github.com/<owner>/<repo>` with an optional `.git`, nothing before or after it. */
export function isAllowedRepoUrl(url) {
const match = typeof url === 'string' ? GITHUB_REPO_URL.exec(url) : null;
if (match === null) return false;
const repo = match[2].replace(/\.git$/, '');
return ![match[1], repo].some((part) => part === '' || part === '.' || part === '..');
}
/** Why an external marketplace source is refused, or null when it is a pinned github git-subdir. */
export function externalSourceProblem(source) {
const s = source ?? {};
if (s.source !== 'git-subdir') return 'source is not a git-subdir';
if (!isAllowedRepoUrl(s.url)) return 'url is not https://github.com/<owner>/<repo>';
if (typeof s.path !== 'string' || !PLUGIN_PATH.test(s.path) || s.path.split('/').some((part) => part === '..' || part === '.')) {
return 'path is not a relative folder of plain names without . or ..';
}
if (typeof s.sha !== 'string' || !/^[0-9a-f]{40}$/.test(s.sha)) return 'source does not pin a full 40-character commit sha';
return null;
}
const SECRET_NAME = /(^GITHUB_TOKEN$|^ACTIONS_|_TOKEN$|_KEY$|SECRET)/i;
const KEPT = ['PATH', 'LANG', 'TERM', 'CI'];
/** The environment an external smoke runs with: PATH, LANG, TERM and CI from `base`, HOME set to `home`, nothing else. */
export function scrubbedEnv(home, base = process.env) {
const env = { HOME: home };
for (const key of KEPT) if (typeof base[key] === 'string' && !SECRET_NAME.test(key)) env[key] = base[key];
return env;
}
/** `inner` resolved through links, when it stays inside `outer` (also resolved); null when it is missing or escapes. */
export function realpathInside(outer, inner) {
let root;
let target;
try {
root = realpathSync(outer);
target = realpathSync(inner);
} catch {
return null;
}
return target === root || target.startsWith(root + sep) ? target : null;
}
/** A fresh work area outside the repository: `checkout/` for the plugin's repository and `home/` for its HOME. */
export function makeWorkArea(parent = tmpdir()) {
const dir = mkdtempSync(join(parent, 'smoke-external-'));
const checkout = join(dir, 'checkout');
const home = join(dir, 'home');
mkdirSync(checkout);
mkdirSync(home);
return { dir, checkout, home };
}
/** Fetches `sha` of `url` into `checkout` (shallow, blobless) with the scrubbed env; null on success, else why it failed. */
export function checkoutAt(url, sha, checkout, home, timeoutMs) {
const env = { ...scrubbedEnv(home), GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' };
for (const args of [['init', '-q'], ['remote', 'add', 'origin', url], ['fetch', '-q', '--depth', '1', '--filter=blob:none', 'origin', sha], ['checkout', '-q', 'FETCH_HEAD']]) {
const r = spawnSync('git', ['-C', checkout, ...args], { encoding: 'utf8', timeout: timeoutMs, env });
if (r.status !== 0) return `git ${args[0]} failed: ${(r.stderr || r.error?.message || '').trim().slice(0, 200)}`;
}
return null;
}
/**
* How to run the plugin's smoke inside a checkout: its manifest must carry `name`, the plugin folder and smoke.sh must stay
* inside the checkout after links are resolved. Returns { problem } or { smoke: null } (no smoke.sh) or { smoke, cwd, env }, the env
* scrubbed from `base` (the caller's environment).
*/
export function externalSmokePlan(checkout, home, path, name, base = process.env) {
const root = realpathInside(checkout, join(checkout, path));
if (root === null) return { problem: `${path} is missing or resolves outside the checkout` };
const manifestPath = realpathInside(checkout, join(root, '.claude-plugin', 'plugin.json'));
let manifest = null;
try {
if (manifestPath !== null && statSync(manifestPath).size <= 1_000_000) manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch { /* reported below */ }
if (manifest?.name !== name) return { problem: `plugin.json at ${path} does not name ${name}` };
const smokePath = join(root, 'scripts', 'smoke.sh');
if (!existsSync(smokePath)) return { smoke: null };
const smoke = realpathInside(checkout, smokePath);
if (smoke === null) return { problem: 'scripts/smoke.sh resolves outside the checkout' };
return { smoke, cwd: realpathSync(checkout), env: scrubbedEnv(home, base) };
}