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>
This commit is contained in:
authored and GitHub committed 2026-10-19 22:55:58 -04:00
1 parent 663c58a091
commit 1af3abedd7
17 files changed
+587 -28

No files matched your search

+11
View File
@@ -51,6 +51,17 @@
"source": "./plugins/ruflo-testgen", "source": "./plugins/ruflo-testgen",
"description": "Test gap detection, coverage analysis, and automated test generation" "description": "Test gap detection, coverage analysis, and automated test generation"
}, },
{
"name": "agentic-qe-fleet",
"source": {
"source": "git-subdir",
"url": "https://github.com/proffesor-for-testing/agentic-qe.git",
"path": "plugins/agentic-qe-fleet",
"ref": "v3.15.10",
"sha": "e5814a2f4f3d7995b87ebda9ad30e8a3d92cd555"
},
"description": "Agentic QE (external: maintained in proffesor-for-testing/agentic-qe, pinned to v3.15.10): a quality-engineering fleet with 11 QE agents, 9 skills, 9 commands, the agentic-qe MCP server pinned to the plugin version, and the aqe-mod guard that refuses destructive operations on AQE learning data"
},
{ {
"name": "ruflo-docs", "name": "ruflo-docs",
"source": "./plugins/ruflo-docs", "source": "./plugins/ruflo-docs",
+19 -1
View File
@@ -16,7 +16,11 @@ on:
branches: [main] branches: [main]
paths: paths:
- 'plugins/**' - 'plugins/**'
- '.claude-plugin/marketplace.json'
- 'scripts/smoke-all-plugins.mjs' - 'scripts/smoke-all-plugins.mjs'
- 'scripts/external-plugins.mjs'
- 'tests/external-plugins.test.mjs'
- '.github/workflows/validate-marketplace.yml'
- 'scripts/audit-plugin-mcp-prefix.mjs' - 'scripts/audit-plugin-mcp-prefix.mjs'
- 'scripts/audit-skill-mcp-tools.mjs' - 'scripts/audit-skill-mcp-tools.mjs'
- 'verification/skill-mcp-tool-baseline.json' - 'verification/skill-mcp-tool-baseline.json'
@@ -33,7 +37,11 @@ on:
pull_request: pull_request:
paths: paths:
- 'plugins/**' - 'plugins/**'
- '.claude-plugin/marketplace.json'
- 'scripts/smoke-all-plugins.mjs' - 'scripts/smoke-all-plugins.mjs'
- 'scripts/external-plugins.mjs'
- 'tests/external-plugins.test.mjs'
- '.github/workflows/validate-marketplace.yml'
- 'scripts/audit-plugin-mcp-prefix.mjs' - 'scripts/audit-plugin-mcp-prefix.mjs'
- 'scripts/audit-skill-mcp-tools.mjs' - 'scripts/audit-skill-mcp-tools.mjs'
- 'verification/skill-mcp-tool-baseline.json' - 'verification/skill-mcp-tool-baseline.json'
@@ -49,12 +57,19 @@ on:
- '.github/workflows/all-plugins-smoke.yml' - '.github/workflows/all-plugins-smoke.yml'
workflow_dispatch: workflow_dispatch:
# The external-plugin smoke runs third-party code (scripts/external-plugins.mjs): the token can only read, and no checkout
# keeps credentials in the workspace.
permissions:
contents: read
jobs: jobs:
smoke-all: smoke-all:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@@ -64,6 +79,9 @@ jobs:
run: | run: |
npm install --prefix "$RUNNER_TEMP/esbuild" --no-audit --no-fund --no-package-lock esbuild@0.25.0 yaml@2.8.0 npm install --prefix "$RUNNER_TEMP/esbuild" --no-audit --no-fund --no-package-lock esbuild@0.25.0 yaml@2.8.0
echo "ESBUILD=$RUNNER_TEMP/esbuild/node_modules/.bin/esbuild" >> "$GITHUB_ENV" echo "ESBUILD=$RUNNER_TEMP/esbuild/node_modules/.bin/esbuild" >> "$GITHUB_ENV"
- name: External plugin checks (github-only source, realpath containment, scrubbed env and cwd)
run: node --test tests/external-plugins.test.mjs
- name: Discover and run every plugin smoke contract (parallel) - name: Discover and run every plugin smoke contract (parallel)
# --timeout 300 caps each individual plugin smoke at 5 minutes. # --timeout 300 caps each individual plugin smoke at 5 minutes.
# iter 119 — bumped from 60s because ruflo-metaharness has grown # iter 119 — bumped from 60s because ruflo-metaharness has grown
@@ -25,6 +25,20 @@ jobs:
console.log('Marketplace:', catalog.name); console.log('Marketplace:', catalog.name);
console.log('Plugins:', catalog.plugins.length); console.log('Plugins:', catalog.plugins.length);
for (const p of catalog.plugins) { for (const p of catalog.plugins) {
// An external plugin (maintained in another repository) is listed by a git source pinned to a commit:
// its files are not in this repository, so only the entry itself is checked here; smoke-all-plugins
// checks out the pinned commit and runs the plugin's own smoke.
if (typeof p.source !== 'string') {
const s = p.source || {};
// Same rules as scripts/external-plugins.mjs: github.com/<owner>/<repo> only, a relative path of plain names without . or ..
const m = typeof s.url === 'string' ? /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(s.url) : null;
if (s.source !== 'git-subdir' || m === null || [m[1], m[2].replace(/\.git$/, '')].some((x) => x === '' || x === '.' || x === '..')) throw new Error('External plugin ' + p.name + ': source must be a git-subdir with url https://github.com/<owner>/<repo>');
if (typeof s.path !== 'string' || !/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(s.path) || s.path.split('/').some((x) => x === '..' || x === '.')) throw new Error('External plugin ' + p.name + ': path must be a relative folder of plain names without . or ..');
if (typeof s.sha !== 'string' || s.sha.length !== 40 || /[^0-9a-f]/.test(s.sha)) throw new Error('External plugin ' + p.name + ': source must pin a full 40-character commit sha');
if (!p.description) throw new Error('External plugin ' + p.name + ': missing description');
console.log(' OK', p.name, '(external', s.url, s.path + '@' + (s.ref || s.sha) + ')');
continue;
}
const dir = p.source; const dir = p.source;
if (!fs.existsSync(dir)) throw new Error('Missing plugin dir: ' + dir); if (!fs.existsSync(dir)) throw new Error('Missing plugin dir: ' + dir);
const manifest = dir + '/.claude-plugin/plugin.json'; const manifest = dir + '/.claude-plugin/plugin.json';
+1
View File
@@ -240,6 +240,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
| [**ruflo-browser**](plugins/ruflo-browser/README.md) | Automate browser testing with Playwright | | [**ruflo-browser**](plugins/ruflo-browser/README.md) | Automate browser testing with Playwright |
| [**ruflo-jujutsu**](plugins/ruflo-jujutsu/README.md) | Analyze git diffs, score risk, suggest reviewers | | [**ruflo-jujutsu**](plugins/ruflo-jujutsu/README.md) | Analyze git diffs, score risk, suggest reviewers |
| [**ruflo-docs**](plugins/ruflo-docs/README.md) | Generate and maintain documentation automatically | | [**ruflo-docs**](plugins/ruflo-docs/README.md) | Generate and maintain documentation automatically |
| [**agentic-qe-fleet**](https://github.com/proffesor-for-testing/agentic-qe/tree/v3.15.10/plugins/agentic-qe-fleet) | External, maintained by [Agentic QE](https://github.com/proffesor-for-testing/agentic-qe): 11 QE agents, test generation, coverage, chaos and TDD skills, the `agentic-qe` MCP server, and a guard for AQE learning data (`/plugin install agentic-qe-fleet@ruflo`). Install it from one marketplace only, `agentic-qe` or `ruflo`: enabled from both, its mod and commands load twice |
#### Security & Compliance #### Security & Compliance
+1
View File
@@ -53,6 +53,7 @@ claude $(ls -d plugins/ruflo-*/ | sed 's|^|--plugin-dir |' | tr '\n' ' ')
| [ruflo-protector](ruflo-protector/) | Project Anatole: optional learning watchdog for unattended agents (default off) | | [ruflo-protector](ruflo-protector/) | Project Anatole: optional learning watchdog for unattended agents (default off) |
| [ruflo-testgen](ruflo-testgen/) | Test gap detection, TDD London School workflow | | [ruflo-testgen](ruflo-testgen/) | Test gap detection, TDD London School workflow |
| [ruflo-browser](ruflo-browser/) | Playwright browser automation and testing | | [ruflo-browser](ruflo-browser/) | Playwright browser automation and testing |
| [agentic-qe-fleet](https://github.com/proffesor-for-testing/agentic-qe/tree/v3.15.10/plugins/agentic-qe-fleet) | External ([Agentic QE](https://github.com/proffesor-for-testing/agentic-qe), pinned in the marketplace): QE agents, coverage, chaos, TDD skills, `agentic-qe` MCP server. Install with `/plugin install agentic-qe-fleet@ruflo`; not in this folder, so `--plugin-dir` does not load it. Install it from one marketplace only (`agentic-qe` or `ruflo`), not both: enabled from both, its mod and commands load twice |
### Development Tools ### Development Tools
@@ -1,7 +1,9 @@
/** /**
* The ruflo marketplace as a catalog: every plugin its clone lists, with the skills, agents, commands, MCP config and * The ruflo marketplace as a catalog: every plugin its clone lists, with the skills, agents, commands, MCP config and
* mod (function hooks) each ships, read from the clone on disk. Read-only, bounded, nothing run: a file over the cap, * mod (function hooks) each ships, read from the clone on disk. An external plugin (listed by a git source, maintained in
* a name that is not a plain word and a path with `..` are skipped, and every read may be refused. * another repository) has no copy in the clone: it is read from its install path in `installed_plugins.json` when it is
* installed. Read-only, bounded, nothing run: a file over the cap, a name that is not a plain word and a path with `..`
* are skipped, and every read may be refused.
*/ */
import type { ReaderFs } from './files' import type { ReaderFs } from './files'
import { plain } from './parse' import { plain } from './parse'
@@ -10,7 +12,7 @@ export type CatalogPlugin = {
name: string name: string
description: string description: string
version: string | null version: string | null
/** Folder of the plugin inside the clone (absolute). */ /** Folder of the plugin inside the clone, or of the installed copy of an external one (absolute); '' when there is neither. */
dir: string dir: string
skills: string[] skills: string[]
agents: string[] agents: string[]
@@ -20,6 +22,8 @@ export type CatalogPlugin = {
options: string[] options: string[]
/** A `hooks/register.ts` makes it a mod: function hooks run in the engine. */ /** A `hooks/register.ts` makes it a mod: function hooks run in the engine. */
isMod: boolean isMod: boolean
/** Set for an external plugin: the repository, folder and ref the marketplace pins it to. */
external?: string
} }
const NAME = /^[A-Za-z0-9._-]{1,80}$/ const NAME = /^[A-Za-z0-9._-]{1,80}$/
@@ -48,15 +52,34 @@ async function namesIn(fs: ReaderFs, dir: string, kind: 'dir' | 'md'): Promise<s
.slice(0, LIST_MAX) .slice(0, LIST_MAX)
} }
/** An external source (`git-subdir`, `github`, `git`, `url`) as one plain line, or null for anything else. */
function externalOf(source: unknown): string | null {
const record = recordOf(source)
const at = (value: unknown) => (typeof value === 'string' ? value : '')
if (record === null || !['git-subdir', 'github', 'git', 'url'].includes(at(record.source))) return null
const where = [at(record.url) || at(record.repo), at(record.path)].filter(Boolean).join(' ')
const ref = at(record.ref) || at(record.sha).slice(0, 12)
return where === '' ? null : plain(`${where}${ref === '' ? '' : ` @ ${ref}`}`, 200)
}
const exists = async (fs: ReaderFs, path: string): Promise<boolean> => (await fs.stat(path).catch(() => undefined)) !== undefined const exists = async (fs: ReaderFs, path: string): Promise<boolean> => (await fs.stat(path).catch(() => undefined)) !== undefined
async function pluginOf(fs: ReaderFs, location: string, entry: Record<string, unknown>): Promise<CatalogPlugin | null> { async function pluginOf(fs: ReaderFs, location: string, entry: Record<string, unknown>, installed: ReadonlyMap<string, string>): Promise<CatalogPlugin | null> {
const name = entry.name const name = entry.name
const source = safeSource(entry.source) const source = safeSource(entry.source)
const external = source === null ? externalOf(entry.source) : null
if (typeof name !== 'string' || !NAME.test(name) || source === null) return null if (typeof name !== 'string' || !NAME.test(name) || (source === null && external === null)) return null
const dir = source !== null ? `${location}/${source}` : (installed.get(name) ?? '')
if (dir === '') {
return { name, options: [], description: plain(typeof entry.description === 'string' ? entry.description : '', 400), version: null, dir, skills: [], agents: [], commands: [], hasMcp: false, isMod: false, ...(external !== null && { external }) }
}
const dir = `${location}/${source}`
const manifest = await fs.read(`${dir}/.claude-plugin/plugin.json`).then( const manifest = await fs.read(`${dir}/.claude-plugin/plugin.json`).then(
text => recordOf(JSON.parse(text)), text => recordOf(JSON.parse(text)),
() => null, () => null,
@@ -66,11 +89,14 @@ async function pluginOf(fs: ReaderFs, location: string, entry: Record<string, un
const options = Object.keys(recordOf(manifest?.userConfig) ?? {}).filter(key => NAME.test(key)).slice(0, 60) const options = Object.keys(recordOf(manifest?.userConfig) ?? {}).filter(key => NAME.test(key)).slice(0, 60)
return { name, options, description: plain(typeof entry.description === 'string' ? entry.description : typeof manifest?.description === 'string' ? manifest.description : '', 400), version, dir, skills, agents, commands, hasMcp, isMod } return { name, options, description: plain(typeof entry.description === 'string' ? entry.description : typeof manifest?.description === 'string' ? manifest.description : '', 400), version, dir, skills, agents, commands, hasMcp, isMod, ...(external !== null && { external }) }
} }
/** Every plugin the marketplace clone at `location` lists, or null when its manifest cannot be read. */ /**
export async function readCatalog(fs: ReaderFs, location: string): Promise<CatalogPlugin[] | null> { * Every plugin the marketplace clone at `location` lists, or null when its manifest cannot be read. `installed` maps a plugin name to
* its install path (absolute, already checked by the caller): an external plugin is read from there.
*/
export async function readCatalog(fs: ReaderFs, location: string, installed: ReadonlyMap<string, string> = new Map()): Promise<CatalogPlugin[] | null> {
if (!location.startsWith('/') || location.split('/').includes('..')) return null if (!location.startsWith('/') || location.split('/').includes('..')) return null
const raw = await fs.read(`${location}/.claude-plugin/marketplace.json`).catch(() => null) const raw = await fs.read(`${location}/.claude-plugin/marketplace.json`).catch(() => null)
@@ -94,7 +120,7 @@ export async function readCatalog(fs: ReaderFs, location: string): Promise<Catal
list.slice(at, at + 8).map(async entry => { list.slice(at, at + 8).map(async entry => {
const record = recordOf(entry) const record = recordOf(entry)
return record === null ? null : pluginOf(fs, location, record).catch(() => null) return record === null ? null : pluginOf(fs, location, record, installed).catch(() => null)
}), }),
) )
@@ -106,7 +132,7 @@ export async function readCatalog(fs: ReaderFs, location: string): Promise<Catal
/** A skill, agent or command file's first lines (and its frontmatter description), or null when it cannot be read. */ /** A skill, agent or command file's first lines (and its frontmatter description), or null when it cannot be read. */
export async function readDoc(fs: ReaderFs, plugin: CatalogPlugin, kind: 'skill' | 'agent' | 'command', name: string): Promise<{ description: string; lines: string[] } | null> { export async function readDoc(fs: ReaderFs, plugin: CatalogPlugin, kind: 'skill' | 'agent' | 'command', name: string): Promise<{ description: string; lines: string[] } | null> {
if (!NAME.test(name)) return null if (!NAME.test(name) || plugin.dir === '') return null
const path = kind === 'skill' ? `${plugin.dir}/skills/${name}/SKILL.md` : `${plugin.dir}/${kind === 'agent' ? 'agents' : 'commands'}/${name}.md` const path = kind === 'skill' ? `${plugin.dir}/skills/${name}/SKILL.md` : `${plugin.dir}/${kind === 'agent' ? 'agents' : 'commands'}/${name}.md`
const stat = await fs.stat(path).catch(() => undefined) const stat = await fs.stat(path).catch(() => undefined)
+11 -1
View File
@@ -8,6 +8,7 @@
*/ */
import type { ActionSpec } from './actions' import type { ActionSpec } from './actions'
import { type CatalogMode, type CatalogPlugin, readCatalog, readDoc, visible } from './data/plugin-catalog' import { type CatalogMode, type CatalogPlugin, readCatalog, readDoc, visible } from './data/plugin-catalog'
import { safeInstallPath } from './data/cost-ledger'
import { plain } from './data/parse' import { plain } from './data/parse'
import { RUFLO_MARKET } from './data/snapshot' import { RUFLO_MARKET } from './data/snapshot'
import type { Host } from './host' import type { Host } from './host'
@@ -131,7 +132,16 @@ export function loadCatalog(state: State, host: Host): void {
catalog.loading = true catalog.loading = true
host.invalidate() host.invalidate()
void readCatalog(host.fs, market.location) // Where each installed ruflo plugin lives: an external one (no copy in the clone) is read from there.
const installed = new Map(
(state.snapshot?.plugins.installed ?? []).flatMap(plugin => {
const path = plugin.marketplace === RUFLO_MARKET ? safeInstallPath(plugin.installPath) : undefined
return path === undefined ? [] : [[plugin.name, path] as const]
}),
)
void readCatalog(host.fs, market.location, installed)
.then(plugins => { .then(plugins => {
catalog.plugins = plugins catalog.plugins = plugins
catalog.why = plugins === null ? 'the clone’s marketplace.json is not readable' : plugins.length === 0 ? 'the clone lists no plugins' : '' catalog.why = plugins === null ? 'the clone’s marketplace.json is not readable' : plugins.length === 0 ? 'the clone lists no plugins' : ''
+4 -3
View File
@@ -1,8 +1,8 @@
/** /**
* Which console section owns each ruflo plugin: the section that launches its slash commands (the Launch row) and answers * Which console section owns each ruflo plugin: the section that launches its slash commands (the Launch row) and answers
* "ask Claude" about it. Every plugin directory under `plugins/` appears here, and a test fails when one is added without a * "ask Claude" about it. Every plugin directory under `plugins/`, and every external plugin the marketplace lists by a git
* line, so no plugin is ever unreachable from the console. A plugin with no section of its own says why (`catalogOnly`) and is * source, appears here, and a test fails when one is added without a line, so no plugin is ever unreachable from the console.
* reached from the Plugin Catalog. Pure data. * A plugin with no section of its own says why (`catalogOnly`) and is reached from the Plugin Catalog. Pure data.
*/ */
import type { ViewId } from './state' import type { ViewId } from './state'
@@ -12,6 +12,7 @@ const home = (view: ViewId): PluginHome => ({ view })
const catalog = (why: string): PluginHome => ({ view: 'market', catalogOnly: why }) const catalog = (why: string): PluginHome => ({ view: 'market', catalogOnly: why })
export const PLUGIN_MAP: Readonly<Record<string, PluginHome>> = { export const PLUGIN_MAP: Readonly<Record<string, PluginHome>> = {
'agentic-qe-fleet': home('devtools'),
'ruflo-adr': home('devtools'), 'ruflo-adr': home('devtools'),
'ruflo-agent': home('swarm'), 'ruflo-agent': home('swarm'),
'ruflo-agentdb': home('memory'), 'ruflo-agentdb': home('memory'),
@@ -15,7 +15,7 @@ function verbOf(ctx: Ctx, plugin: CatalogPlugin): { verb: Verb; label: string }
} }
function counts(plugin: CatalogPlugin): string { function counts(plugin: CatalogPlugin): string {
return [plugin.skills.length > 0 ? `S${plugin.skills.length}` : '', plugin.agents.length > 0 ? `A${plugin.agents.length}` : '', plugin.commands.length > 0 ? `C${plugin.commands.length}` : '', plugin.hasMcp ? 'MCP' : '', plugin.isMod ? 'MOD' : ''].filter(Boolean).join(' ') return [plugin.skills.length > 0 ? `S${plugin.skills.length}` : '', plugin.agents.length > 0 ? `A${plugin.agents.length}` : '', plugin.commands.length > 0 ? `C${plugin.commands.length}` : '', plugin.hasMcp ? 'MCP' : '', plugin.isMod ? 'MOD' : '', plugin.external !== undefined ? 'EXT' : ''].filter(Boolean).join(' ')
} }
/** One plugin row: the whole row selects it, the trailing button changes it. */ /** One plugin row: the whole row selects it, the trailing button changes it. */
@@ -71,6 +71,10 @@ function detailRows(ctx: Ctx, plugin: CatalogPlugin): RenderElement[] {
row(ctx, [text(ctx, ' related ', { dimColor: true }), ...(home === null ? [] : [button(ctx, `cat-home-${plugin.name}`, `→ ${home.label}: its commands (Launch)`, () => ctx.act.view(home.view))]), button(ctx, `cat-health-${plugin.name}`, '→ Plugins: setup health', () => ctx.act.view('plugins'))], `cat-related-${plugin.name}`), row(ctx, [text(ctx, ' related ', { dimColor: true }), ...(home === null ? [] : [button(ctx, `cat-home-${plugin.name}`, `→ ${home.label}: its commands (Launch)`, () => ctx.act.view(home.view))]), button(ctx, `cat-health-${plugin.name}`, '→ Plugins: setup health', () => ctx.act.view('plugins'))], `cat-related-${plugin.name}`),
) )
if (plugin.external !== undefined) {
rows.push(text(ctx, ` EXTERNAL: maintained outside ruflo, installed from ${plugin.external}`, { color: THEME.warn }))
if (plugin.dir === '') rows.push(text(ctx, ' its skills, agents and commands are listed once it is installed (the ruflo clone holds no copy)', { dimColor: true }))
}
if (plugin.isMod) rows.push(text(ctx, ' MOD: function hooks run in the engine once this plugin is loaded (trust it like code you run)', { color: THEME.warn })) if (plugin.isMod) rows.push(text(ctx, ' MOD: function hooks run in the engine once this plugin is loaded (trust it like code you run)', { color: THEME.warn }))
if (plugin.hasMcp) rows.push(text(ctx, ' MCP: ships an .mcp.json: its servers start with the plugin', { dimColor: true })) if (plugin.hasMcp) rows.push(text(ctx, ' MCP: ships an .mcp.json: its servers start with the plugin', { dimColor: true }))
@@ -4,6 +4,8 @@ import { readCatalog, readDoc, visible, type CatalogPlugin } from '../hooks/data
import type { ReaderFs } from '../hooks/data/files' import type { ReaderFs } from '../hooks/data/files'
const ROOT = '/h/.claude/plugins/marketplaces/ruflo' const ROOT = '/h/.claude/plugins/marketplaces/ruflo'
const CACHE = '/h/.claude/plugins/cache/ruflo'
const GIT = { source: 'git-subdir', url: 'https://example.com/qe.git', path: 'plugins/qe', ref: 'v2.0.0', sha: 'a'.repeat(40) }
const files: Record<string, string> = { const files: Record<string, string> = {
[`${ROOT}/.claude-plugin/marketplace.json`]: JSON.stringify({ [`${ROOT}/.claude-plugin/marketplace.json`]: JSON.stringify({
plugins: [ plugins: [
@@ -11,6 +13,9 @@ const files: Record<string, string> = {
{ name: 'b-mod', source: './plugins/b-mod', description: 'A mod' }, { name: 'b-mod', source: './plugins/b-mod', description: 'A mod' },
{ name: 'evil', source: './../../etc', description: 'climbs out' }, { name: 'evil', source: './../../etc', description: 'climbs out' },
{ name: 'bad name!', source: './plugins/x', description: 'not a word' }, { name: 'bad name!', source: './plugins/x', description: 'not a word' },
{ name: 'ext-installed', source: GIT, description: 'An external plugin, installed' },
{ name: 'ext-missing', source: { ...GIT, path: 'plugins/other' }, description: 'An external plugin, not installed' },
{ name: 'ext-odd', source: { source: 'npm', package: 'x' }, description: 'a source kind it does not read' },
], ],
}), }),
[`${ROOT}/plugins/a-plugin/.claude-plugin/plugin.json`]: '{"version":"1.2.3"}', [`${ROOT}/plugins/a-plugin/.claude-plugin/plugin.json`]: '{"version":"1.2.3"}',
@@ -19,6 +24,10 @@ const files: Record<string, string> = {
[`${ROOT}/plugins/a-plugin/commands/go.md`]: 'x', [`${ROOT}/plugins/a-plugin/commands/go.md`]: 'x',
[`${ROOT}/plugins/a-plugin/.mcp.json`]: '{}', [`${ROOT}/plugins/a-plugin/.mcp.json`]: '{}',
[`${ROOT}/plugins/b-mod/hooks/register.ts`]: 'x', [`${ROOT}/plugins/b-mod/hooks/register.ts`]: 'x',
[`${CACHE}/ext-installed/2.0.0/.claude-plugin/plugin.json`]: '{"version":"2.0.0","userConfig":{"guardMode":{}}}',
[`${CACHE}/ext-installed/2.0.0/commands/qe-go.md`]: '---\ndescription: Go\n---\n',
[`${CACHE}/ext-installed/2.0.0/.mcp.json`]: '{}',
[`${CACHE}/ext-installed/2.0.0/hooks/register.ts`]: 'x',
} }
const fs: ReaderFs = { const fs: ReaderFs = {
@@ -38,14 +47,26 @@ const fs: ReaderFs = {
} }
describe('plugin catalog reader', () => { describe('plugin catalog reader', () => {
it('lists each plugin with what it ships, skipping a climbing source and a non-word name', async () => { it('lists each plugin with what it ships, skipping a climbing source, a non-word name and a source kind it does not read', async () => {
const plugins = (await readCatalog(fs, ROOT)) as CatalogPlugin[] const plugins = (await readCatalog(fs, ROOT)) as CatalogPlugin[]
expect(plugins.map(plugin => plugin.name)).toEqual(['a-plugin', 'b-mod']) expect(plugins.map(plugin => plugin.name)).toEqual(['a-plugin', 'b-mod', 'ext-installed', 'ext-missing'])
expect(plugins[0]).toMatchObject({ version: '1.2.3', skills: ['alpha'], agents: ['helper'], commands: ['go'], hasMcp: true, isMod: false }) expect(plugins[0]).toMatchObject({ version: '1.2.3', skills: ['alpha'], agents: ['helper'], commands: ['go'], hasMcp: true, isMod: false })
expect(plugins[0]?.external).toBeUndefined()
expect(plugins[1]).toMatchObject({ isMod: true, skills: [] }) expect(plugins[1]).toMatchObject({ isMod: true, skills: [] })
}) })
it('reads an external plugin from its install path, and lists one that is not installed by its entry alone', async () => {
const plugins = (await readCatalog(fs, ROOT, new Map([['ext-installed', `${CACHE}/ext-installed/2.0.0`]]))) as CatalogPlugin[]
const installed = plugins.find(plugin => plugin.name === 'ext-installed') as CatalogPlugin
const missing = plugins.find(plugin => plugin.name === 'ext-missing') as CatalogPlugin
expect(installed).toMatchObject({ dir: `${CACHE}/ext-installed/2.0.0`, version: '2.0.0', commands: ['qe-go'], options: ['guardMode'], hasMcp: true, isMod: true, external: 'https://example.com/qe.git plugins/qe @ v2.0.0' })
expect((await readDoc(fs, installed, 'command', 'qe-go'))?.description).toBe('Go')
expect(missing).toMatchObject({ dir: '', version: null, skills: [], agents: [], commands: [], hasMcp: false, isMod: false, external: 'https://example.com/qe.git plugins/other @ v2.0.0' })
expect(await readDoc(fs, missing, 'command', 'qe-go')).toBeNull()
})
it('refuses a location with .. or a relative one, and a missing manifest', async () => { it('refuses a location with .. or a relative one, and a missing manifest', async () => {
expect(await readCatalog(fs, '/h/../etc')).toBeNull() expect(await readCatalog(fs, '/h/../etc')).toBeNull()
expect(await readCatalog(fs, 'relative/clone')).toBeNull() expect(await readCatalog(fs, 'relative/clone')).toBeNull()
@@ -66,10 +87,59 @@ describe('plugin catalog reader', () => {
const installed = new Set(['a-plugin']) const installed = new Set(['a-plugin'])
expect(visible(plugins, installed, 'installed', '').map(plugin => plugin.name)).toEqual(['a-plugin']) expect(visible(plugins, installed, 'installed', '').map(plugin => plugin.name)).toEqual(['a-plugin'])
expect(visible(plugins, installed, 'missing', '').map(plugin => plugin.name)).toEqual(['b-mod']) expect(visible(plugins, installed, 'missing', '').map(plugin => plugin.name)).toEqual(['b-mod', 'ext-installed', 'ext-missing'])
expect(visible(plugins, installed, 'mods', '').map(plugin => plugin.name)).toEqual(['b-mod']) expect(visible(plugins, installed, 'mods', '').map(plugin => plugin.name)).toEqual(['b-mod'])
expect(visible(plugins, installed, 'all', 'alpha').map(plugin => plugin.name)).toEqual(['a-plugin']) expect(visible(plugins, installed, 'all', 'alpha').map(plugin => plugin.name)).toEqual(['a-plugin'])
expect(visible(plugins, installed, 'skills', '').map(plugin => plugin.name)).toEqual(['a-plugin']) expect(visible(plugins, installed, 'skills', '').map(plugin => plugin.name)).toEqual(['a-plugin'])
expect(visible(plugins, installed, 'all', 'nothing-like-this')).toEqual([]) expect(visible(plugins, installed, 'all', 'nothing-like-this')).toEqual([])
}) })
}) })
describe('plugin catalog reader: hostile text from an external plugin', () => {
// ESC/CSI colour, an OSC 8 hyperlink and an OSC title, and the bidi embeddings, overrides and isolates (U+202A–202E, U+2066–2069),
// written as \u escapes so no control character sits in this source.
const ESC = '\u001b'
const BIDI = ['‪', '‫', '‬', '‭', '‮', '⁦', '⁧', '⁨', '⁩']
const hostile = `${ESC}[31mred${ESC}[0m ${ESC}]8;;https://evil.example${ESC}\\link${ESC}]8;;${ESC}\\ ${ESC}]0;title\u0007${BIDI.join('')}txt.exe`
const dir = `${CACHE}/ext-hostile/1.0.0`
const hostileFiles: Record<string, string> = {
[`${ROOT}/.claude-plugin/marketplace.json`]: JSON.stringify({ plugins: [{ name: 'ext-hostile', source: { ...GIT, url: `https://github.com/o/r${ESC}[2J` } }] }),
[`${dir}/.claude-plugin/plugin.json`]: JSON.stringify({ name: 'ext-hostile', version: `1.0.0${ESC}[2J${BIDI[4]}`, description: hostile }),
[`${dir}/commands/go.md`]: `---\ndescription: ${hostile}\n---\n${hostile}\n`,
[`${dir}/commands/${ESC}[2Jbad.md`]: 'x',
}
const hostileFs: ReaderFs = {
read: async path => {
if (!(path in hostileFiles)) throw new Error('ENOENT')
return hostileFiles[path] as string
},
stat: async path => (path in hostileFiles ? { size: (hostileFiles[path] as string).length } : undefined),
list: async path => {
const below = Object.keys(hostileFiles).filter(file => file.startsWith(`${path}/`)).map(file => file.slice(path.length + 1))
if (below.length === 0) throw new Error('ENOENT')
return [...new Set(below.map(name => name.split('/')[0] as string))].map(name => ({ name, kind: below.some(file => file === name) ? 'file' : 'dir' }))
},
}
const clean = (text: string) => {
expect(text).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/)
for (const mark of BIDI) expect(text.includes(mark), `U+${mark.charCodeAt(0).toString(16)}`).toBe(false)
expect(text).not.toMatch(/\]8;;|\[31m|\[2J|\]0;/)
}
it('strips escape sequences and bidi controls from its description, version, source line and docs, and drops a hostile file name', async () => {
const [plugin] = (await readCatalog(hostileFs, ROOT, new Map([['ext-hostile', dir]]))) as CatalogPlugin[]
expect(plugin?.name).toBe('ext-hostile')
for (const text of [plugin?.description ?? '', plugin?.version ?? '', plugin?.external ?? '']) clean(text)
expect(plugin?.description).toBe('red link txt.exe')
expect(plugin?.commands).toEqual(['go'])
const doc = await readDoc(hostileFs, plugin as CatalogPlugin, 'command', 'go')
clean(doc?.description ?? 'missing')
for (const line of doc?.lines ?? []) clean(line)
})
})
@@ -1,8 +1,9 @@
/** /**
* Plugin coverage: every ruflo plugin directory has a home section in the console, no entry names a plugin that is gone, every * Plugin coverage: every ruflo plugin directory and every external plugin the marketplace lists has a home section in the
* home is a real view, and a plugin that has no surface of its own says why. A new plugin added without a line fails here. * console, no entry names a plugin that is gone, every home is a real view, and a plugin that has no surface of its own says
* why. A new plugin added without a line fails here.
*/ */
import { existsSync, readdirSync } from 'node:fs' import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
@@ -14,6 +15,11 @@ const onDisk = readdirSync(PLUGINS, { withFileTypes: true })
.filter(entry => entry.isDirectory() && existsSync(join(PLUGINS, entry.name, '.claude-plugin', 'plugin.json'))) .filter(entry => entry.isDirectory() && existsSync(join(PLUGINS, entry.name, '.claude-plugin', 'plugin.json')))
.map(entry => entry.name) .map(entry => entry.name)
.sort() .sort()
// An external plugin is listed by a git source (an object, not a `./plugins/...` path): it has no directory here.
const external = (JSON.parse(readFileSync(join(PLUGINS, '..', '.claude-plugin', 'marketplace.json'), 'utf8')) as { plugins: { name: string; source: unknown }[] }).plugins
.filter(plugin => typeof plugin.source !== 'string')
.map(plugin => plugin.name)
const known = [...onDisk, ...external]
describe('plugin coverage', () => { describe('plugin coverage', () => {
it('finds the plugin directories (the guard is not checking nothing)', () => { it('finds the plugin directories (the guard is not checking nothing)', () => {
@@ -22,8 +28,8 @@ describe('plugin coverage', () => {
}) })
it('every plugin directory has a home section, and no home is for a plugin that is gone', () => { it('every plugin directory has a home section, and no home is for a plugin that is gone', () => {
expect(onDisk.filter(name => PLUGIN_MAP[name] === undefined), 'unmapped plugins: add a line to hooks/plugin-map.ts').toEqual([]) expect(known.filter(name => PLUGIN_MAP[name] === undefined), 'unmapped plugins: add a line to hooks/plugin-map.ts').toEqual([])
expect(Object.keys(PLUGIN_MAP).filter(name => !onDisk.includes(name)), 'stale entries').toEqual([]) expect(Object.keys(PLUGIN_MAP).filter(name => !known.includes(name)), 'stale entries').toEqual([])
}) })
it('every home is a real section, and a plugin without a surface of its own says why', () => { it('every home is a real section, and a plugin without a surface of its own says why', () => {
@@ -41,6 +47,7 @@ describe('plugin coverage', () => {
it('homeOf reads a plugin name or a slash name; pluginsOfView lists what a section owns', () => { it('homeOf reads a plugin name or a slash name; pluginsOfView lists what a section owns', () => {
expect(homeOf('ruflo-cost-tracker')).toBe('cost') expect(homeOf('ruflo-cost-tracker')).toBe('cost')
expect(homeOf('ruflo-ruos:deploy')).toBe('swarm') expect(homeOf('ruflo-ruos:deploy')).toBe('swarm')
expect(homeOf('agentic-qe-fleet:aqe-generate')).toBe('devtools')
expect(homeOf('not-a-plugin:thing')).toBeNull() expect(homeOf('not-a-plugin:thing')).toBeNull()
expect(pluginsOfView('secure')).toEqual(['ruflo-aidefence', 'ruflo-protector', 'ruflo-security-audit']) expect(pluginsOfView('secure')).toEqual(['ruflo-aidefence', 'ruflo-protector', 'ruflo-security-audit'])
}) })
+1
View File
@@ -84,6 +84,7 @@ scripts/__tests__/mod-capability-matrix.test.mjs
tests/all-plugins-smoke-workflow.test.mjs tests/all-plugins-smoke-workflow.test.mjs
tests/check-guard-tool-names.test.mjs tests/check-guard-tool-names.test.mjs
tests/check-status-forwarding.test.mjs tests/check-status-forwarding.test.mjs
tests/external-plugins.test.mjs
plugins/ruflo-console/tests/result-display.test.ts plugins/ruflo-console/tests/result-display.test.ts
plugins/ruflo-console/tests/toasts.test.ts plugins/ruflo-console/tests/toasts.test.ts
plugins/ruflo-console/tests/full-text.test.ts plugins/ruflo-console/tests/full-text.test.ts
+102
View File
@@ -0,0 +1,102 @@
// 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) };
}
+48 -3
View File
@@ -9,6 +9,12 @@
// node scripts/smoke-all-plugins.mjs --only ruflo-agent,ruflo-cost-tracker // node scripts/smoke-all-plugins.mjs --only ruflo-agent,ruflo-cost-tracker
// node scripts/smoke-all-plugins.mjs --skip ruflo-iot-cognitum // node scripts/smoke-all-plugins.mjs --skip ruflo-iot-cognitum
// node scripts/smoke-all-plugins.mjs --skip-guard-probe # omit the final guard-probe step // node scripts/smoke-all-plugins.mjs --skip-guard-probe # omit the final guard-probe step
// node scripts/smoke-all-plugins.mjs --skip-external # omit the external plugins (no network)
//
// External plugins: a marketplace entry whose source is a git-subdir pinned to a commit on github.com (a plugin maintained in
// another repository) is checked out at that commit into a temporary folder, its manifest must carry the listed name, and its
// own scripts/smoke.sh, when it ships one, runs as one more row with a scrubbed env, a temporary HOME and its checkout as cwd
// (scripts/external-plugins.mjs). This step needs network access to the plugin's repository.
// //
// After the per-plugin smokes, `scripts/probe-mod-guards.mjs --fast` runs every hooks/guard.ts through the adversarial corpus. It // After the per-plugin smokes, `scripts/probe-mod-guards.mjs --fast` runs every hooks/guard.ts through the adversarial corpus. It
// fails only on holes NOT listed in scripts/probe-mod-guards.known-holes.json, so a regression (or a new guard copied from a // fails only on holes NOT listed in scripts/probe-mod-guards.known-holes.json, so a regression (or a new guard copied from a
@@ -24,10 +30,11 @@
// 2 config error (e.g. invalid CLI args) // 2 config error (e.g. invalid CLI args)
// 3 no smoke scripts found (likely repo-layout drift — fail closed) // 3 no smoke scripts found (likely repo-layout drift — fail closed)
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs'; import { readdirSync, existsSync, statSync, readFileSync, rmSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { checkoutAt, externalSmokePlan, externalSourceProblem, makeWorkArea } from './external-plugins.mjs';
const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url)); const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = dirname(SCRIPTS_DIR); const REPO_ROOT = dirname(SCRIPTS_DIR);
@@ -42,6 +49,7 @@ const ARGS = (() => {
timeoutSec: 120, // per-plugin hard cap — prevents a hung smoke from deadlocking the run timeoutSec: 120, // per-plugin hard cap — prevents a hung smoke from deadlocking the run
failFast: false, // if true, kill remaining smokes on first failure failFast: false, // if true, kill remaining smokes on first failure
guardProbe: true, // final step: fast adversarial probe of every hooks/guard.ts guardProbe: true, // final step: fast adversarial probe of every hooks/guard.ts
external: true, // check out each external plugin at its pinned commit and run its own smoke
}; };
for (let i = 2; i < process.argv.length; i++) { for (let i = 2; i < process.argv.length; i++) {
const v = process.argv[i]; const v = process.argv[i];
@@ -50,6 +58,7 @@ const ARGS = (() => {
else if (v === '--timeout') a.timeoutSec = parseFloat(process.argv[++i]); else if (v === '--timeout') a.timeoutSec = parseFloat(process.argv[++i]);
else if (v === '--fail-fast') a.failFast = true; else if (v === '--fail-fast') a.failFast = true;
else if (v === '--skip-guard-probe') a.guardProbe = false; else if (v === '--skip-guard-probe') a.guardProbe = false;
else if (v === '--skip-external') a.external = false;
else if (v === '--only') { else if (v === '--only') {
a.only = new Set((process.argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean)); a.only = new Set((process.argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean));
} else if (v === '--skip') { } else if (v === '--skip') {
@@ -83,10 +92,42 @@ function discoverPlugins() {
return plugins.sort((a, b) => a.name.localeCompare(b.name)); return plugins.sort((a, b) => a.name.localeCompare(b.name));
} }
/** The marketplace's external entries (a git-subdir source), filtered by --only and --skip. */
function discoverExternal() {
let plugins = [];
try { plugins = JSON.parse(readFileSync(join(REPO_ROOT, '.claude-plugin', 'marketplace.json'), 'utf8')).plugins ?? []; } catch { return []; }
return plugins
.filter((p) => p && typeof p.source === 'object' && p.source !== null && typeof p.name === 'string')
.filter((p) => (!ARGS.only || ARGS.only.has(p.name)) && !ARGS.skip.has(p.name))
.map((p) => ({ name: p.name, source: p.source }));
}
/** One external plugin as a smoke row: checkout at the pinned commit, manifest name, then its own scripts/smoke.sh, run as
* third-party code (scrubbed env, temporary HOME, cwd = its own checkout outside the workspace; see external-plugins.mjs). */
async function runExternal(entry) {
const name = `${entry.name} (external)`;
const t0 = Date.now();
const fail = (reason) => ({ name, exitCode: 1, ok: false, timedOut: false, aborted: false, terminationReason: reason, passed: null, failed: null, durationMs: Date.now() - t0, failingSteps: [], stderrTail: '' });
const problem = externalSourceProblem(entry.source);
if (problem !== null) return fail(problem);
const { url, path, sha } = entry.source;
const area = makeWorkArea();
try {
const fetched = checkoutAt(url, sha, area.checkout, area.home, ARGS.timeoutSec * 1000);
if (fetched !== null) return fail(fetched);
const plan = externalSmokePlan(area.checkout, area.home, path, entry.name);
if (plan.problem) return fail(plan.problem);
if (plan.smoke === null) return { ...fail('no scripts/smoke.sh; manifest checked'), exitCode: 0, ok: true };
return { ...(await runSmoke({ name, smoke: plan.smoke, cwd: plan.cwd, env: plan.env })), durationMs: Date.now() - t0 };
} finally {
rmSync(area.dir, { recursive: true, force: true });
}
}
function runSmoke(plugin, abortSignal) { function runSmoke(plugin, abortSignal) {
return new Promise((resolve) => { return new Promise((resolve) => {
const start = Date.now(); const start = Date.now();
const p = spawn('bash', [plugin.smoke], { stdio: ['ignore', 'pipe', 'pipe'] }); const p = spawn('bash', [plugin.smoke], { stdio: ['ignore', 'pipe', 'pipe'], ...(plugin.cwd && { cwd: plugin.cwd }), ...(plugin.env && { env: plugin.env }) });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let timedOut = false; let timedOut = false;
@@ -207,7 +248,8 @@ function runChangelogContract() {
async function main() { async function main() {
const plugins = discoverPlugins(); const plugins = discoverPlugins();
if (plugins.length === 0) { const external = ARGS.external ? discoverExternal() : [];
if (plugins.length === 0 && external.length === 0) {
console.error('smoke-all-plugins: no plugins/*/scripts/smoke.sh found — repo layout drift?'); console.error('smoke-all-plugins: no plugins/*/scripts/smoke.sh found — repo layout drift?');
process.exit(3); process.exit(3);
} }
@@ -245,6 +287,9 @@ async function main() {
results = await Promise.all(pending); results = await Promise.all(pending);
} }
if (ARGS.external && !abortController?.signal.aborted) {
for (const entry of external) results.push(await runExternal(entry));
}
results.push(runChangelogContract()); results.push(runChangelogContract());
if (ARGS.guardProbe && !abortController?.signal.aborted) results.push(runGuardProbe()); if (ARGS.guardProbe && !abortController?.signal.aborted) results.push(runGuardProbe());
+156
View File
@@ -0,0 +1,156 @@
// node --test tests/external-plugins.test.mjs
// The external-plugin checks (ADR-484): the github-only source allowlist in scripts/external-plugins.mjs and in
// validate-marketplace.yml's inline validator, the realpath containment of the plugin folder, and the environment and working
// directory an external smoke.sh runs with.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { externalSmokePlan, externalSourceProblem, isAllowedRepoUrl, makeWorkArea, realpathInside, scrubbedEnv } from '../scripts/external-plugins.mjs';
const REPO = fileURLToPath(new URL('..', import.meta.url));
const SHA = 'e5814a2f4f3d7995b87ebda9ad30e8a3d92cd555';
const source = (over) => ({ source: 'git-subdir', url: 'https://github.com/proffesor-for-testing/agentic-qe.git', path: 'plugins/agentic-qe-fleet', ref: 'v3.15.10', sha: SHA, ...over });
const REFUSED_URLS = [
'https://github.com.evil.io/owner/repo',
'https://evil.io/github.com/owner/repo',
'https://gitlab.com/owner/repo',
'http://github.com/owner/repo',
'https://user@github.com/owner/repo',
'https://github.com/owner/repo/extra',
'https://github.com/owner',
'https://github.com/../repo',
'https://github.com/owner/..',
'https://github.com/owner/repo?x=1',
'https://github.com/own er/repo',
'git@github.com:owner/repo.git',
'--upload-pack=x',
];
// Paths the plugin folder may not take: encoded or backslashed climbs, empty or trailing segments, absolute, . and .. segments.
const BAD_PATHS = ['%2e%2e', 'plugins/%2e%2e/x', 'a\\..\\b', 'a//b', 'plugins/p/', '/etc', '.', '..', 'a/./b', 'a/../b', '', 'a b', 'a/b?c'];
test('the allowlist takes https://github.com/<owner>/<repo>, with or without .git', () => {
assert.equal(isAllowedRepoUrl('https://github.com/proffesor-for-testing/agentic-qe.git'), true);
assert.equal(isAllowedRepoUrl('https://github.com/ruvnet/ruflo'), true);
});
test('the allowlist refuses other hosts, lookalikes and anything around the owner/repo pair', () => {
for (const url of REFUSED_URLS) assert.equal(isAllowedRepoUrl(url), false, url);
});
test('externalSourceProblem refuses a non-github host, a lookalike, a climbing path and a short sha', () => {
assert.equal(externalSourceProblem(source()), null);
assert.match(externalSourceProblem(source({ url: 'https://gitlab.com/owner/repo.git' })), /github/);
assert.match(externalSourceProblem(source({ url: 'https://github.com.evil.io/owner/repo' })), /github/);
assert.match(externalSourceProblem(source({ url: 'https://evil.io/github.com/owner/repo' })), /github/);
assert.match(externalSourceProblem(source({ path: 'plugins/../..' })), /path/);
assert.match(externalSourceProblem(source({ path: '/etc' })), /path/);
for (const path of BAD_PATHS) assert.match(externalSourceProblem(source({ path })) ?? 'accepted', /path/, path);
assert.equal(externalSourceProblem(source({ path: 'plugins/agentic-qe-fleet' })), null);
assert.equal(externalSourceProblem(source({ path: 'a.b/c_d-e' })), null);
assert.match(externalSourceProblem(source({ sha: SHA.slice(0, 12) })), /sha/);
assert.match(externalSourceProblem(source({ source: 'url' })), /git-subdir/);
});
/** Runs validate-marketplace.yml's inline validator (the `node -e "..."` block, through bash as CI does) on one entry. */
function runWorkflowValidator(entrySource) {
const yml = readFileSync(join(REPO, '.github', 'workflows', 'validate-marketplace.yml'), 'utf8');
const start = yml.indexOf('node -e "');
const end = yml.indexOf('\n "', start);
assert.ok(start > 0 && end > start, 'found the inline validator');
const dir = mkdtempSync(join(tmpdir(), 'validate-marketplace-'));
try {
mkdirSync(join(dir, '.claude-plugin'));
const entry = { name: 'ext', source: entrySource, description: 'an external plugin' };
writeFileSync(join(dir, '.claude-plugin', 'marketplace.json'), JSON.stringify({ name: 't', plugins: [entry] }));
return spawnSync('bash', ['-c', yml.slice(start, end + 12)], { cwd: dir, encoding: 'utf8' });
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test('validate-marketplace.yml accepts a pinned github git-subdir and refuses other hosts and lookalikes', () => {
assert.equal(runWorkflowValidator(source()).status, 0);
for (const url of ['https://gitlab.com/owner/repo.git', 'https://github.com.evil.io/owner/repo', 'https://evil.io/github.com/owner/repo', 'https://github.com/../repo']) {
const r = runWorkflowValidator(source({ url }));
assert.notEqual(r.status, 0, url);
assert.match(r.stderr, /github\.com/, url);
}
for (const path of [...BAD_PATHS, 'plugins/../x']) assert.notEqual(runWorkflowValidator(source({ path })).status, 0, path);
assert.equal(runWorkflowValidator(source({ path: 'a.b/c_d-e' })).status, 0);
assert.notEqual(runWorkflowValidator(source({ sha: 'abc' })).status, 0);
});
/** A fake external checkout: plugins/p with a manifest and a smoke.sh that records its env and cwd under $HOME. */
function fakeCheckout(area) {
const plugin = join(area.checkout, 'plugins', 'p');
mkdirSync(join(plugin, '.claude-plugin'), { recursive: true });
mkdirSync(join(plugin, 'scripts'));
writeFileSync(join(plugin, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'p' }));
writeFileSync(join(plugin, 'scripts', 'smoke.sh'), 'env > "$HOME/env.txt"\npwd -P > "$HOME/cwd.txt"\nls -A > "$HOME/ls.txt"\n');
return plugin;
}
test('an external smoke runs with a scrubbed env (no GITHUB_TOKEN, no *_TOKEN/*_KEY/*SECRET*/ACTIONS_*) and its checkout as cwd, outside the workspace', () => {
const area = makeWorkArea();
try {
fakeCheckout(area);
const parent = { PATH: process.env.PATH, LANG: 'C.UTF-8', TERM: 'xterm', CI: 'true', GITHUB_TOKEN: 'ghs_x', ACTIONS_RUNTIME_TOKEN: 'y', NPM_TOKEN: 'z', ANTHROPIC_API_KEY: 'k', MY_SECRET_VALUE: 's', GITHUB_WORKSPACE: REPO, HOME: '/root' };
const plan = externalSmokePlan(area.checkout, area.home, 'plugins/p', 'p', parent);
assert.ok(plan.smoke, JSON.stringify(plan));
// Run it the way smoke-all-plugins' runSmoke does: bash <smoke> with exactly the plan's cwd and env.
const r = spawnSync('bash', [plan.smoke], { cwd: plan.cwd, env: plan.env, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const seen = readFileSync(join(area.home, 'env.txt'), 'utf8');
for (const name of ['GITHUB_TOKEN', 'ACTIONS_RUNTIME_TOKEN', 'NPM_TOKEN', 'ANTHROPIC_API_KEY', 'MY_SECRET_VALUE', 'GITHUB_WORKSPACE']) {
assert.doesNotMatch(seen, new RegExp(`^${name}=`, 'm'), name);
}
assert.match(seen, new RegExp(`^HOME=${area.home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
const cwd = readFileSync(join(area.home, 'cwd.txt'), 'utf8').trim();
assert.equal(cwd, plan.cwd);
assert.ok(!(cwd + sep).startsWith(REPO), `cwd ${cwd} is outside the workspace ${REPO}`);
assert.deepEqual(readFileSync(join(area.home, 'ls.txt'), 'utf8').trim().split('\n'), ['plugins'], 'the cwd holds only the checkout');
assert.deepEqual(Object.keys(plan.env).sort(), ['CI', 'HOME', 'LANG', 'PATH', 'TERM']);
} finally {
rmSync(area.dir, { recursive: true, force: true });
}
});
test('a plugin folder that is a symlink out of the checkout is refused, and so is a smoke.sh that links out', () => {
const area = makeWorkArea();
const outside = mkdtempSync(join(tmpdir(), 'outside-'));
try {
mkdirSync(join(outside, '.claude-plugin'));
writeFileSync(join(outside, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'p' }));
mkdirSync(join(area.checkout, 'plugins'));
symlinkSync(outside, join(area.checkout, 'plugins', 'p'));
assert.equal(realpathInside(area.checkout, join(area.checkout, 'plugins', 'p')), null);
assert.match(externalSmokePlan(area.checkout, area.home, 'plugins/p', 'p').problem, /outside the checkout/);
rmSync(join(area.checkout, 'plugins'), { recursive: true, force: true });
const plugin = fakeCheckout(area);
rmSync(join(plugin, 'scripts', 'smoke.sh'));
writeFileSync(join(outside, 'evil.sh'), 'exit 0\n');
symlinkSync(join(outside, 'evil.sh'), join(plugin, 'scripts', 'smoke.sh'));
assert.match(externalSmokePlan(area.checkout, area.home, 'plugins/p', 'p').problem, /smoke\.sh resolves outside/);
} finally {
rmSync(area.dir, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('a manifest that does not carry the listed name is refused', () => {
const area = makeWorkArea();
try {
fakeCheckout(area);
assert.match(externalSmokePlan(area.checkout, area.home, 'plugins/p', 'other').problem, /does not name other/);
} finally {
rmSync(area.dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,91 @@
# ADR 484: External plugins in the ruflo marketplace (first: Agentic QE)
Status: Accepted
Date: 2026-10-09
Builds on: ADR-446 (the plugin fleet as mods), ADR-434 (the Plugins page detects the marketplace), ADR-411 (the plugin map)
## 1. Context
Every entry in `.claude-plugin/marketplace.json` has been a local folder, `./plugins/ruflo-*`. Agentic QE (`agentic-qe` on npm) ships a
Claude Code plugin, `agentic-qe-fleet`, from its own repository and its own marketplace: 11 QE agents, 9 skills, 9 commands, an `.mcp.json`
that runs `npx -y agentic-qe@<plugin version> mcp`, and an ADR-446-style mod (`aqe-mod`: a status file at `.claude-flow/aqe-mod/status.json`,
`/aqe-mod`, and a tighten-only `tool.call` guard that refuses destructive operations on `.agentic-qe/*.db` and `*.rvf`). Ruflo users should
be able to install it from the ruflo marketplace and see it in the console.
Two ways to list it:
1. **An external entry**: the marketplace points at the plugin's folder in its own repository, pinned to a commit.
2. **A local wrapper**: a `plugins/ruflo-agentic-qe` folder with its own `plugin.json` and a pinned `.mcp.json`.
The wrapper would carry only what it copies: the agents, skills, commands and the mod would either be copied (two sources of truth, drifting
on every AQE release) or left out (a plugin that is mostly an MCP line).
## 2. Decision
**List external plugins by a `git-subdir` source pinned to a full commit sha**, and teach the ruflo tooling that an entry may have no folder
here. The first entry:
```json
{ "name": "agentic-qe-fleet",
"source": { "source": "git-subdir", "url": "https://github.com/proffesor-for-testing/agentic-qe.git",
"path": "plugins/agentic-qe-fleet", "ref": "v3.15.10", "sha": "e5814a2f4f3d7995b87ebda9ad30e8a3d92cd555" } }
```
Rules for an external entry:
- **Pinned.** `sha` is the full 40-character commit; `ref` names the release tag for people. Moving to a new release is a reviewed change to
this file. `validate-marketplace.yml`, which failed on any non-string source (`Missing plugin dir: [object Object]`), now checks an
external entry instead: a `git-subdir` with an https url, a path without `..`, a full sha and a description.
- **Smoked at the pin.** `scripts/smoke-all-plugins.mjs` checks out each external entry at its sha (shallow, blobless, into a temporary
folder), requires its `plugin.json` to carry the listed name, and runs the plugin's own `scripts/smoke.sh` when it ships one. The step needs
network access; `--skip-external` turns it off. `all-plugins-smoke.yml` also runs when `marketplace.json` changes.
- **Third-party code in CI is fenced** (`scripts/external-plugins.mjs`, `tests/external-plugins.test.mjs`). Only
`https://github.com/<owner>/<repo>` sources are accepted, in both validators. The plugin folder and its `smoke.sh` must stay inside
the checkout after links are resolved. The smoke runs with only PATH, LANG, TERM and CI from the job's environment (no GITHUB_TOKEN,
ACTIONS_*, *_TOKEN, *_KEY or *SECRET*), a temporary HOME, and the checkout, in a temporary folder outside the workspace, as its working
directory. `all-plugins-smoke.yml` runs with `permissions: contents: read` and checks out with `persist-credentials: false`, so no
token sits in the workspace's git config. This is not a sandbox: the script can still read absolute paths and use the network.
So every pin bump needs a human review of the pinned commit's `scripts/smoke.sh` (and anything it runs) before merge: a hosted
runner cannot contain it, only keep secrets away from it.
- **Mapped in the console.** `hooks/plugin-map.ts` has a line for it like any plugin (Agentic QE: Dev Tools, beside `ruflo-testgen`), and
`tests/plugin-coverage.spec.ts` requires a line for every external marketplace entry as well as every `plugins/` folder.
- **Read from the installed copy.** The Plugin Catalog reads a local plugin from the marketplace clone; an external one has no copy there, so
its skills, agents, commands, `.mcp.json` and `hooks/register.ts` are read from its install path in Claude Code's
`installed_plugins.json` (the same checked path the What's new page reads). Not installed, it is listed by its entry alone. Either way
the row carries an `EXT` badge and the detail names where it is installed from. Ships in the next ruflo console release (version set at merge).
Nothing else needed a change: the health matrix lists installed plugins by marketplace (`agentic-qe-fleet@ruflo` is a ruflo plugin there),
the Mods section already reads any `.claude-flow/*-mod/status.json`, and the scripts that walk `plugins/*` (`audit-plugin-*`,
`check-plugin-manifests`, `mod-capability-matrix`, `probe-mod-guards`, the CLI command inventory) skip a non-string source or never read the
marketplace. They do not cover the external plugin; its own repository runs its manifest, mod and guard tests.
## 3. Evidence (Claude Code 2.1.295 on 2026-10-09; the install re-run on 2.1.296 at the v3.15.2 pin, 2026-10-10, at the v3.15.3 pin, 2026-10-11, at the v3.15.4 and v3.15.5 pins, 2026-10-12, at the v3.15.6 pin, 2026-10-13, on 2.1.300 at the v3.15.7 pin, 2026-10-15, on 2.1.301 at the v3.15.8 pin, 2026-10-16, on 2.1.302 at the v3.15.9 pin, 2026-10-18, and on 2.1.303 at the v3.15.10 pin, 2026-10-19)
- `claude plugin validate .claude-plugin/marketplace.json` passes with the entry; a `{"source":"bogus"}` source is refused.
`claude plugin validate .` at the root fails exactly as it does on `main`, on the legacy root `.claude-plugin/plugin.json`
(`repository` must be a string), and reports nothing about the new entry.
- In a throwaway `CLAUDE_CONFIG_DIR`: `claude plugin marketplace add <marketplace>` then `claude plugin install agentic-qe-fleet@<marketplace>`
installs 3.15.10 into `plugins/cache/<marketplace>/agentic-qe-fleet/3.15.10` (`gitCommitSha` e5814a2f in `installed_plugins.json`; d1c33d2b at the v3.15.9 pin, d9ce98f7 at the v3.15.8 pin, 7f2d091d at the v3.15.7 pin, 0fa34d24 at the v3.15.6 pin, d65ede14 at the v3.15.5 pin, bec02b13 at the v3.15.4 pin);
`claude mcp list` shows `plugin:agentic-qe-fleet:agentic-qe: npx -y agentic-qe@3.15.10 mcp - ✔ Connected`; `claude plugin test` on the
installed copy: 45 pass, 0 fail across 3 files (the same 45 as at v3.15.2 through v3.15.9; 11 at v3.15.0 and 22 at v3.15.1, as those releases added guard and status-file tests).
At the v3.15.2 pin, `claude -p "/aqe-mod status"` answered from the mod, wrote `.claude-flow/aqe-mod/status.json` and reported the mod's
version (3.15.2) and its totals across sessions; the v3.15.3 to v3.15.10 re-runs had no logged-in session, so that step was not repeated. From 3.15.2 to
3.15.10 the plugin folder changed only its version (`plugin.json`, the mod's `hooks/version.ts`, CHANGELOG entries) and its MCP pin.
## 4. Consequences
- Ruflo does not review each AQE commit, only each pin. The pinned sha is the trust boundary: an AQE release reaches ruflo users when a PR here
moves the pin and the external smoke passes on it.
- The AQE mod's guard is not in `probe-mod-guards` or the capability matrix, which read `plugins/` only. Covering external guards would mean
bundling code from another repository in ruflo CI; left out on purpose.
- **Older Claude Code cannot read the marketplace at all.** Claude Code before 2.1.69 refuses the whole `marketplace.json` over this one entry
(`plugins.N.source: Invalid input`), so `ruflo-*` plugins stop installing there too; found by bisecting `claude plugin marketplace add` across
npm releases (2.1.68 refuses, 2.1.69 adds it). On such a client an existing `ruflo` marketplace stops refreshing (`Failed to refresh
marketplace 'ruflo': Invalid schema … plugins.9.source`) and its installed plugins report "not found in marketplace". The line was already
crossed on `main`: on 2.1.68, 37 of the 46 listed plugins refuse to install (`Unrecognized key: "userConfig"`), and `ruflo-testgen`, one that
installs, fails to load (`Hook load failed`). The README requires Claude Code 2.1.287 (2026-10-01) or later; 2.1.69 is from 2026-03-04.
A second marketplace file for external entries would avoid this, but the plugin would then be `agentic-qe-fleet@<other name>`, outside the
console's `ruflo` health matrix, so it is not done.
- `--plugin-dir plugins/...` (the plugins README's quick start) does not load an external plugin; it installs from the marketplace only.
+1
View File
@@ -69,6 +69,7 @@ Two files carry the number 430 (the menu's accents, and the menu-design amendmen
| 478 | Console What's new page, plugin changelogs | Accepted | 10-07 | 428, 477 | — | — | [ADR-478](ADR-478-console-whats-new.md) | — | | 478 | Console What's new page, plugin changelogs | Accepted | 10-07 | 428, 477 | — | — | [ADR-478](ADR-478-console-whats-new.md) | — |
| 480 | Console ADRs page: your project's ADRs, and missions, loops and swarms that follow them | Accepted | 10-07 | 407, 443, 444, 450, 474, 477, 478 | — | this PR | console 0.39.0, swarm 0.3.5, workflows 0.6.4 | `adr.spec`, `adr-write.spec`, `adr-integration.spec`, `adr-page.spec`, `adr-mutation.spec`, `adr-digest.spec` | | 480 | Console ADRs page: your project's ADRs, and missions, loops and swarms that follow them | Accepted | 10-07 | 407, 443, 444, 450, 474, 477, 478 | — | this PR | console 0.39.0, swarm 0.3.5, workflows 0.6.4 | `adr.spec`, `adr-write.spec`, `adr-integration.spec`, `adr-page.spec`, `adr-mutation.spec`, `adr-digest.spec` |
| 483 | Console advisor checkpoints for mission loops (plan, repeated failure, before done) | Accepted | 10-08 | 441, 443, 444, 477, 480, 481 | — | this PR | console 0.40.0 | `mission-advisor.spec`, `mission-advisor-mutation.spec` | | 483 | Console advisor checkpoints for mission loops (plan, repeated failure, before done) | Accepted | 10-08 | 441, 443, 444, 477, 480, 481 | — | this PR | console 0.40.0 | `mission-advisor.spec`, `mission-advisor-mutation.spec` |
| 484 | External plugins in the ruflo marketplace (first: Agentic QE) | Accepted | 10-09 | 446, 434, 411 | — | this PR | next console release | `plugin-catalog.spec`, `plugin-coverage.spec`, `smoke-all-plugins` external step |
| 486 | Session workspace: attention queue, session browser and preview on the Room (Claude, Codex and Ruflo sessions) | Accepted | 10-09 | 406, 444, 448, 473, 477, 481 | — | this PR | console 0.41.0, mods 0.4.0 | `sessions.spec`, `sessions-safety.spec`, `sessions-real-fs.spec`, `attention.test` | | 486 | Session workspace: attention queue, session browser and preview on the Room (Claude, Codex and Ruflo sessions) | Accepted | 10-09 | 406, 444, 448, 473, 477, 481 | — | this PR | console 0.41.0, mods 0.4.0 | `sessions.spec`, `sessions-safety.spec`, `sessions-real-fs.spec`, `attention.test` |
Other ADRs outside 404-451 that the mod ADRs lean on: 150 (removable integrations), 174 (failures as a learning signal), 322 and 322A/B/C (flywheel, evaluation and promotion, receipts), 324 and 325 (policy engine, claims plane), 103 (witness). The console's Mods section (`hooks/data/mods.ts`, console 0.33.1, PR #3707) reads the status files ADR 446 defines. Other ADRs outside 404-451 that the mod ADRs lean on: 150 (removable integrations), 174 (failures as a learning signal), 322 and 322A/B/C (flywheel, evaluation and promotion, receipts), 324 and 325 (policy engine, claims plane), 103 (witness). The console's Mods section (`hooks/data/mods.ts`, console 0.33.1, PR #3707) reads the status files ADR 446 defines.