mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
17 files changed
+587
-28
No files matched your search
@@ -51,6 +51,17 @@
|
||||
"source": "./plugins/ruflo-testgen",
|
||||
"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",
|
||||
"source": "./plugins/ruflo-docs",
|
||||
|
||||
@@ -16,7 +16,11 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
- '.claude-plugin/marketplace.json'
|
||||
- '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-skill-mcp-tools.mjs'
|
||||
- 'verification/skill-mcp-tool-baseline.json'
|
||||
@@ -33,7 +37,11 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
- '.claude-plugin/marketplace.json'
|
||||
- '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-skill-mcp-tools.mjs'
|
||||
- 'verification/skill-mcp-tool-baseline.json'
|
||||
@@ -49,12 +57,19 @@ on:
|
||||
- '.github/workflows/all-plugins-smoke.yml'
|
||||
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:
|
||||
smoke-all:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -64,6 +79,9 @@ jobs:
|
||||
run: |
|
||||
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"
|
||||
- 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)
|
||||
# --timeout 300 caps each individual plugin smoke at 5 minutes.
|
||||
# iter 119 — bumped from 60s because ruflo-metaharness has grown
|
||||
|
||||
@@ -25,6 +25,20 @@ jobs:
|
||||
console.log('Marketplace:', catalog.name);
|
||||
console.log('Plugins:', catalog.plugins.length);
|
||||
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;
|
||||
if (!fs.existsSync(dir)) throw new Error('Missing plugin dir: ' + dir);
|
||||
const manifest = dir + '/.claude-plugin/plugin.json';
|
||||
|
||||
@@ -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-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 |
|
||||
| [**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
|
||||
|
||||
|
||||
@@ -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-testgen](ruflo-testgen/) | Test gap detection, TDD London School workflow |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 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,
|
||||
* a name that is not a plain word and a path with `..` are skipped, and every read may be refused.
|
||||
* mod (function hooks) each ships, read from the clone on disk. An external plugin (listed by a git source, maintained in
|
||||
* 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 { plain } from './parse'
|
||||
@@ -10,7 +12,7 @@ export type CatalogPlugin = {
|
||||
name: string
|
||||
description: string
|
||||
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
|
||||
skills: string[]
|
||||
agents: string[]
|
||||
@@ -20,6 +22,8 @@ export type CatalogPlugin = {
|
||||
options: string[]
|
||||
/** A `hooks/register.ts` makes it a mod: function hooks run in the engine. */
|
||||
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}$/
|
||||
@@ -48,15 +52,34 @@ async function namesIn(fs: ReaderFs, dir: string, kind: 'dir' | 'md'): Promise<s
|
||||
.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
|
||||
|
||||
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 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(
|
||||
text => recordOf(JSON.parse(text)),
|
||||
() => 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)
|
||||
|
||||
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
|
||||
|
||||
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 => {
|
||||
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. */
|
||||
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 stat = await fs.stat(path).catch(() => undefined)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { ActionSpec } from './actions'
|
||||
import { type CatalogMode, type CatalogPlugin, readCatalog, readDoc, visible } from './data/plugin-catalog'
|
||||
import { safeInstallPath } from './data/cost-ledger'
|
||||
import { plain } from './data/parse'
|
||||
import { RUFLO_MARKET } from './data/snapshot'
|
||||
import type { Host } from './host'
|
||||
@@ -131,7 +132,16 @@ export function loadCatalog(state: State, host: Host): void {
|
||||
|
||||
catalog.loading = true
|
||||
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 => {
|
||||
catalog.plugins = plugins
|
||||
catalog.why = plugins === null ? 'the clone’s marketplace.json is not readable' : plugins.length === 0 ? 'the clone lists no plugins' : ''
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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
|
||||
* line, so no plugin is ever unreachable from the console. A plugin with no section of its own says why (`catalogOnly`) and is
|
||||
* reached from the Plugin Catalog. Pure data.
|
||||
* "ask Claude" about it. Every plugin directory under `plugins/`, and every external plugin the marketplace lists by a git
|
||||
* source, appears here, and a test fails when one is added without a line, so no plugin is ever unreachable from the console.
|
||||
* 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'
|
||||
|
||||
@@ -12,6 +12,7 @@ const home = (view: ViewId): PluginHome => ({ view })
|
||||
const catalog = (why: string): PluginHome => ({ view: 'market', catalogOnly: why })
|
||||
|
||||
export const PLUGIN_MAP: Readonly<Record<string, PluginHome>> = {
|
||||
'agentic-qe-fleet': home('devtools'),
|
||||
'ruflo-adr': home('devtools'),
|
||||
'ruflo-agent': home('swarm'),
|
||||
'ruflo-agentdb': home('memory'),
|
||||
|
||||
@@ -15,7 +15,7 @@ function verbOf(ctx: Ctx, plugin: CatalogPlugin): { verb: Verb; label: 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. */
|
||||
@@ -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}`),
|
||||
)
|
||||
|
||||
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.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'
|
||||
|
||||
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> = {
|
||||
[`${ROOT}/.claude-plugin/marketplace.json`]: JSON.stringify({
|
||||
plugins: [
|
||||
@@ -11,6 +13,9 @@ const files: Record<string, string> = {
|
||||
{ name: 'b-mod', source: './plugins/b-mod', description: 'A mod' },
|
||||
{ name: 'evil', source: './../../etc', description: 'climbs out' },
|
||||
{ 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"}',
|
||||
@@ -19,6 +24,10 @@ const files: Record<string, string> = {
|
||||
[`${ROOT}/plugins/a-plugin/commands/go.md`]: 'x',
|
||||
[`${ROOT}/plugins/a-plugin/.mcp.json`]: '{}',
|
||||
[`${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 = {
|
||||
@@ -38,14 +47,26 @@ const fs: ReaderFs = {
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
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]?.external).toBeUndefined()
|
||||
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 () => {
|
||||
expect(await readCatalog(fs, '/h/../etc')).toBeNull()
|
||||
expect(await readCatalog(fs, 'relative/clone')).toBeNull()
|
||||
@@ -66,10 +87,59 @@ describe('plugin catalog reader', () => {
|
||||
const installed = new Set(['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, '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, '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
|
||||
* 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.
|
||||
* Plugin coverage: every ruflo plugin directory and every external plugin the marketplace lists has a home section in the
|
||||
* 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 { 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')))
|
||||
.map(entry => entry.name)
|
||||
.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', () => {
|
||||
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', () => {
|
||||
expect(onDisk.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(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 => !known.includes(name)), 'stale entries').toEqual([])
|
||||
})
|
||||
|
||||
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', () => {
|
||||
expect(homeOf('ruflo-cost-tracker')).toBe('cost')
|
||||
expect(homeOf('ruflo-ruos:deploy')).toBe('swarm')
|
||||
expect(homeOf('agentic-qe-fleet:aqe-generate')).toBe('devtools')
|
||||
expect(homeOf('not-a-plugin:thing')).toBeNull()
|
||||
expect(pluginsOfView('secure')).toEqual(['ruflo-aidefence', 'ruflo-protector', 'ruflo-security-audit'])
|
||||
})
|
||||
|
||||
@@ -84,6 +84,7 @@ scripts/__tests__/mod-capability-matrix.test.mjs
|
||||
tests/all-plugins-smoke-workflow.test.mjs
|
||||
tests/check-guard-tool-names.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/toasts.test.ts
|
||||
plugins/ruflo-console/tests/full-text.test.ts
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -9,6 +9,12 @@
|
||||
// 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-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
|
||||
// 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)
|
||||
// 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 { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkoutAt, externalSmokePlan, externalSourceProblem, makeWorkArea } from './external-plugins.mjs';
|
||||
|
||||
const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
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
|
||||
failFast: false, // if true, kill remaining smokes on first failure
|
||||
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++) {
|
||||
const v = process.argv[i];
|
||||
@@ -50,6 +58,7 @@ const ARGS = (() => {
|
||||
else if (v === '--timeout') a.timeoutSec = parseFloat(process.argv[++i]);
|
||||
else if (v === '--fail-fast') a.failFast = true;
|
||||
else if (v === '--skip-guard-probe') a.guardProbe = false;
|
||||
else if (v === '--skip-external') a.external = false;
|
||||
else if (v === '--only') {
|
||||
a.only = new Set((process.argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
} else if (v === '--skip') {
|
||||
@@ -83,10 +92,42 @@ function discoverPlugins() {
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
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 stderr = '';
|
||||
let timedOut = false;
|
||||
@@ -207,7 +248,8 @@ function runChangelogContract() {
|
||||
|
||||
async function main() {
|
||||
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?');
|
||||
process.exit(3);
|
||||
}
|
||||
@@ -245,6 +287,9 @@ async function main() {
|
||||
results = await Promise.all(pending);
|
||||
}
|
||||
|
||||
if (ARGS.external && !abortController?.signal.aborted) {
|
||||
for (const entry of external) results.push(await runExternal(entry));
|
||||
}
|
||||
results.push(runChangelogContract());
|
||||
if (ARGS.guardProbe && !abortController?.signal.aborted) results.push(runGuardProbe());
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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) | — |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user