diff --git a/.grok-plugin/README.md b/.grok-plugin/README.md index 0202310ee..c26b75d5a 100644 --- a/.grok-plugin/README.md +++ b/.grok-plugin/README.md @@ -11,10 +11,19 @@ thin subdirectory copy drops parent-relative runtime files. ## Install -ECC trusted install is `node scripts/grok-install.js` (`previewInstall` / -`applyInstall` in `scripts/lib/grok-harness-adapter.js`, install target `grok`, -state at `~/.grok/ecc/install-state.json`). That plan is receipt-backed and -requires explicit consent for hooks and each MCP server. +ECC installs through the canonical installer and receipt lifecycle: + +```bash +node scripts/grok-install.js --dry-run --trust --consent-hooks --consent-mcp chrome-devtools +node scripts/grok-install.js --trust --consent-hooks --consent-mcp chrome-devtools +node scripts/repair.js --target grok +node scripts/uninstall.js --target grok +``` + +`scripts/grok-install.js` is a thin alias for `install-apply.js --target grok +--profile full`; state stays at `~/.grok/ecc/install-state.json`. Upgrade and +rollback use the same canonical apply path; pass `--source-sha <40-hex>` to +reapply a previous pinned commit. Grok CLI marketplace add / install / enable is **discovery only**. `grok plugin install --trust` skips Grok's confirmation prompt; it is **not @@ -39,14 +48,14 @@ grok plugin validate /absolute/path/to/ECC Do not also run `./install.sh --target claude` (or a full manual Claude copy) into the same Grok session. Pick one install path per harness. -`./install.sh --target grok` does not copy hooks or root MCP without the same -adapter consent flags. +`./install.sh --target grok --profile full` does not copy hooks or root MCP +without `--trust` and the matching consent flags. ## Hooks Grok sets `GROK_PLUGIN_ROOT`. Shared hook code does not read that alias. -`applyInstall` maps it onto `PLUGIN_ROOT` at the installed-tree boundary when -hooks are consented. Without hook consent, the installed copy has no +The Grok target adapter maps it onto the shared root contract in the installed +hook configuration when hooks are consented. Without hook consent, the installed copy has no `hooks/hooks.json`. Grok does not honor Claude `userConfig`. Hook enablement and profile stay on @@ -62,8 +71,8 @@ export ECC_HOOK_PROFILE=standard # minimal | standard | strict `.grok-plugin/plugin.json` sets `"mcpServers": ""` so a trusted Grok plugin install does not attach repo-root `.mcp.json` or `chrome-devtools`. That root file remains for Claude/Codex. Additional connectors stay opt-in via -`mcp-configs/mcp-servers.json`. Adapter receipts still require explicit -per-server consent before any MCP name is copied into an install plan. +`mcp-configs/mcp-servers.json`. Canonical receipts record explicit per-server +consent before any MCP name is copied into an install plan. ## Known limits diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index 15bb0b614..01d13b03f 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ecc", "description": "Harness-native ECC operator layer - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Grok Build, Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", - "version": "2.2.0", + "version": "2.2.1", "author": { "name": "Affaan Mustafa", "email": "me@affaanmustafa.com" diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index 760095861..f8bae0102 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ecc", - "version": "2.2.0", + "version": "2.2.1", "description": "Harness-native ECC plugin for engineering teams - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Grok Build plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", diff --git a/commands/auto-update.md b/commands/auto-update.md index 5c077fca8..6b8533c05 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i pi) | Agent chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter (individual agent definitions are portable via scripts/agent-convert.js); Pi core has no MCP surface, though ECC MCP configs load verbatim through the community pi-mcp-adapter package, which ECC neither installs nor depends on | `pi install git:github.com/affaan-m/ECC`; `pi install /path/to/ECC` from a local checkout | `node tests/pi/pi-package-manifest.test.js`; `node tests/pi/pi-extension-adapter.test.js`; `node tests/pi/agent-convert.test.js`; `npm run harness:adapters -- --check` | Pi extensions execute with full user permissions, and hooks run without a shell and resolve from the installed package rather than the user project; Keep canonical skills and commands as the single source of truth, and never generate copies under .pi/ | | Cursor | Adapter-backed | Cursor rules; project-local skills; hook adapter; shared scripts | Cursor hook events and rule loading differ from Claude Code | `./install.sh --profile minimal --target cursor` | `node tests/lib/install-targets.test.js`; `npm run harness:audit -- --format json` | Cursor adapters must preserve existing project rules and avoid silent overwrite. | diff --git a/docs/ja-JP/commands/auto-update.md b/docs/ja-JP/commands/auto-update.md index c47fb65f5..167be9d0f 100644 --- a/docs/ja-JP/commands/auto-update.md +++ b/docs/ja-JP/commands/auto-update.md @@ -11,7 +11,7 @@ ECCをアップストリームリポジトリから更新し、元のインス ```bash # 何も変更せずに更新をプレビュー -ECC_ROOT="${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) { ```bash node -e " -const aa = require((function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i { parsed.plugins[0].description = description; } ), }, - { - filePath: grokPluginJsonPath, - parseExpectations: content => parseCatalogDescriptionExpectations( - content, - '.grok-plugin/plugin.json description', - parsed => parsed.description - ), - syncContent: (content, catalog) => syncCatalogDescription( - content, - catalog, - '.grok-plugin/plugin.json description', - parsed => parsed.description, - (parsed, description) => { parsed.description = description; } - ), - }, - { - filePath: grokMarketplaceJsonPath, - parseExpectations: content => parseCatalogDescriptionExpectations( - content, - '.grok-plugin/marketplace.json plugin description', - parsed => parsed.plugins?.[0]?.description - ), - syncContent: (content, catalog) => syncCatalogDescription( - content, - catalog, - '.grok-plugin/marketplace.json plugin description', - parsed => parsed.plugins?.[0]?.description, - (parsed, description) => { parsed.plugins[0].description = description; } - ), - }, ]; } @@ -763,8 +729,6 @@ function createDocumentSpecsForRoot(root) { zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'), pluginJsonPath: path.join(root, '.claude-plugin', 'plugin.json'), marketplaceJsonPath: path.join(root, '.claude-plugin', 'marketplace.json'), - grokPluginJsonPath: path.join(root, '.grok-plugin', 'plugin.json'), - grokMarketplaceJsonPath: path.join(root, '.grok-plugin', 'marketplace.json'), }); } diff --git a/scripts/grok-install.js b/scripts/grok-install.js index 7d9bd6090..c4dde1d9f 100644 --- a/scripts/grok-install.js +++ b/scripts/grok-install.js @@ -2,126 +2,38 @@ 'use strict'; const path = require('path'); -const { - parseMcpConsentList, - resolveHomeDir, - runGrokInstall, -} = require('./lib/grok-harness-adapter'); +const { spawnSync } = require('child_process'); -const IGNORED_VALUE_FLAGS = new Set([ - '--target', - '--profile', - '--modules', - '--with', - '--without', - '--config', - '--locale', - '--skill', - '--skills', -]); +const SELECTION_FLAGS = new Set(['--profile', '--modules', '--with', '--skill', '--skills', '--config']); -function printHelp() { - console.log(`Usage: node scripts/grok-install.js [--dry-run] [--json] [--trust] [--consent-hooks] [--consent-mcp ] - -ECC trusted Grok install. This is the consent/receipt path. -Grok CLI marketplace add/install/enable is discovery only. - -Options: - --dry-run Preview the plan without copying files - --json Emit JSON - --trust Mark the plan trusted (still requires per-capability consent) - --consent-hooks Consent to hooks - --consent-mcp Comma-separated MCP server names to attach - --help Show this help -`); -} - -function parseArgs(argv) { - const parsed = { - dryRun: false, - json: false, - trust: false, - help: false, - consent: { hooks: false, mcp: {} }, - }; - - for (let index = 2; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === '--dry-run') { - parsed.dryRun = true; - } else if (arg === '--json') { - parsed.json = true; - } else if (arg === '--trust') { - parsed.trust = true; - } else if (arg === '--consent-hooks') { - parsed.consent.hooks = true; - } else if (arg === '--consent-mcp') { - parsed.consent.mcp = parseMcpConsentList(argv[index + 1] || ''); +function canonicalArgs(argv = process.argv.slice(2)) { + const args = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--target') { index += 1; - } else if (IGNORED_VALUE_FLAGS.has(arg)) { - index += 1; - } else if (arg === '--help' || arg === '-h') { - parsed.help = true; - } else { - throw new Error(`Unknown argument: ${arg}`); + continue; } + args.push(argv[index]); } - - return parsed; -} - -function printHuman(result) { - const plan = result.plan; - console.log(result.dryRun ? 'Grok adapter dry-run plan\n' : 'Grok adapter install\n'); - console.log(`Identity: ${plan.identity}`); - console.log(`Trust: ${plan.trust}`); - console.log(`Hooks enabled: ${plan.hooksEnabled}`); - console.log(`MCP attached: ${plan.mcpAttached.join(', ') || '(none)'}`); - console.log(`Chrome DevTools: ${plan.attachChromeDevtools}`); - console.log(`Native MCP opted out: ${result.nativeMcpOptedOut}`); - console.log(`Native hooks opted out: ${result.nativeHooksOptedOut}`); - if (result.receipt) { - console.log(`Receipt: ${result.receipt.id}`); - console.log(`Install-state: ${result.receipt.installStatePath}`); - console.log(`Installed root: ${result.receipt.installedRoot}`); + if (!args.some((argument) => SELECTION_FLAGS.has(argument)) && !args.includes('--help') && !args.includes('-h')) { + args.unshift('--profile', 'full'); } + return ['--target', 'grok', ...args]; } function main() { - let parsed; - try { - parsed = parseArgs(process.argv); - } catch (error) { - process.stderr.write(`Error: ${error.message}\n`); - printHelp(); - process.exit(1); - } - - if (parsed.help) { - printHelp(); - return; - } - - const result = runGrokInstall({ - dryRun: parsed.dryRun, - trust: parsed.trust, - consent: parsed.consent, - homeDir: resolveHomeDir(), - repoRoot: path.join(__dirname, '..'), - }); - - if (parsed.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - printHuman(result); + const result = spawnSync( + process.execPath, + [path.join(__dirname, 'install-apply.js'), ...canonicalArgs()], + { stdio: 'inherit' } + ); + if (result.error) throw result.error; + process.exitCode = result.status === null ? 1 : result.status; } -if (require.main === module) { - main(); -} +if (require.main === module) main(); module.exports = { - parseArgs, + canonicalArgs, main, }; diff --git a/scripts/hooks/observe-runner.js b/scripts/hooks/observe-runner.js index 63f42b2c4..5213eb1e2 100644 --- a/scripts/hooks/observe-runner.js +++ b/scripts/hooks/observe-runner.js @@ -13,9 +13,6 @@ function getPluginRoot(options = {}) { if (options.pluginRoot && String(options.pluginRoot).trim()) { return String(options.pluginRoot).trim(); } - if (process.env.GROK_PLUGIN_ROOT && process.env.GROK_PLUGIN_ROOT.trim()) { - return process.env.GROK_PLUGIN_ROOT.trim(); - } if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT.trim(); } @@ -136,7 +133,6 @@ function run(raw, options = {}) { encoding: 'utf8', env: { ...process.env, - GROK_PLUGIN_ROOT: pluginRoot, CLAUDE_PLUGIN_ROOT: pluginRoot, ECC_PLUGIN_ROOT: pluginRoot }, diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index a1a0c0650..713f882b6 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -173,7 +173,6 @@ function spawnNode(rootDir, relPath, raw, args, options = {}) { ensureAgentDataHomeEnv(); const hookEnv = { ...process.env, - GROK_PLUGIN_ROOT: rootDir, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), @@ -207,7 +206,6 @@ function spawnShell(rootDir, relPath, raw, args, options = {}) { ensureAgentDataHomeEnv(); const hookEnv = { ...process.env, - GROK_PLUGIN_ROOT: rootDir, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), @@ -268,7 +266,7 @@ async function main() { contextScanner.push(contextDecoder.end()); const hookContext = contextScanner.context; const rootDir = normalizePluginRootForPlatform( - process.env.GROK_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT + process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT ); if (!mode || !relPath || !rootDir) { diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index dc2ecb5e5..e81d1fb37 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -69,7 +69,7 @@ const ASYNC_HOOKS = [ ]; function getPluginRoot(env = process.env) { - return env.GROK_PLUGIN_ROOT || env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..'); + return env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..'); } function matchesTool(matcher, toolName) { diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 86a975806..00c666308 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -142,9 +142,6 @@ function truncatedInputResult(hookId, maxStdin) { } function getPluginRoot() { - if (process.env.GROK_PLUGIN_ROOT && process.env.GROK_PLUGIN_ROOT.trim()) { - return process.env.GROK_PLUGIN_ROOT; - } if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT; } @@ -322,7 +319,6 @@ async function main() { encoding: 'utf8', env: { ...process.env, - GROK_PLUGIN_ROOT: pluginRoot, CLAUDE_PLUGIN_ROOT: pluginRoot, ECC_PLUGIN_ROOT: pluginRoot, ECC_HOOK_ID: hookId, diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 78e3e08a5..97b657870 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -21,16 +21,6 @@ const { getComputeSponsorCopy } = require('./lib/compute-sponsor'); const { stripAnsi } = require('./lib/utils'); const { describeMissingDependencyError } = require('./lib/missing-dependency'); -function argvTarget(argv) { - const args = argv.slice(2); - for (let index = 0; index < args.length; index += 1) { - if (args[index] === '--target') { - return args[index + 1] || null; - } - } - return null; -} - function getHelpText() { const languages = listLegacyCompatibilityLanguages(); const locales = listSupportedLocales(); @@ -58,7 +48,7 @@ Targets: zed - Install project settings, commands, agents, skills, and flattened rules into ./.zed/ hermes - Install shared rules/skills/commands into ~/.hermes/ kimi - Install Kimi Code project instructions, skills, and MCP config into ./.kimi-code/ (ECC hooks not configured) - grok - ECC Grok adapter install (previewInstall/applyInstall). Discovery-only Grok CLI is separate. + grok - Install through the canonical plan/apply/receipt lifecycle into ~/.grok/plugins/ecc/ openclaw - Install shared rules/skills/commands into ~/.openclaw/ adal - Install shared rules/skills/commands into ./.adal/ @@ -75,6 +65,10 @@ Options: --enable-hooks Confirm installing the automatic hook runtime (required when the selected profile/modules materialize hooks) --no-hooks Install everything except the automatic hook runtime + --trust Trust executable Grok capabilities (still requires each consent flag) + --consent-hooks Allow Grok hooks for this install + --consent-mcp Allow named Grok MCP servers, comma-separated + --source-sha Install or roll back Grok to an exact 40-character Git commit --dry-run Show the install plan without copying files --json Emit machine-readable plan/result JSON --help Show this help text @@ -166,46 +160,8 @@ function printHumanPlan(plan, dryRun) { } async function main() { + let rawPlan = null; try { - if (argvTarget(process.argv) === 'grok') { - const { parseArgs } = require('./grok-install'); - const { parseMcpConsentList, resolveHomeDir, runGrokInstall } = require('./lib/grok-harness-adapter'); - const parsed = parseArgs(process.argv); - if (parsed.help) { - showHelp(0); - } - const grokResult = runGrokInstall({ - dryRun: parsed.dryRun === true, - trust: parsed.trust === true || process.env.ECC_GROK_TRUST === '1', - consent: { - hooks: parsed.consent.hooks === true || process.env.ECC_GROK_CONSENT_HOOKS === '1', - mcp: { - ...parseMcpConsentList(process.env.ECC_GROK_CONSENT_MCP || ''), - ...parsed.consent.mcp, - }, - }, - homeDir: resolveHomeDir(), - repoRoot: process.cwd(), - }); - const options = { dryRun: parsed.dryRun, json: parsed.json }; - if (options.json) { - console.log(JSON.stringify({ dryRun: options.dryRun === true, grok: grokResult }, null, 2)); - } else { - console.log(options.dryRun ? 'Grok adapter dry-run plan\n' : 'Grok adapter install\n'); - console.log(`Identity: ${grokResult.plan.identity}`); - console.log(`Trust: ${grokResult.plan.trust}`); - console.log(`Hooks enabled: ${grokResult.plan.hooksEnabled}`); - console.log(`MCP attached: ${grokResult.plan.mcpAttached.join(', ') || '(none)'}`); - console.log(`Chrome DevTools: ${grokResult.plan.attachChromeDevtools}`); - if (grokResult.receipt) { - console.log(`Install-state: ${grokResult.receipt.installStatePath}`); - } - console.log('\nConsent flags: ECC_GROK_TRUST=1 ECC_GROK_CONSENT_HOOKS=1 ECC_GROK_CONSENT_MCP=name,name'); - console.log('Or: node scripts/grok-install.js --dry-run --trust --consent-hooks'); - } - return; - } - const options = parseInstallArgs(process.argv); if (options.help) { @@ -232,7 +188,7 @@ async function main() { config, }); - const rawPlan = createInstallPlanFromRequest(request, { + rawPlan = createInstallPlanFromRequest(request, { projectRoot: process.cwd(), homeDir: process.env.HOME || os.homedir(), env: process.env, @@ -267,13 +223,12 @@ async function main() { printHumanPlan(result, false); } } catch (error) { - const missingDependencyMessage = describeMissingDependencyError(error); - process.stderr.write( - missingDependencyMessage - ? `Error: ${missingDependencyMessage}\n` - : `Error: ${error.message}${getHelpText()}` - ); - process.exit(1); + process.stderr.write(`Error: ${error.message}${getHelpText()}`); + process.exitCode = 1; + } finally { + if (rawPlan && typeof rawPlan.dispose === 'function') { + rawPlan.dispose(); + } } } diff --git a/scripts/lib/grok-harness-adapter.js b/scripts/lib/grok-harness-adapter.js index 420265e79..1af7a4727 100644 --- a/scripts/lib/grok-harness-adapter.js +++ b/scripts/lib/grok-harness-adapter.js @@ -4,35 +4,13 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { - createInstallState, - readInstallState, - writeInstallState, -} = require('./install-state'); const grokHomeTarget = require('./install-targets/grok-home'); -const { - isGitWorkTreeRoot, - resolvePinnedGitSha, - copyInstallSource, - assertSourceIdentity, - writePinnedMarketplace, -} = require('./grok-source-identity'); +const { SHA_PATTERN } = require('./grok-source-identity'); const GROK_PLUGIN_ROOT_ENV = 'GROK_PLUGIN_ROOT'; const GROK_HOME_DIRNAME = '.grok'; -const INSTALLED_PLUGINS_DIR = 'installed-plugins'; -const PREVIOUS_STATE_FILE = 'install-state.previous.json'; const SOURCE_IDENTITY_FILE = '.ecc-source.json'; -const SHA_PATTERN = /^[0-9a-f]{40}$/; -const HOOKS_CAPABILITY_ID = 'hooks'; -const CHROME_DEVTOOLS_MCP = 'chrome-devtools'; const CURRENT_PLUGIN_SLUG = 'ecc'; -const PLUGIN_CACHE_SLUGS = [CURRENT_PLUGIN_SLUG, 'everything-claude-code']; -const PLUGIN_ROOT_SEGMENTS = [ - [CURRENT_PLUGIN_SLUG], - [`${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`], - ['marketplaces', CURRENT_PLUGIN_SLUG], -]; const DEFAULT_SCRIPT_PROBE = path.join('scripts', 'lib', 'utils.js'); const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2'); const SHARED_PLUGIN_ROOT_KEYS = Object.freeze([ @@ -49,163 +27,29 @@ function grokPluginRootFromEnv(env = {}) { return trimEnv(env[GROK_PLUGIN_ROOT_ENV]); } -function hasSharedPluginRoot(env = {}) { - return SHARED_PLUGIN_ROOT_KEYS.some((key) => trimEnv(env[key])); -} - -/** - * Map Grok-only env onto the harness-neutral PLUGIN_ROOT without leaving - * GROK_PLUGIN_ROOT for shared hook/root code to select. - */ function toSharedPluginEnv(env = {}) { const next = { ...env }; const grokRoot = grokPluginRootFromEnv(env); delete next[GROK_PLUGIN_ROOT_ENV]; - if (grokRoot && !hasSharedPluginRoot(next)) { + if (grokRoot && !SHARED_PLUGIN_ROOT_KEYS.some((key) => trimEnv(next[key]))) { next.PLUGIN_ROOT = grokRoot; } return next; } -/** - * Grok-facing plugin.json `mcpServers: ""` opts the native trusted plugin - * surface out of repo-root `.mcp.json` (including chrome-devtools). - */ -function nativePluginMcpOptedOut(pluginManifest = {}) { - const value = pluginManifest.mcpServers; - if (value === '' || value === undefined || value === null) { - return true; - } - if (Array.isArray(value)) { - return value.length === 0; - } - if (typeof value === 'object') { - return Object.keys(value).length === 0; - } - return false; -} - -function nativeTrustedMcpServerNames(pluginManifest = {}) { - if (nativePluginMcpOptedOut(pluginManifest)) { - return []; - } - if (pluginManifest.mcpServers && typeof pluginManifest.mcpServers === 'object' - && !Array.isArray(pluginManifest.mcpServers)) { - return Object.keys(pluginManifest.mcpServers); - } - return []; -} - -function nativeTrustedInstallAttachesChromeDevtools(pluginManifest = {}) { - return nativeTrustedMcpServerNames(pluginManifest).includes(CHROME_DEVTOOLS_MCP); -} - -function readGrokPluginManifest(pluginJsonPath, readFile = (file) => fs.readFileSync(file, 'utf8')) { - return JSON.parse(readFile(pluginJsonPath)); -} - function resolveHomeDir(env = process.env) { - const explicit = trimEnv(env.ECC_GROK_HOME); - if (explicit) { - return explicit; - } - return os.homedir(); + return trimEnv(env.ECC_GROK_HOME) || os.homedir(); } function grokHomeDir(homeDir, pathModule = path) { - if (pathModule === path) { - return grokHomeTarget.resolveRoot({ homeDir }); - } return pathModule.join(homeDir, GROK_HOME_DIRNAME); } function grokInstallStatePath(homeDir, pathModule = path) { - if (pathModule === path) { - return grokHomeTarget.getInstallStatePath({ homeDir }); - } + if (pathModule === path) return grokHomeTarget.getInstallStatePath({ homeDir }); return pathModule.join(homeDir, GROK_HOME_DIRNAME, 'ecc', 'install-state.json'); } -function grokPreviousStatePath(homeDir, pathModule = path) { - return pathModule.join(pathModule.dirname(grokInstallStatePath(homeDir, pathModule)), PREVIOUS_STATE_FILE); -} - -function normalizeContainedPath(candidate, pathModule = path) { - const raw = String(candidate || ''); - if (!raw) { - return ''; - } - return pathModule.normalize(raw); -} - -function isContained(candidate, root, pathModule = path) { - const resolvedRoot = pathModule.resolve(normalizeContainedPath(root, pathModule)); - const resolvedCandidate = pathModule.resolve(resolvedRoot, normalizeContainedPath(candidate, pathModule)); - if (resolvedCandidate === resolvedRoot) { - return true; - } - const prefix = resolvedRoot.endsWith(pathModule.sep) - ? resolvedRoot - : resolvedRoot + pathModule.sep; - return resolvedCandidate.startsWith(prefix); -} - -function assertRootContainment(candidate, root, pathModule = path) { - if (!isContained(candidate, root, pathModule)) { - throw new Error(`install path escapes Grok root: ${candidate}`); - } - return true; -} - -function assertPinnedSource(source) { - if (!source || typeof source !== 'object') { - throw new Error('marketplace source must be an object'); - } - if (source.source !== 'url') { - throw new Error('marketplace source must be type url'); - } - if (!source.url || typeof source.url !== 'string') { - throw new Error('marketplace source must include a Git URL'); - } - if (!SHA_PATTERN.test(source.sha || '')) { - throw new Error('marketplace source must pin a 40-character lowercase commit SHA'); - } - return { - source: 'url', - url: source.url, - sha: source.sha, - }; -} - -function sourceIdentity(source) { - const pinned = assertPinnedSource(source); - return `${pinned.url}@${pinned.sha}`; -} - -function readMarketplaceSource(catalogPath, readFile = (file) => fs.readFileSync(file, 'utf8')) { - const catalog = JSON.parse(readFile(catalogPath)); - const plugin = catalog && Array.isArray(catalog.plugins) ? catalog.plugins[0] : null; - if (!plugin) { - throw new Error('marketplace catalog has no plugins'); - } - return { - name: plugin.name, - version: plugin.version, - source: assertPinnedSource(plugin.source), - }; -} - -function listMcpCapabilities(mcpConfig) { - const servers = mcpConfig && mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object' - ? mcpConfig.mcpServers - : {}; - return Object.keys(servers).map((name) => ({ - id: name, - kind: 'mcp', - command: servers[name] && servers[name].command, - })); -} - function readJsonIfPresent(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -215,310 +59,25 @@ function readJsonIfPresent(filePath) { } function isCompleteRoot(dir, probe, pathModule = path) { - if (probe) { - return fs.existsSync(pathModule.join(dir, probe)); - } + if (probe) return fs.existsSync(pathModule.join(dir, probe)); return fs.existsSync(pathModule.join(dir, DEFAULT_SCRIPT_PROBE)) && fs.existsSync(pathModule.join(dir, DEFAULT_SKILL_PROBE)); } -function findRootInGrokHome(homeDir, probe, pathModule = path) { - const vendorDir = grokHomeDir(homeDir, pathModule); - if (isCompleteRoot(vendorDir, probe, pathModule)) { - return vendorDir; - } - - for (const segments of PLUGIN_ROOT_SEGMENTS) { - const candidate = pathModule.join(vendorDir, 'plugins', ...segments); - if (isCompleteRoot(candidate, probe, pathModule)) { - return candidate; - } - } - - try { - for (const slug of PLUGIN_CACHE_SLUGS) { - const cacheBase = pathModule.join(vendorDir, 'plugins', 'cache', slug); - const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true }); - for (const orgEntry of orgDirs) { - if (!orgEntry.isDirectory()) continue; - const orgPath = pathModule.join(cacheBase, orgEntry.name); - let versionDirs; - try { - versionDirs = fs.readdirSync(orgPath, { withFileTypes: true }); - } catch { - continue; - } - for (const verEntry of versionDirs) { - if (!verEntry.isDirectory()) continue; - const candidate = pathModule.join(orgPath, verEntry.name); - if (isCompleteRoot(candidate, probe, pathModule)) { - return candidate; - } - } - } - } - } catch { - // Grok plugin cache is optional. - } - - try { - const installedBase = pathModule.join(vendorDir, INSTALLED_PLUGINS_DIR); - const installedDirs = fs.readdirSync(installedBase, { withFileTypes: true }); - for (const entry of installedDirs) { - if (!entry.isDirectory()) continue; - const candidate = pathModule.join(installedBase, entry.name); - if (isCompleteRoot(candidate, probe, pathModule)) { - return candidate; - } - } - } catch { - // installed-plugins is Grok-specific and optional. - } - - return null; -} - -function receiptIdFromState(state) { - const sha = state && state.source && state.source.repoCommit - ? String(state.source.repoCommit).slice(0, 12) - : 'unknown'; - return `ecc-${sha}-${state.installedAt}`; -} - -function receiptFromInstallState(state) { - if (!state) { - return null; - } - const copyOp = (state.operations || []).find((operation) => operation.moduleId === 'ecc'); - const mcpAttached = (state.operations || []) - .filter((operation) => operation.kind === 'mcp-attach') - .map((operation) => operation.moduleId); - const hooksOp = (state.operations || []).find((operation) => operation.moduleId === 'hooks'); - const sourcePin = (state.operations || []).find((operation) => operation.kind === 'source-pin'); - const enabled = Array.isArray(state.resolution.selectedModules) - && state.resolution.selectedModules.includes(CURRENT_PLUGIN_SLUG); - return { - id: receiptIdFromState(state), - pluginId: CURRENT_PLUGIN_SLUG, - version: state.source.repoVersion, - source: { - source: 'url', - url: sourcePin ? sourcePin.destinationPath : '', - sha: state.source.repoCommit, - }, - identity: sourcePin - ? `${sourcePin.destinationPath}@${state.source.repoCommit}` - : state.source.repoCommit, - installedRoot: copyOp ? copyOp.destinationPath : state.target.root, - enabled, - trust: (state.operations || []).some((operation) => operation.strategy === 'consent-allow'), - capabilities: (state.operations || []) - .filter((operation) => operation.moduleId === 'hooks' || operation.kind === 'mcp-attach' || operation.kind === 'mcp-omit') - .map((operation) => ({ - id: operation.moduleId, - kind: operation.moduleId === 'hooks' ? 'hooks' : 'mcp', - consented: operation.strategy === 'consent-allow', - })), - hooksEnabled: Boolean(hooksOp && hooksOp.strategy === 'consent-allow'), - mcpAttached, - previousReceiptId: null, - createdAt: state.installedAt, - operation: (state.operations || []).some((operation) => operation.kind === 'rollback') - ? 'rollback' - : (state.operations || []).some((operation) => operation.kind === 'uninstall') - ? 'uninstall' - : 'install', - installStatePath: state.target.installStatePath, - schemaVersion: state.schemaVersion, - }; -} - -function loadCurrentReceipt(homeDir, pathModule = path) { - const statePath = grokInstallStatePath(homeDir, pathModule); - if (!fs.existsSync(statePath)) { - return null; - } - try { - const state = readInstallState(statePath); - const receipt = receiptFromInstallState(state); - const previousPath = grokPreviousStatePath(homeDir, pathModule); - if (fs.existsSync(previousPath)) { - try { - const previous = readInstallState(previousPath); - receipt.previousReceiptId = receiptIdFromState(previous); - } catch { - receipt.previousReceiptId = null; - } - } - return receipt; - } catch { - return null; - } -} - -function installDestination(homeDir, plan, pathModule = path) { - const grokRoot = grokHomeDir(homeDir, pathModule); - const dest = pathModule.join( - grokRoot, - INSTALLED_PLUGINS_DIR, - `${plan.pluginId || CURRENT_PLUGIN_SLUG}-${String(plan.source.sha).slice(0, 12)}` - ); - assertRootContainment(dest, grokRoot, pathModule); - return dest; -} - -function readInstalledSharedEnv(installedRoot, pathModule = path) { - return readJsonIfPresent(pathModule.join(installedRoot, '.grok-plugin', 'shared-env.json')); -} - -function listManagedInstallRoots(homeDir, pathModule = path) { - const roots = []; - const base = pathModule.join(grokHomeDir(homeDir, pathModule), INSTALLED_PLUGINS_DIR); - try { - const entries = fs.readdirSync(base, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const installedRoot = pathModule.join(base, entry.name); - if (readJsonIfPresent(pathModule.join(installedRoot, SOURCE_IDENTITY_FILE))) { - roots.push(installedRoot); - } - } - } catch { - return roots; - } - return roots; -} - -function planToOperations(plan, dest, pathModule) { - const operations = [ - { - kind: 'copy-path', - moduleId: CURRENT_PLUGIN_SLUG, - sourceRelativePath: '.', - destinationPath: dest, - strategy: 'preserve-relative-path', - ownership: 'managed', - scaffoldOnly: false, - }, - { - kind: 'source-pin', - moduleId: 'marketplace', - sourceRelativePath: '.grok-plugin/marketplace.json', - destinationPath: plan.source.url, - strategy: 'pin-sha', - ownership: 'managed', - scaffoldOnly: false, - }, - { - kind: plan.hooksEnabled ? 'copy-path' : 'omit-path', - moduleId: 'hooks', - sourceRelativePath: 'hooks/hooks.json', - destinationPath: pathModule.join(dest, 'hooks', 'hooks.json'), - strategy: plan.hooksEnabled ? 'consent-allow' : 'consent-deny', - ownership: 'managed', - scaffoldOnly: false, - }, - ]; - for (const capability of plan.capabilities.filter((item) => item.kind === 'mcp')) { - operations.push({ - kind: capability.consented ? 'mcp-attach' : 'mcp-omit', - moduleId: capability.id, - sourceRelativePath: '.mcp.json', - destinationPath: pathModule.join(dest, '.mcp.json'), - strategy: capability.consented ? 'consent-allow' : 'consent-deny', - ownership: 'managed', - scaffoldOnly: false, - }); - } - return operations; -} - -function writeGrokInstallState(homeDir, plan, dest, now, pathModule, extraOperations = []) { - const installStatePath = grokInstallStatePath(homeDir, pathModule); - const operations = planToOperations(plan, dest, pathModule).concat(extraOperations); - const state = createInstallState({ - installedAt: now, - adapter: { - id: grokHomeTarget.id, - target: grokHomeTarget.target, - kind: grokHomeTarget.kind, - }, - targetRoot: grokHomeDir(homeDir, pathModule), - installStatePath, - request: { - profile: null, - modules: plan.enabled !== false ? [CURRENT_PLUGIN_SLUG] : [], - includeComponents: plan.mcpAttached.slice(), - excludeComponents: plan.hooksEnabled ? [] : ['hooks'], - legacyLanguages: [], - legacyMode: false, - }, - resolution: { - selectedModules: plan.enabled !== false ? [CURRENT_PLUGIN_SLUG] : [], - skippedModules: plan.enabled !== false ? [] : [CURRENT_PLUGIN_SLUG], - }, - source: { - repoVersion: plan.version || null, - repoCommit: plan.source.sha, - manifestVersion: 1, - }, - operations, - }); - writeInstallState(installStatePath, state); - return state; -} - -function resolveGrokPluginRoot(options = {}) { - const pathModule = options.pathModule || path; - const env = options.env || {}; - const envRoot = options.envRoot !== undefined - ? trimEnv(options.envRoot) - : grokPluginRootFromEnv(env); - if (envRoot) { - return envRoot; - } - - const homeDir = options.homeDir || os.homedir(); - const requireEnabled = options.requireEnabled !== false; - const current = loadCurrentReceipt(homeDir, pathModule); - if (requireEnabled && current && current.enabled === false) { - return null; - } - - const pinnedSha = options.pinnedSha || (options.source && options.source.sha) || ''; - if (pinnedSha) { - const pinned = selectPinnedCachedVersion( - listCachedGrokVersions(homeDir, pathModule), - pinnedSha - ); - if (pinned && isCompleteRoot(pinned.installedRoot, options.probe, pathModule)) { - return pinned.installedRoot; - } - } - - if (current && current.installedRoot && isCompleteRoot(current.installedRoot, options.probe, pathModule)) { - return current.installedRoot; - } - - return findRootInGrokHome(homeDir, options.probe, pathModule); -} - function listCachedGrokVersions(homeDir, pathModule = path) { const versions = []; const cacheBase = pathModule.join(grokHomeDir(homeDir, pathModule), 'plugins', 'cache', CURRENT_PLUGIN_SLUG); try { - const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true }); - for (const orgEntry of orgDirs) { + for (const orgEntry of fs.readdirSync(cacheBase, { withFileTypes: true })) { if (!orgEntry.isDirectory()) continue; const orgPath = pathModule.join(cacheBase, orgEntry.name); - const versionDirs = fs.readdirSync(orgPath, { withFileTypes: true }); - for (const verEntry of versionDirs) { - if (!verEntry.isDirectory()) continue; - const installedRoot = pathModule.join(orgPath, verEntry.name); + for (const versionEntry of fs.readdirSync(orgPath, { withFileTypes: true })) { + if (!versionEntry.isDirectory()) continue; + const installedRoot = pathModule.join(orgPath, versionEntry.name); const identity = readJsonIfPresent(pathModule.join(installedRoot, SOURCE_IDENTITY_FILE)); versions.push({ org: orgEntry.name, - version: verEntry.name, + version: versionEntry.name, installedRoot, sha: identity && identity.sha, }); @@ -531,331 +90,64 @@ function listCachedGrokVersions(homeDir, pathModule = path) { } function selectPinnedCachedVersion(versions, sha) { - if (!SHA_PATTERN.test(sha || '')) { - return null; - } + if (!SHA_PATTERN.test(sha || '')) return null; return versions.find((entry) => entry.sha === sha) || null; } -function capabilityConsented(consent, capability) { - if (!consent || typeof consent !== 'object') { - return false; +function findRootInGrokHome(homeDir, probe, pathModule = path) { + const grokRoot = grokHomeDir(homeDir, pathModule); + const candidates = [ + pathModule.join(grokRoot, 'plugins', CURRENT_PLUGIN_SLUG), + pathModule.join(grokRoot, 'plugins', `${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`), + pathModule.join(grokRoot, 'plugins', 'marketplaces', CURRENT_PLUGIN_SLUG), + ]; + for (const candidate of candidates) { + if (isCompleteRoot(candidate, probe, pathModule)) return candidate; } - if (capability.kind === 'hooks') { - return consent.hooks === true; - } - if (capability.kind === 'mcp') { - const mcp = consent.mcp && typeof consent.mcp === 'object' ? consent.mcp : {}; - return mcp[capability.id] === true; - } - return false; -} - -function previewInstall(options = {}) { - const pathModule = options.pathModule || path; - const source = assertPinnedSource(options.source); - const trust = options.trust === true; - const consent = options.consent && typeof options.consent === 'object' ? options.consent : {}; - const mcpCapabilities = listMcpCapabilities(options.mcpConfig || {}); - const capabilities = [ - { id: HOOKS_CAPABILITY_ID, kind: 'hooks' }, - ...mcpCapabilities, - ].map((capability) => { - const consented = trust && capabilityConsented(consent, capability); - return { - ...capability, - consented, - }; - }); - - const mcpAttached = capabilities - .filter((capability) => capability.kind === 'mcp' && capability.consented) - .map((capability) => capability.id); - const hooksEnabled = capabilities.some( - (capability) => capability.id === HOOKS_CAPABILITY_ID && capability.consented - ); - - return { - kind: 'grok-install-plan', - pluginId: options.pluginId || CURRENT_PLUGIN_SLUG, - version: options.version || null, - trust, - enabled: options.enabled !== false, - source, - identity: sourceIdentity(source), - capabilities, - hooksEnabled, - mcpAttached, - attachRootMcp: mcpAttached.length > 0, - attachChromeDevtools: mcpAttached.includes(CHROME_DEVTOOLS_MCP), - homeDir: options.homeDir, - sourceRoot: options.sourceRoot || null, - pathModule: pathModule === path.win32 ? 'win32' : 'posix', - }; -} - -function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function filterMcpConfig(mcpConfig, attachedNames) { - const servers = mcpConfig && mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object' - ? mcpConfig.mcpServers - : {}; - const mcpServers = {}; - for (const name of attachedNames) { - if (servers[name]) { - mcpServers[name] = servers[name]; + for (const dirname of ['installed-plugins', 'plugins']) { + const base = pathModule.join(grokRoot, dirname); + try { + for (const entry of fs.readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = pathModule.join(base, entry.name); + if (isCompleteRoot(candidate, probe, pathModule)) return candidate; + } + } catch { + // Optional native discovery root. } } - return { mcpServers }; + return null; } -const SHARED_INLINE_ENV = 'process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT'; -const INSTALLED_INLINE_ENV = `${SHARED_INLINE_ENV}||process.env.GROK_PLUGIN_ROOT`; - -function applyInstalledHookBoundary(dest, pathModule) { - const mapped = toSharedPluginEnv({ GROK_PLUGIN_ROOT: dest }); - writeJson(pathModule.join(dest, '.grok-plugin', 'shared-env.json'), { - PLUGIN_ROOT: mapped.PLUGIN_ROOT, - }); - const destHooks = pathModule.join(dest, 'hooks', 'hooks.json'); - if (!fs.existsSync(destHooks)) { - return; - } - const text = fs.readFileSync(destHooks, 'utf8'); - fs.writeFileSync(destHooks, text.split(SHARED_INLINE_ENV).join(INSTALLED_INLINE_ENV)); -} - -function applyInstall(plan, options = {}) { - if (!plan || plan.kind !== 'grok-install-plan') { - throw new Error('applyInstall requires a grok-install-plan'); - } +function resolveGrokPluginRoot(options = {}) { const pathModule = options.pathModule || path; - const homeDir = plan.homeDir || options.homeDir; - if (!homeDir) { - throw new Error('homeDir is required to apply a Grok install'); + const envRoot = options.envRoot !== undefined + ? trimEnv(options.envRoot) + : grokPluginRootFromEnv(options.env || {}); + if (envRoot) return envRoot; + if (options.enabled === false) return null; + + const homeDir = options.homeDir || os.homedir(); + const pinnedSha = options.pinnedSha || (options.source && options.source.sha) || ''; + if (pinnedSha) { + const pinned = selectPinnedCachedVersion(listCachedGrokVersions(homeDir, pathModule), pinnedSha); + if (pinned && isCompleteRoot(pinned.installedRoot, options.probe, pathModule)) { + return pinned.installedRoot; + } } - const dest = installDestination(homeDir, plan, pathModule); - - if (!plan.sourceRoot) { - throw new Error('sourceRoot is required to apply a Grok install'); - } - assertSourceIdentity(plan.sourceRoot, plan.source, pathModule); - - fs.mkdirSync(dest, { recursive: true }); - copyInstallSource(plan.sourceRoot, dest, pathModule, plan.source.sha); - writeJson(pathModule.join(dest, SOURCE_IDENTITY_FILE), plan.source); - writePinnedMarketplace(dest, plan.source, pathModule); - writeJson( - pathModule.join(dest, '.mcp.json'), - filterMcpConfig(options.mcpConfig || plan.mcpConfig, plan.mcpAttached) - ); - - if (!plan.hooksEnabled) { - fs.rmSync(pathModule.join(dest, 'hooks'), { recursive: true, force: true }); - } else { - applyInstalledHookBoundary(dest, pathModule); - } - - const now = options.now || new Date().toISOString(); - const statePath = grokInstallStatePath(homeDir, pathModule); - const previousPath = grokPreviousStatePath(homeDir, pathModule); - if (fs.existsSync(statePath)) { - fs.mkdirSync(pathModule.dirname(previousPath), { recursive: true }); - fs.copyFileSync(statePath, previousPath); - } - - writeGrokInstallState(homeDir, plan, dest, now, pathModule); - const receipt = loadCurrentReceipt(homeDir, pathModule); - receipt.operation = fs.existsSync(previousPath) ? 'upgrade' : 'install'; - receipt.trust = plan.trust === true; - return receipt; -} - -function uninstall(homeDir, options = {}) { - const pathModule = options.pathModule || path; - const current = loadCurrentReceipt(homeDir, pathModule); - if (!current) { - return { operation: 'uninstall', removed: false }; - } - const managedRoots = listManagedInstallRoots(homeDir, pathModule); - for (const installedRoot of managedRoots) { - fs.rmSync(installedRoot, { recursive: true, force: true }); - } - const statePath = grokInstallStatePath(homeDir, pathModule); - const previousPath = grokPreviousStatePath(homeDir, pathModule); - fs.rmSync(statePath, { force: true }); - fs.rmSync(previousPath, { force: true }); - return { - ...current, - enabled: false, - operation: 'uninstall', - removedRoots: managedRoots, - uninstalledAt: options.now || new Date().toISOString(), - }; -} - -function rollback(homeDir, options = {}) { - const pathModule = options.pathModule || path; - const current = loadCurrentReceipt(homeDir, pathModule); - const previousPath = grokPreviousStatePath(homeDir, pathModule); - if (!current || !fs.existsSync(previousPath)) { - throw new Error('no previous receipt to roll back to'); - } - const previousState = readInstallState(previousPath); - const previousReceipt = receiptFromInstallState(previousState); - if (!previousReceipt.installedRoot || !fs.existsSync(previousReceipt.installedRoot)) { - throw new Error('previous install is not available for rollback'); - } - const statePath = grokInstallStatePath(homeDir, pathModule); - fs.copyFileSync(previousPath, statePath); - fs.rmSync(previousPath, { force: true }); - if (current.installedRoot && current.installedRoot !== previousReceipt.installedRoot) { - fs.rmSync(current.installedRoot, { recursive: true, force: true }); - } - return { - ...loadCurrentReceipt(homeDir, pathModule), - operation: 'rollback', - rolledBackFrom: current.id, - }; -} - -function setPluginEnabled(homeDir, enabled, options = {}) { - const pathModule = options.pathModule || path; - const statePath = grokInstallStatePath(homeDir, pathModule); - if (!fs.existsSync(statePath)) { - throw new Error('no current Grok install to enable or disable'); - } - const state = readInstallState(statePath); - const next = createInstallState({ - installedAt: state.installedAt, - adapter: { - id: state.target.id, - target: state.target.target, - kind: state.target.kind, - }, - targetRoot: state.target.root, - installStatePath: state.target.installStatePath, - request: { - ...state.request, - modules: enabled ? [CURRENT_PLUGIN_SLUG] : [], - }, - resolution: { - selectedModules: enabled ? [CURRENT_PLUGIN_SLUG] : [], - skippedModules: enabled ? [] : [CURRENT_PLUGIN_SLUG], - }, - source: state.source, - operations: state.operations, - }); - writeInstallState(statePath, next); - return loadCurrentReceipt(homeDir, pathModule); -} - -function parseMcpConsentList(raw) { - const mcp = {}; - String(raw || '') - .split(',') - .map((name) => name.trim()) - .filter(Boolean) - .forEach((name) => { - mcp[name] = true; - }); - return mcp; -} - -function resolveRepoRoot(candidate) { - const start = candidate || path.join(__dirname, '..', '..'); - try { - const { execFileSync } = require('child_process'); - return execFileSync('git', ['-C', start, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch { - return start; - } -} - -function runGrokInstall(options = {}) { - const repoRoot = resolveRepoRoot(options.repoRoot || path.join(__dirname, '..', '..')); - const homeDir = options.homeDir || resolveHomeDir(); - const marketplace = readMarketplaceSource(path.join(repoRoot, '.grok-plugin', 'marketplace.json')); - const pluginManifest = readGrokPluginManifest(path.join(repoRoot, '.grok-plugin', 'plugin.json')); - let mcpConfig = { mcpServers: {} }; - try { - mcpConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, '.mcp.json'), 'utf8')); - } catch { - mcpConfig = { mcpServers: {} }; - } - - const plan = previewInstall({ - source: marketplace.source, - version: marketplace.version, - sourceRoot: repoRoot, - homeDir, - trust: options.trust === true, - consent: options.consent && typeof options.consent === 'object' ? options.consent : {}, - mcpConfig, - enabled: true, - }); - - const result = { - kind: 'grok-install-result', - dryRun: options.dryRun === true, - plan, - nativeMcpOptedOut: nativePluginMcpOptedOut(pluginManifest), - nativeHooksOptedOut: pluginManifest.hooks === '', - }; - - if (options.dryRun) { - return result; - } - - result.receipt = applyInstall(plan, { mcpConfig, homeDir }); - return result; + return findRootInGrokHome(homeDir, options.probe, pathModule); } module.exports = { GROK_PLUGIN_ROOT_ENV, GROK_HOME_DIRNAME, SOURCE_IDENTITY_FILE, - SHA_PATTERN, - HOOKS_CAPABILITY_ID, - CHROME_DEVTOOLS_MCP, grokPluginRootFromEnv, toSharedPluginEnv, - nativePluginMcpOptedOut, - nativeTrustedMcpServerNames, - nativeTrustedInstallAttachesChromeDevtools, - readGrokPluginManifest, resolveHomeDir, grokHomeDir, grokInstallStatePath, - isContained, - assertRootContainment, - assertPinnedSource, - sourceIdentity, - readMarketplaceSource, - listMcpCapabilities, resolveGrokPluginRoot, listCachedGrokVersions, selectPinnedCachedVersion, - installDestination, - readInstalledSharedEnv, - listManagedInstallRoots, - planToOperations, - previewInstall, - applyInstall, - uninstall, - rollback, - setPluginEnabled, - loadCurrentReceipt, - parseMcpConsentList, - resolveRepoRoot, - runGrokInstall, - isGitWorkTreeRoot, - resolvePinnedGitSha, - assertSourceIdentity, }; diff --git a/scripts/lib/grok-source-identity.js b/scripts/lib/grok-source-identity.js index bdc7b35b8..2fe5f80ca 100644 --- a/scripts/lib/grok-source-identity.js +++ b/scripts/lib/grok-source-identity.js @@ -2,9 +2,17 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const SHA_PATTERN = /^[0-9a-f]{40}$/; +const TEMP_SOURCE_ROOTS = new Set(); + +process.once('exit', () => { + for (const tempRoot of TEMP_SOURCE_ROOTS) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); function isGitWorkTreeRoot(dir, pathModule = path) { try { @@ -74,65 +82,163 @@ function copyGitArchive(sourceRoot, sha, dest, pathModule = path) { } } -function copyInstallSource(sourceRoot, dest, pathModule = path, sha) { - if (isGitWorkTreeRoot(sourceRoot, pathModule)) { - if (!resolvePinnedGitSha(sourceRoot, sha)) { - throw new Error(`pinned SHA ${sha} is not in source git`); - } - copyGitArchive(sourceRoot, sha, dest, pathModule); - return { mode: 'git-archive', sha }; - } - - fs.cpSync(sourceRoot, dest, { recursive: true }); - return { mode: 'tree' }; -} - -function assertSourceIdentity(sourceRoot, source, pathModule = path) { - if (isGitWorkTreeRoot(sourceRoot, pathModule)) { - if (!resolvePinnedGitSha(sourceRoot, source && source.sha)) { - throw new Error(`pinned SHA ${source && source.sha} is not in source git`); - } - return 'git-archive'; - } - +function readPinnedMarketplaceSource(sourceRoot, pathModule = path) { const catalogPath = pathModule.join(sourceRoot, '.grok-plugin', 'marketplace.json'); - if (!fs.existsSync(catalogPath)) { - return 'tree'; + let catalog; + try { + catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to read Grok marketplace source at ${catalogPath}: ${error.message}`); } - - const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); const plugin = catalog && Array.isArray(catalog.plugins) ? catalog.plugins[0] : null; - const catalogSha = plugin && plugin.source && plugin.source.sha; - if (catalogSha && catalogSha !== source.sha) { - throw new Error('sourceRoot marketplace sha does not match install plan pin'); + const source = plugin && plugin.source; + if (!source || source.source !== 'url' || typeof source.url !== 'string' || !source.url.trim()) { + throw new Error('Grok marketplace source must be a Git URL'); } - return 'tree'; -} - -function writePinnedMarketplace(dest, source, pathModule = path) { - const catalogPath = pathModule.join(dest, '.grok-plugin', 'marketplace.json'); - if (!fs.existsSync(catalogPath)) { - return false; + if (!SHA_PATTERN.test(source.sha || '')) { + throw new Error('Grok marketplace source must pin a 40-character lowercase commit SHA'); } - const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); - if (!catalog.plugins || !catalog.plugins[0]) { - return false; + if (typeof plugin.version !== 'string' || !plugin.version.trim()) { + throw new Error('Grok marketplace source must declare a plugin version'); } - catalog.plugins[0].source = { - source: source.source, + return { url: source.url, sha: source.sha, + version: plugin.version, }; - fs.mkdirSync(pathModule.dirname(catalogPath), { recursive: true }); - fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); - return true; +} + +function normalizeGitLocation(value, pathModule = path) { + const location = String(value || '').trim().replace(/\/+$/, '').replace(/\.git$/, ''); + if (/^(?:[a-z][a-z0-9+.-]*:\/\/|git@)/i.test(location)) { + return location; + } + return pathModule.resolve(location); +} + +function findRegistrySource(sourceRoot, homeDir, pathModule = path) { + const registryPath = pathModule.join(homeDir, '.grok', 'installed-plugins', 'registry.json'); + let registry; + try { + registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw new Error(`Failed to read Grok plugin registry at ${registryPath}: ${error.message}`); + } + const expectedPath = fs.realpathSync(sourceRoot); + for (const record of Object.values(registry.repos || {})) { + let recordPath; + try { + recordPath = fs.realpathSync(record.path); + } catch { + continue; + } + if (recordPath !== expectedPath || String(record.kind && record.kind.type).toLowerCase() !== 'git') { + continue; + } + return { + url: record.kind.url, + sha: record.kind.commit, + }; + } + return null; +} + +function fetchPinnedGitSource(sourceUrl, sha, parentDir) { + const gitDir = path.join(parentDir, 'git'); + fs.mkdirSync(gitDir, { recursive: true }); + execFileSync('git', ['init', '--bare', '--quiet', gitDir], { stdio: 'ignore' }); + execFileSync('git', ['-C', gitDir, 'fetch', '--quiet', '--depth=1', '--no-tags', sourceUrl, sha], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + const fetched = execFileSync('git', ['-C', gitDir, 'rev-parse', 'FETCH_HEAD^{commit}'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (fetched !== sha) { + throw new Error(`Fetched Grok source commit ${fetched} does not match pinned SHA ${sha}`); + } + return gitDir; +} + +function assertNoSymlinks(root) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Pinned Grok source contains unsupported symlink: ${entryPath}`); + } + if (entry.isDirectory()) assertNoSymlinks(entryPath); + } +} + +function preparePinnedGrokSource(options = {}) { + const sourceRoot = path.resolve(options.sourceRoot || ''); + const homeDir = path.resolve(options.homeDir || os.homedir()); + const catalogSource = readPinnedMarketplaceSource(sourceRoot); + const sourceUrl = options.sourceUrl || catalogSource.url; + const sourceSha = options.sourceSha || catalogSource.sha; + if (normalizeGitLocation(sourceUrl) !== normalizeGitLocation(catalogSource.url)) { + throw new Error('Recorded Grok source URL does not match the marketplace source'); + } + if (!SHA_PATTERN.test(sourceSha || '')) { + throw new Error('Recorded Grok source must pin a 40-character lowercase commit SHA'); + } + + let gitSourceRoot = null; + if (isGitWorkTreeRoot(sourceRoot) && resolvePinnedGitSha(sourceRoot, sourceSha)) { + gitSourceRoot = sourceRoot; + } else { + const registrySource = findRegistrySource(sourceRoot, homeDir); + if (!registrySource + || normalizeGitLocation(registrySource.url) !== normalizeGitLocation(sourceUrl) + || registrySource.sha !== sourceSha) { + throw new Error('Non-Git Grok source is unverifiable without matching registry URL/SHA evidence'); + } + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-grok-source-')); + TEMP_SOURCE_ROOTS.add(tempRoot); + const snapshotRoot = path.join(tempRoot, 'snapshot'); + try { + if (!gitSourceRoot) { + gitSourceRoot = fetchPinnedGitSource(sourceUrl, sourceSha, tempRoot); + } + copyGitArchive(gitSourceRoot, sourceSha, snapshotRoot); + assertNoSymlinks(snapshotRoot); + const snapshotManifest = JSON.parse( + fs.readFileSync(path.join(snapshotRoot, '.grok-plugin', 'plugin.json'), 'utf8') + ); + if (snapshotManifest.version !== catalogSource.version) { + throw new Error( + `Pinned Grok plugin version ${snapshotManifest.version || '(missing)'} does not match marketplace version ${catalogSource.version}` + ); + } + fs.writeFileSync(path.join(snapshotRoot, '.ecc-source.json'), `${JSON.stringify({ + source: 'url', + url: sourceUrl, + sha: sourceSha, + }, null, 2)}\n`); + return { + sourceRoot: snapshotRoot, + sourceUrl, + sourceSha, + sourceVersion: catalogSource.version, + cleanup: () => { + TEMP_SOURCE_ROOTS.delete(tempRoot); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }, + }; + } catch (error) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + TEMP_SOURCE_ROOTS.delete(tempRoot); + throw error; + } } module.exports = { SHA_PATTERN, isGitWorkTreeRoot, resolvePinnedGitSha, - copyInstallSource, - assertSourceIdentity, - writePinnedMarketplace, + readPinnedMarketplaceSource, + preparePinnedGrokSource, }; diff --git a/scripts/lib/harness-adapter-compliance.js b/scripts/lib/harness-adapter-compliance.js index d156351ae..6a8a98263 100644 --- a/scripts/lib/harness-adapter-compliance.js +++ b/scripts/lib/harness-adapter-compliance.js @@ -114,7 +114,7 @@ const ADAPTER_RECORDS = Object.freeze([ 'No plugin rules/ surface', ], install_or_onramp: [ - '`node scripts/grok-install.js --dry-run` / `node scripts/grok-install.js` (ECC 2 `grok` install-state under ~/.grok/ecc/)', + '`node scripts/grok-install.js --dry-run` / `node scripts/grok-install.js` (canonical `grok` install-state under ~/.grok/ecc/)', '`grok plugin marketplace add affaan-m/ECC && grok plugin install ecc && grok plugin enable ecc` (discovery only; not ECC consent)', ], verification_commands: [ @@ -124,7 +124,7 @@ const ADAPTER_RECORDS = Object.freeze([ ], risk_notes: [ 'Grok rejects Claude marketplace source "./"; pin a url+sha catalog in .grok-plugin/marketplace.json to a commit that contains the Grok adapter', - 'ECC consent is grok-install.js / applyInstall; native Grok CLI discovery opts hooks and MCP out via empty plugin.json fields; the full skill catalog is large', + 'ECC consent is the canonical install-apply receipt path; native Grok CLI discovery opts hooks and MCP out via empty plugin.json fields; the full skill catalog is large', ], last_verified_at: '2026-09-03', owner: 'ECC maintainers', diff --git a/scripts/lib/harness-capabilities.js b/scripts/lib/harness-capabilities.js index 8daa1d021..071deedf4 100644 --- a/scripts/lib/harness-capabilities.js +++ b/scripts/lib/harness-capabilities.js @@ -265,12 +265,12 @@ const HARNESS_CAPABILITIES = deepFreeze([ installMode: 'native-plugin', guidedReady: false, availability: 'advanced', - destination: '~/.grok through the ECC Grok install-state adapter', + destination: '~/.grok', scopes: [scope('native', 'grok', '~/.grok')], hooks: hooks( 'native-trust', true, - 'ECC Grok installs use previewInstall/applyInstall with per-capability consent. Native Grok CLI --trust opts MCP out via empty mcpServers in .grok-plugin/plugin.json.' + 'ECC Grok installs use the canonical plan/apply receipt with per-capability consent. Native Grok CLI --trust opts MCP out via empty mcpServers in .grok-plugin/plugin.json.' ), aliases: ['grok-build'], }, diff --git a/scripts/lib/hook-flags.js b/scripts/lib/hook-flags.js index 60da9d90d..69d3f25d6 100644 --- a/scripts/lib/hook-flags.js +++ b/scripts/lib/hook-flags.js @@ -41,7 +41,7 @@ function sanitizeDiagnostic(value) { function readManagedHookConfig(env = process.env) { const pluginRoot = String( - env.GROK_PLUGIN_ROOT || env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || '' + env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || '' ).trim(); const configPath = String(env.ECC_HOOK_CONFIG || '').trim() || (pluginRoot ? path.join(pluginRoot, 'ecc', 'setup.json') : ''); diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 72eac5e8a..7d09823ff 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -552,7 +552,9 @@ function createLegacyCompatInstallPlan(options = {}) { requestModuleIds: [], requestIncludeComponentIds: includeComponentIds, requestExcludeComponentIds: excludeComponentIds, - mode: 'legacy-compat' + mode: 'legacy-compat', + trust: options.trust === true, + consent: options.consent || {}, }); } diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index d637107a6..740b2ec29 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -5,7 +5,7 @@ const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./instal const { resolveInvocationEnvironment } = require('./invocation-environment'); const DEFAULT_REPO_ROOT = path.join(__dirname, '../..'); -const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'copilot', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi', 'adal']; +const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi', 'adal', 'grok']; const COMPONENT_FAMILY_PREFIXES = { baseline: 'baseline:', language: 'lang:', @@ -618,6 +618,9 @@ function resolveInstallPlan(options = {}) { projectRoot: validatedProjectRoot || manifests.repoRoot, homeDir: validatedHomeDir || os.homedir(), env: resolveInvocationEnvironment(options), + trust: options.trust === true, + consent: options.consent || {}, + sourceSha: options.sourceSha || null, } : null; const targetAdapter = target ? getInstallTargetAdapter(target) : null; @@ -661,7 +664,8 @@ function resolveInstallPlan(options = {}) { const supportsTarget = !target || ( - readModuleTargetsOrThrow(module).includes(target) + (readModuleTargetsOrThrow(module).includes(target) + || targetAdapter?.acceptsUndeclaredModuleTargets === true) && (!targetAdapter || targetAdapter.supportsModule(module, targetPlanningInput)) ); @@ -730,6 +734,9 @@ function resolveInstallPlan(options = {}) { projectRoot: targetPlanningInput.projectRoot, homeDir: targetPlanningInput.homeDir, env: targetPlanningInput.env, + trust: targetPlanningInput.trust, + consent: targetPlanningInput.consent, + sourceSha: targetPlanningInput.sourceSha, modules: selectedModules, exemptValidationCodes: options.exemptValidationCodes || [], }) diff --git a/scripts/lib/install-state.js b/scripts/lib/install-state.js index 3018b1ee3..0f18d7c89 100644 --- a/scripts/lib/install-state.js +++ b/scripts/lib/install-state.js @@ -132,7 +132,7 @@ function createFallbackValidator() { validateNoAdditionalProperties( request, '/request', - ['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode', 'hookConsent'] + ['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode', 'hookConsent', 'trust', 'consentMcp'] ); if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) { pushError('/request/profile', 'must be string or null'); @@ -152,6 +152,12 @@ function createFallbackValidator() { ) { pushError('/request/hookConsent', 'must be enabled, declined, or null'); } + if (request.trust !== undefined && typeof request.trust !== 'boolean') { + pushError('/request/trust', 'must be boolean'); + } + if (request.consentMcp !== undefined) { + validateStringArray(request.consentMcp, '/request/consentMcp'); + } } const resolution = state.resolution; @@ -167,8 +173,9 @@ function createFallbackValidator() { if (!source || typeof source !== 'object' || Array.isArray(source)) { pushError('/source', 'must be object'); } else { - validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']); + validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoUrl', 'repoCommit', 'manifestVersion']); validateOptionalString(source.repoVersion, '/source/repoVersion'); + validateOptionalString(source.repoUrl, '/source/repoUrl'); validateOptionalString(source.repoCommit, '/source/repoCommit'); if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) { pushError('/source/manifestVersion', 'must be integer >= 1'); @@ -306,6 +313,10 @@ function createInstallState(options) { hookConsent: Object.prototype.hasOwnProperty.call(options.request, 'hookConsent') ? options.request.hookConsent : null, + trust: options.request.trust === true, + consentMcp: Array.isArray(options.request.consentMcp) + ? [...options.request.consentMcp] + : [], }, resolution: { selectedModules: Array.isArray(options.resolution.selectedModules) @@ -317,6 +328,7 @@ function createInstallState(options) { }, source: { repoVersion: options.source.repoVersion || null, + repoUrl: options.source.repoUrl || null, repoCommit: options.source.repoCommit || null, manifestVersion: options.source.manifestVersion, }, diff --git a/scripts/lib/install-targets/grok-home.js b/scripts/lib/install-targets/grok-home.js index c2576c37f..b52e9f34c 100644 --- a/scripts/lib/install-targets/grok-home.js +++ b/scripts/lib/install-targets/grok-home.js @@ -1,5 +1,9 @@ 'use strict'; +const fs = require('fs'); +const path = require('path'); +const { preparePinnedGrokSource } = require('../grok-source-identity'); + const { createInstallTargetAdapter, isForeignPlatformPath, @@ -19,31 +23,104 @@ function isHooksPath(relativePath) { return normalized === 'hooks' || normalized.startsWith('hooks/'); } +function readMcpConfig(repoRoot) { + const configPath = path.join(repoRoot, '.mcp.json'); + let config; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return { mcpServers: {} }; + throw new Error(`Failed to parse Grok MCP config at ${configPath}: ${error.message}`); + } + if (!config || typeof config !== 'object' || Array.isArray(config) + || !config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) { + throw new Error(`Invalid Grok MCP config at ${configPath}: expected an mcpServers object`); + } + return config; +} + module.exports = createInstallTargetAdapter({ id: 'grok-home', target: 'grok', kind: 'home', + acceptsUndeclaredModuleTargets: true, rootSegments: ['.grok'], installStatePathSegments: ['ecc', 'install-state.json'], nativeRootRelativePath: '.grok', + prepareSource(input = {}) { + return preparePinnedGrokSource({ + sourceRoot: input.sourceRoot || input.repoRoot, + homeDir: input.homeDir, + sourceUrl: input.sourceUrl, + sourceSha: input.sourceSha, + }); + }, planOperations(input = {}, adapter) { const trust = input.trust === true; const consent = input.consent && typeof input.consent === 'object' ? input.consent : {}; const allowHooks = trust && consent.hooks === true; + const requestedMcpIds = Object.entries(consent.mcp || {}) + .filter(([, allowed]) => allowed === true) + .map(([id]) => id); + const mcpConfig = readMcpConfig(input.repoRoot); + const unknownMcpIds = requestedMcpIds.filter((id) => !Object.hasOwn(mcpConfig.mcpServers, id)); + if (unknownMcpIds.length > 0) { + throw new Error(`Unknown Grok MCP capability: ${unknownMcpIds.join(', ')}`); + } + const allowedMcpIds = trust ? requestedMcpIds : []; const modules = Array.isArray(input.modules) ? input.modules : []; - return modules.flatMap((module) => { + const operations = modules.flatMap((module) => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter((sourceRelativePath) => !isForeignPlatformPath(sourceRelativePath, 'grok')) - // Root .mcp.json attach is owned by applyInstall's per-server filter. + // Root MCP files are emitted only by the adapter's per-server consent operation. .filter((sourceRelativePath) => !isMcpPath(sourceRelativePath)) .filter((sourceRelativePath) => allowHooks || !isHooksPath(sourceRelativePath)) - .map((sourceRelativePath) => adapter.createScaffoldOperation( - module.id, - sourceRelativePath, - input - )); + .map((sourceRelativePath) => { + const operation = adapter.createScaffoldOperation(module.id, sourceRelativePath, input); + return { + ...operation, + destinationPath: path.join(adapter.resolveRoot(input), 'plugins', 'ecc', sourceRelativePath), + ...(normalizeRelativePath(sourceRelativePath) === 'hooks/hooks.json' + ? { contentTransform: 'grok-hook-boundary' } + : {}), + }; + }); }); + const hooksEnabled = allowHooks && operations.some((operation) => isHooksPath(operation.sourceRelativePath)); + const pluginRoot = path.join(adapter.resolveRoot(input), 'plugins', 'ecc'); + if (hooksEnabled) { + operations.push({ + ...adapter.createScaffoldOperation('hooks-runtime', 'hooks/hooks.json', input), + destinationPath: path.join(pluginRoot, 'hooks', 'hooks.json'), + contentTransform: 'grok-hook-boundary', + }); + } + operations.push({ + ...adapter.createScaffoldOperation('grok-source-identity', '.ecc-source.json', input), + destinationPath: path.join(pluginRoot, '.ecc-source.json'), + }); + if (allowedMcpIds.length > 0) { + operations.push({ + ...adapter.createScaffoldOperation('grok-mcp', '.mcp.json', input), + destinationPath: path.join(pluginRoot, '.mcp.json'), + contentTransform: 'grok-mcp-consent', + grokMcpIds: allowedMcpIds, + }); + } + const manifestOperation = adapter.createScaffoldOperation( + 'grok-manifest', + '.grok-plugin/plugin.json', + input + ); + operations.push({ + ...manifestOperation, + destinationPath: path.join(pluginRoot, '.grok-plugin', 'plugin.json'), + contentTransform: 'grok-plugin-consent', + grokHooksEnabled: hooksEnabled, + grokMcpEnabled: allowedMcpIds.length > 0, + }); + return operations; }, }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 5df5ae1c4..39fb01163 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -318,6 +318,7 @@ function createInstallTargetAdapter(config) { id: config.id, target: config.target, kind: config.kind, + acceptsUndeclaredModuleTargets: config.acceptsUndeclaredModuleTargets === true, nativeRootRelativePath: config.nativeRootRelativePath || null, supports(target) { return target === config.target || target === config.id; @@ -367,8 +368,13 @@ function createInstallTargetAdapter(config) { strategy: adapter.determineStrategy(normalizedSourcePath), }); }, - excludesSourcePath(sourceRelativePath) { - return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths); + prepareSource(input = {}) { + if (typeof config.prepareSource === 'function') { + return config.prepareSource(input, adapter); + } + return { + sourceRoot: input.sourceRoot || input.repoRoot, + }; }, planOperations(input = {}) { if (typeof config.planOperations === 'function') { diff --git a/scripts/lib/install-targets/registry.js b/scripts/lib/install-targets/registry.js index a523676ce..770096742 100644 --- a/scripts/lib/install-targets/registry.js +++ b/scripts/lib/install-targets/registry.js @@ -60,6 +60,9 @@ function planInstallTargetScaffold(options = {}) { projectRoot: options.projectRoot || options.repoRoot, homeDir: options.homeDir, env: resolveInvocationEnvironment(options), + trust: options.trust === true, + consent: options.consent || {}, + sourceSha: options.sourceSha || null, }; const validationIssues = adapter.validate(planningInput); const blockingIssues = validationIssues.filter(issue => ( diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index ff49ddf53..cb1907251 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -57,8 +57,7 @@ const { withoutStaleOperations, } = require('./stale-operations-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); -const { transformInstallContent } = require('./content-transform'); -const { assertScriptBoundary, isScriptBoundary } = require('./opencode-script-boundary'); +const { transformCopyFileContent } = require('./content-transform'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); @@ -910,7 +909,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals && isMarkdownPath(operation.destinationPath) ); if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) { - const transformed = transformInstallContent( + const transformed = transformCopyFileContent( operation, fs.readFileSync(operation.sourcePath, 'utf8') ); diff --git a/scripts/lib/install/content-transform.js b/scripts/lib/install/content-transform.js index b2f6e1fe1..a440c421c 100644 --- a/scripts/lib/install/content-transform.js +++ b/scripts/lib/install/content-transform.js @@ -1,59 +1,48 @@ 'use strict'; -const { stripPluginNamespace } = require('./plugin-namespace'); const { adaptAntigravityAgent } = require('./antigravity-agent'); -const { adaptCopilotAgent } = require('./copilot-agent'); -const { disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent } = require('./hook-consent'); -function rewriteCopilotWorkflowPaths(content, sourceRelativePath) { - if (!/\.md$/i.test(sourceRelativePath)) return content; - // Commands run from the installed project root. Rewrite only the supported - // helper entrypoint, leaving unrelated project scripts and URLs untouched. - return content.replace(/(^|[\s`"'(])scripts\/setup-package-manager\.js\b/g, - '$1.github/ecc/scripts/setup-package-manager.js'); +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; } -function transformInstallContent(operation, content) { +function transformCopyFileContent(operation, content) { if (!operation.contentTransform) { return content; } - if (operation.contentTransform === 'claude-manual-plugin-namespace') { - return stripPluginNamespace(content); - } - if (operation.contentTransform === 'qwen-agent-frontmatter') { - return require('../../qwen-adapt-agents').adaptFrontmatter(content).text; - } if (operation.contentTransform === 'antigravity-agent-frontmatter') { return adaptAntigravityAgent(content, operation.sourceRelativePath); } - if (operation.contentTransform === 'copilot-agent-frontmatter') { - return rewriteCopilotWorkflowPaths(adaptCopilotAgent(content, operation.sourceRelativePath), operation.sourceRelativePath); + if (operation.contentTransform === 'grok-plugin-consent') { + const manifest = JSON.parse(content); + return formatJson({ + ...manifest, + hooks: operation.grokHooksEnabled ? 'hooks/hooks.json' : '', + mcpServers: operation.grokMcpEnabled ? '.mcp.json' : '', + }); } - if (operation.contentTransform === 'copilot-workflow-paths') { - return rewriteCopilotWorkflowPaths(content, operation.sourceRelativePath); + if (operation.contentTransform === 'grok-hook-boundary') { + return content.split('var e=process.env.CLAUDE_PLUGIN_ROOT;').join( + 'var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT;' + ); } - if (operation.contentTransform === 'opencode-disable-ecc-hooks') { - return transformInstallContent({ ...operation, contentTransform: 'opencode-home-skills-path' }, - disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath)); - } - if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { - return getDisabledOpenCodePluginContent(); - } - if (operation.contentTransform === 'opencode-home-skills-path') { + if (operation.contentTransform === 'grok-mcp-consent') { const config = JSON.parse(content); - if (!Array.isArray(config?.skills?.paths) || !config.skills.paths.includes('../skills')) { - return content; + if (!config || typeof config !== 'object' || Array.isArray(config) + || !config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) { + throw new Error(`Invalid Grok MCP config: ${operation.sourceRelativePath}`); } - const installedConfig = { + const allowed = new Set(operation.grokMcpIds || []); + return formatJson({ ...config, - skills: { - ...config.skills, - paths: config.skills.paths.map(skillPath => skillPath === '../skills' ? './skills' : skillPath), - }, - }; - return `${JSON.stringify(installedConfig, null, 2)}\n`; + mcpServers: Object.fromEntries( + Object.entries(config.mcpServers).filter(([id]) => allowed.has(id)) + ), + }); } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } -module.exports = { transformInstallContent }; +module.exports = { + transformCopyFileContent, +}; diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index d8a8e93ff..f5cb11fe1 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -103,6 +103,9 @@ function buildCopyFileOperation({ destinationPath, strategy, contentTransform, + grokHooksEnabled, + grokMcpEnabled, + grokMcpIds, }) { // Copilot's agent and workflow transforms are textual. Their directories may // also contain binary assets and executable helpers, which must keep bytes. @@ -118,7 +121,10 @@ function buildCopyFileOperation({ strategy, ownership: 'managed', scaffoldOnly: false, - ...(applicableTransform ? { contentTransform: applicableTransform } : {}), + ...(contentTransform ? { contentTransform } : {}), + ...(contentTransform === 'grok-plugin-consent' ? { grokHooksEnabled } : {}), + ...(contentTransform === 'grok-plugin-consent' ? { grokMcpEnabled } : {}), + ...(contentTransform === 'grok-mcp-consent' ? { grokMcpIds: [...grokMcpIds] } : {}), }; } @@ -198,6 +204,9 @@ function materializeScaffoldOperation(sourceRoot, operation) { destinationPath: operation.destinationPath, strategy: operation.strategy, contentTransform: operation.contentTransform, + grokHooksEnabled: operation.grokHooksEnabled, + grokMcpEnabled: operation.grokMcpEnabled, + grokMcpIds: operation.grokMcpIds, }) ]; } @@ -215,6 +224,9 @@ function materializeScaffoldOperation(sourceRoot, operation) { destinationPath: path.join(operation.destinationPath, relativeFile), strategy: operation.strategy, contentTransform: operation.contentTransform, + grokHooksEnabled: operation.grokHooksEnabled, + grokMcpEnabled: operation.grokMcpEnabled, + grokMcpIds: operation.grokMcpIds, }); }); } @@ -256,9 +268,20 @@ function dedupeCopyFileOperations(operations) { } function createManifestInstallPlan(options = {}) { - const sourceRoot = options.sourceRoot || getSourceRoot(); + const requestedSourceRoot = options.sourceRoot || getSourceRoot(); const projectRoot = options.projectRoot || process.cwd(); const target = options.target || 'claude'; + const adapter = getInstallTargetAdapter(target); + const preparedSource = adapter.prepareSource({ + sourceRoot: requestedSourceRoot, + repoRoot: requestedSourceRoot, + projectRoot, + homeDir: options.homeDir, + env: resolveInvocationEnvironment(options), + sourceUrl: options.sourceUrl || null, + sourceSha: options.sourceSha || null, + }); + const sourceRoot = preparedSource.sourceRoot || requestedSourceRoot; const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : []; const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null; const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : []; @@ -283,8 +306,10 @@ function createManifestInstallPlan(options = {}) { excludeComponentIds: options.excludeComponentIds || [], target, exemptValidationCodes: options.exemptValidationCodes || [], + trust: options.trust === true, + consent: options.consent || {}, + sourceSha: options.sourceSha || null, }); - const adapter = getInstallTargetAdapter(target); const materializedOperations = plan.operations.flatMap(operation => ( materializeScaffoldOperation(sourceRoot, operation) )).map(operation => { @@ -307,8 +332,9 @@ function createManifestInstallPlan(options = {}) { : materializedOperations ); const source = { - repoVersion: getPackageVersion(sourceRoot), - repoCommit: getRepoCommit(sourceRoot), + repoVersion: preparedSource.sourceVersion || getPackageVersion(sourceRoot), + repoUrl: preparedSource.sourceUrl || null, + repoCommit: preparedSource.sourceSha || getRepoCommit(sourceRoot), manifestVersion: getManifestVersion(sourceRoot) }; const statePreview = createStatePreview({ @@ -321,7 +347,11 @@ function createManifestInstallPlan(options = {}) { includeComponents: requestIncludeComponentIds, excludeComponents: requestExcludeComponentIds, legacyLanguages, - legacyMode: Boolean(options.legacyMode) + legacyMode: Boolean(options.legacyMode), + trust: options.trust === true, + consentMcp: Object.entries(options.consent && options.consent.mcp || {}) + .filter(([, allowed]) => allowed === true) + .map(([id]) => id), }, resolution: { selectedModules: plan.selectedModuleIds, @@ -331,7 +361,7 @@ function createManifestInstallPlan(options = {}) { source }); - return disableUnselectedOpenCodeHooks({ + const installPlan = { mode: options.mode || 'manifest', sourceRoot, target, @@ -357,7 +387,15 @@ function createManifestInstallPlan(options = {}) { excludedModuleIds: plan.excludedModuleIds, operations, statePreview - }); + }; + if (typeof preparedSource.cleanup === 'function') { + Object.defineProperty(installPlan, 'dispose', { + configurable: false, + enumerable: false, + value: preparedSource.cleanup, + }); + } + return installPlan; } module.exports = { diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 9dfbed7e2..d525990a7 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -32,6 +32,10 @@ function parseInstallArgs(argv) { locale: null, enableHooks: false, noHooks: false, + trust: false, + consentHooks: false, + consentMcp: [], + sourceSha: null, }; for (let index = 0; index < args.length; index += 1) { @@ -76,6 +80,16 @@ function parseInstallArgs(argv) { parsed.enableHooks = true; } else if (arg === '--no-hooks') { parsed.noHooks = true; + } else if (arg === '--trust') { + parsed.trust = true; + } else if (arg === '--consent-hooks') { + parsed.consentHooks = true; + } else if (arg === '--consent-mcp') { + parsed.consentMcp = dedupeStrings((args[index + 1] || '').split(',')); + index += 1; + } else if (arg === '--source-sha') { + parsed.sourceSha = args[index + 1] || null; + index += 1; } else if (arg === '--dry-run') { parsed.dryRun = true; } else if (arg === '--json') { @@ -134,7 +148,16 @@ function normalizeInstallRequest(options = {}) { ...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []), ...(Array.isArray(options.languages) ? options.languages : []), ]).map(language => language.toLowerCase())); - const hookConsent = resolveHookConsentFlags(options); + const hookConsent = resolveHookConsentFlags({ + ...options, + enableHooks: options.enableHooks === true || options.consentHooks === true, + }); + const trust = options.trust === true; + const consentMcp = dedupeStrings(options.consentMcp || []); + const sourceSha = options.sourceSha || null; + if (sourceSha && (target !== 'grok' || !/^[0-9a-f]{40}$/.test(sourceSha))) { + throw new Error('--source-sha requires --target grok and a 40-character lowercase commit SHA'); + } if (hookConsent === 'declined' && moduleIds.includes('hooks-runtime')) { throw new Error('--no-hooks cannot be combined with an explicit hooks-runtime module selection'); } @@ -167,6 +190,9 @@ function normalizeInstallRequest(options = {}) { excludeComponentIds, legacyLanguages, hookConsent, + trust, + consentMcp, + sourceSha, configPath: config?.path || options.configPath || null, }; } diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index eabb930b2..d79b787d9 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -28,6 +28,13 @@ function createRawInstallPlan(request, options = {}) { homeDir: options.homeDir, env: resolveInvocationEnvironment(options), sourceRoot: options.sourceRoot, + sourceUrl: options.sourceUrl, + sourceSha: options.sourceSha || request.sourceSha, + trust: request.trust, + consent: { + hooks: request.hookConsent === 'enabled', + mcp: Object.fromEntries((request.consentMcp || []).map(id => [id, true])), + }, exemptValidationCodes: options.exemptValidationCodes || [], }); } diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index a82806545..cd9b4d3ff 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -29,33 +29,75 @@ const PLUGIN_ROOT_SEGMENTS = [ // If that skill is ever renamed, move this sentinel with it. const DEFAULT_SCRIPT_PROBE = path.join('scripts', 'lib', 'utils.js'); const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2'); -const PLUGIN_ROOT_ENV_KEYS = ['GROK_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'ECC_PLUGIN_ROOT']; -function pluginRootFromEnv(env = process.env) { - for (const key of PLUGIN_ROOT_ENV_KEYS) { - const value = env[key]; - if (value && String(value).trim()) { - return String(value).trim(); - } - } - return ''; -} +/** + * Resolve the ECC source root directory. + * + * Tries, in order: + * 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user) + * 2. Standard install location (~/.claude/) — when scripts exist there + * 3. Known plugin roots under ~/.claude/plugins/ (current + legacy slugs) + * 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/{ecc,everything-claude-code}/ + * 5. Fallback to ~/.claude/ (original behaviour) + * + * @param {object} [options] + * @param {string} [options.homeDir] Override home directory (for testing) + * @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing) + * @param {string} [options.probe] Relative path used to verify a candidate + * root contains what the caller needs. When + * given, it is honored exactly (script + * consumers pass their own script path). When + * omitted, a candidate must contain BOTH the + * ECC script tree and a sentinel ECC skill, + * so a partial install (scripts without + * skills) is rejected for skill consumers. + * @returns {string} Resolved ECC root path + */ +function resolveEccRoot(options = {}) { + const envRoot = options.envRoot !== undefined + ? options.envRoot + : (process.env.CLAUDE_PLUGIN_ROOT || ''); -function findRootInVendorHome(vendorDir, isRoot) { - if (isRoot(vendorDir)) { - return vendorDir; + if (envRoot && envRoot.trim()) { + return envRoot.trim(); } - for (const segments of PLUGIN_ROOT_SEGMENTS) { - const candidate = path.join(vendorDir, 'plugins', ...segments); + const homeDir = options.homeDir || os.homedir(); + const claudeDir = path.join(homeDir, '.claude'); + + // Decide whether a candidate directory is a usable ECC root. An explicit + // caller probe is honored exactly (script consumers know the artifact they + // need). With the default probe the caller is a skill consumer, so a + // candidate must contain both ECC's scripts and a sentinel ECC skill — + // otherwise a scripts-only ~/.claude short-circuits and every skill path + // resolves to a location that does not exist (#2544). + const isRoot = options.probe + ? (dir) => fs.existsSync(path.join(dir, options.probe)) + : (dir) => fs.existsSync(path.join(dir, DEFAULT_SCRIPT_PROBE)) + && fs.existsSync(path.join(dir, DEFAULT_SKILL_PROBE)); + + // Standard install — files are copied directly into ~/.claude/ + if (isRoot(claudeDir)) { + return claudeDir; + } + + // Exact legacy plugin install locations. These preserve backwards + // compatibility without scanning arbitrary plugin trees. + const legacyPluginRoots = PLUGIN_ROOT_SEGMENTS.map((segments) => + path.join(claudeDir, 'plugins', ...segments) + ); + + for (const candidate of legacyPluginRoots) { if (isRoot(candidate)) { return candidate; } } + // Plugin cache — Claude Code stores marketplace plugins under + // ~/.claude/plugins/cache//// try { for (const slug of PLUGIN_CACHE_SLUGS) { - const cacheBase = path.join(vendorDir, 'plugins', 'cache', slug); + const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug); const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true }); for (const orgEntry of orgDirs) { @@ -79,77 +121,10 @@ function findRootInVendorHome(vendorDir, isRoot) { } } } catch { - // Vendor plugin cache doesn't exist or isn't readable. + // Plugin cache doesn't exist or isn't readable — continue to fallback } - try { - const installedBase = path.join(vendorDir, 'installed-plugins'); - const installedDirs = fs.readdirSync(installedBase, { withFileTypes: true }); - for (const entry of installedDirs) { - if (!entry.isDirectory()) continue; - const candidate = path.join(installedBase, entry.name); - if (isRoot(candidate)) { - return candidate; - } - } - } catch { - // Grok (and similar) installed-plugins homes are optional. - } - - return null; -} - -/** - * Resolve the ECC source root directory. - * - * Tries, in order: - * 1. GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, or ECC_PLUGIN_ROOT - * 2. Standard install location (~/.claude/) — when a complete root exists there - * 3. Known plugin roots under ~/.claude/plugins/ (current + legacy slugs) - * 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/{ecc,everything-claude-code}/ - * 5. The same layout under ~/.grok/, plus ~/.grok/installed-plugins// - * 6. Fallback to ~/.claude/ (original behaviour) - * - * @param {object} [options] - * @param {string} [options.homeDir] Override home directory (for testing) - * @param {string} [options.envRoot] Override plugin-root env (for testing) - * @param {string} [options.probe] Relative path used to verify a candidate - * root contains what the caller needs. When - * given, it is honored exactly (script - * consumers pass their own script path). When - * omitted, a candidate must contain BOTH the - * ECC script tree and a sentinel ECC skill, - * so a partial install (scripts without - * skills) is rejected for skill consumers. - * @returns {string} Resolved ECC root path - */ -function resolveEccRoot(options = {}) { - const envRoot = options.envRoot !== undefined - ? options.envRoot - : pluginRootFromEnv(process.env); - - if (envRoot && envRoot.trim()) { - return envRoot.trim(); - } - - const homeDir = options.homeDir || os.homedir(); - const claudeDir = path.join(homeDir, '.claude'); - const grokDir = path.join(homeDir, '.grok'); - - // Decide whether a candidate directory is a usable ECC root. An explicit - // caller probe is honored exactly (script consumers know the artifact they - // need). With the default probe the caller is a skill consumer, so a - // candidate must contain both ECC's scripts and a sentinel ECC skill — - // otherwise a scripts-only ~/.claude short-circuits and every skill path - // resolves to a location that does not exist (#2544). - const isRoot = options.probe - ? (dir) => fs.existsSync(path.join(dir, options.probe)) - : (dir) => fs.existsSync(path.join(dir, DEFAULT_SCRIPT_PROBE)) - && fs.existsSync(path.join(dir, DEFAULT_SKILL_PROBE)); - - return findRootInVendorHome(claudeDir, isRoot) - || findRootInVendorHome(grokDir, isRoot) - || claudeDir; + return claudeDir; } function normalizePluginRootForPlatform(rootDir, platform = process.platform) { @@ -171,21 +146,21 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * * This minified form contains no spread, no nested array literals, and no * escaped double quotes, so it survives `node -e "..."` quoting on every shell. - * When GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, or ECC_PLUGIN_ROOT is set it is - * used directly. Grok trusted plugin hooks set both GROK_PLUGIN_ROOT and the - * CLAUDE_PLUGIN_ROOT alias. Otherwise the inline probes ~/.claude plugin - * locations only far enough to load this module, then delegates to - * resolveEccRoot() (which also searches ~/.grok). + * When CLAUDE_PLUGIN_ROOT is set (as Claude Code does for plugin hooks and + * commands) it is used directly. Otherwise the inline probes the same set of + * locations resolveEccRoot() knows about — ~/.claude, the exact plugin roots + * under ~/.claude/plugins/, and the versioned plugin cache — only far enough to + * load the committed resolve-ecc-root module, then delegates the authoritative + * decision to resolveEccRoot(). This keeps discovery behaviour identical to the + * old inline while centralising the real logic in one tested module. * * Usage in commands: * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i { @@ -99,36 +98,6 @@ function runTests() { }); })) passed++; else failed++; - if (test('GROK_PLUGIN_ROOT locates managed hook config when Claude root is unset', () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-flags-grok-')); - const configPath = path.join(root, 'ecc', 'setup.json'); - try { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify({ - hooks: { enabled: false, profile: 'minimal' }, - })); - withEnv({ - ECC_HOOKS_ENABLED: undefined, - ECC_HOOK_PROFILE: undefined, - CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: undefined, - CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: undefined, - ECC_HOOK_CONFIG: undefined, - CLAUDE_PLUGIN_ROOT: undefined, - ECC_PLUGIN_ROOT: undefined, - GROK_PLUGIN_ROOT: root, - }, () => { - assert.deepStrictEqual(readManagedHookConfig(), { - enabled: false, - profile: 'minimal', - }); - assert.strictEqual(areHooksEnabled(), false); - assert.strictEqual(getHookProfile(), 'minimal'); - }); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - })) passed++; else failed++; - if (test('Claude plugin options control enabled state and profile', () => { withEnv({ ECC_HOOKS_ENABLED: undefined, diff --git a/tests/lib/grok-canonical-install.test.js b/tests/lib/grok-canonical-install.test.js new file mode 100644 index 000000000..a166455f3 --- /dev/null +++ b/tests/lib/grok-canonical-install.test.js @@ -0,0 +1,370 @@ +/** + * Canonical Grok install contract tests. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install-executor'); +const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.stack || error.message}`); + return false; + } +} + +function git(cwd, args, options = {}) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }).trim(); +} + +function commit(root, message) { + git(root, ['add', '.']); + git(root, ['-c', 'user.email=grok-canonical@test', '-c', 'user.name=GrokCanonical', 'commit', '-m', message]); + return git(root, ['rev-parse', 'HEAD']); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function createPinnedFixture() { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-grok-canonical-')); + const sourceRoot = path.join(parent, 'source'); + const homeDir = path.join(parent, 'home'); + git(parent, ['clone', '--quiet', '--no-local', repoRoot, sourceRoot]); + git(sourceRoot, ['remote', 'add', 'pinned-source', sourceRoot]); + fs.mkdirSync(homeDir, { recursive: true }); + + fs.writeFileSync(path.join(sourceRoot, 'commands', 'plan.md'), 'pinned payload\n'); + writeJson(path.join(sourceRoot, '.mcp.json'), { + mcpServers: { + 'chrome-devtools': { command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest'] }, + 'other-mcp': { command: 'node', args: ['mcp.js'] }, + }, + }); + const pluginManifestPath = path.join(sourceRoot, '.grok-plugin', 'plugin.json'); + const pluginManifest = JSON.parse(fs.readFileSync(pluginManifestPath, 'utf8')); + writeJson(pluginManifestPath, { ...pluginManifest, version: '2.2.1' }); + const payloadSha = commit(sourceRoot, 'fixture payload'); + + const marketplacePath = path.join(sourceRoot, '.grok-plugin', 'marketplace.json'); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf8')); + marketplace.plugins[0] = { + ...marketplace.plugins[0], + version: '2.2.1', + source: { + source: 'url', + url: sourceRoot, + sha: payloadSha, + }, + }; + writeJson(marketplacePath, marketplace); + commit(sourceRoot, 'fixture catalog'); + + return { parent, sourceRoot, homeDir, payloadSha }; +} + +function request({ trust = true, hooks = true, mcp = ['other-mcp'] } = {}) { + return { + mode: 'manifest', + target: 'grok', + profileId: null, + moduleIds: ['commands-core', 'hooks-runtime'], + includeComponentIds: [], + excludeComponentIds: [], + legacyLanguages: [], + hookConsent: hooks ? 'enabled' : 'declined', + trust, + consentMcp: mcp, + }; +} + +function createPlan(fixture, requestOptions = {}, extra = {}) { + return createInstallPlanFromRequest(request(requestOptions), { + sourceRoot: fixture.sourceRoot, + projectRoot: fixture.sourceRoot, + homeDir: fixture.homeDir, + env: {}, + ...extra, + }); +} + +function installedRoot(fixture) { + return path.join(fixture.homeDir, '.grok', 'plugins', 'ecc'); +} + +function inspectWithGrok(fixture) { + const projectRoot = path.join(fixture.parent, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + const result = spawnSync('grok', ['inspect', '--json'], { + cwd: projectRoot, + encoding: 'utf8', + env: { + ...process.env, + HOME: fixture.homeDir, + GROK_HOME: path.join(fixture.homeDir, '.grok'), + }, + timeout: 30000, + }); + if (result.error && result.error.code === 'ENOENT') return null; + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + +function runTests() { + console.log('\n=== Canonical Grok install contract ===\n'); + + if (test('preview and apply use one canonical operation set with explicit capability consent', () => { + const fixture = createPinnedFixture(); + try { + const rawPlan = createPlan(fixture); + const preview = previewInstallPlan(rawPlan); + const applied = applyInstallPlan(rawPlan); + const state = JSON.parse(fs.readFileSync(rawPlan.installStatePath, 'utf8')); + + assert.deepStrictEqual(applied.plannedOperations, preview.plannedOperations); + assert.deepStrictEqual( + state.operations.map(operation => operation.destinationPath), + preview.plannedOperations.map(operation => operation.destinationPath) + ); + assert.strictEqual(state.request.trust, true); + assert.deepStrictEqual(state.request.consentMcp, ['other-mcp']); + assert.strictEqual(state.source.repoCommit, fixture.payloadSha); + assert.strictEqual(state.source.repoUrl, fixture.sourceRoot); + + const manifest = JSON.parse(fs.readFileSync(path.join(installedRoot(fixture), '.grok-plugin', 'plugin.json'), 'utf8')); + const mcp = JSON.parse(fs.readFileSync(path.join(installedRoot(fixture), '.mcp.json'), 'utf8')); + assert.strictEqual(manifest.hooks, 'hooks/hooks.json'); + assert.strictEqual(manifest.mcpServers, '.mcp.json'); + assert.deepStrictEqual(Object.keys(mcp.mcpServers), ['other-mcp']); + assert.ok(!JSON.stringify(mcp).includes('chrome-devtools')); + const installedHooks = fs.readFileSync(path.join(installedRoot(fixture), 'hooks', 'hooks.json'), 'utf8'); + assert.ok(installedHooks.includes('GROK_PLUGIN_ROOT')); + assert.ok(!fs.readFileSync(path.join(repoRoot, 'hooks', 'hooks.json'), 'utf8').includes('GROK_PLUGIN_ROOT')); + const inspection = inspectWithGrok(fixture); + if (inspection) { + const plugin = inspection.plugins.find((item) => item.name === 'ecc'); + assert.ok(plugin && plugin.enabled); + assert.strictEqual(plugin.provides.hooks, true); + assert.strictEqual(plugin.provides.mcpServers, 1); + } + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('untrusted and denied plans omit hooks and every MCP capability', () => { + const fixture = createPinnedFixture(); + try { + for (const options of [ + { trust: false, hooks: true, mcp: ['chrome-devtools', 'other-mcp'] }, + { trust: true, hooks: false, mcp: [] }, + ]) { + fs.rmSync(path.join(fixture.homeDir, '.grok'), { recursive: true, force: true }); + const plan = createPlan(fixture, options); + applyInstallPlan(plan); + const manifest = JSON.parse(fs.readFileSync(path.join(installedRoot(fixture), '.grok-plugin', 'plugin.json'), 'utf8')); + assert.strictEqual(manifest.hooks, ''); + assert.strictEqual(manifest.mcpServers, ''); + assert.ok(!fs.existsSync(path.join(installedRoot(fixture), '.mcp.json'))); + assert.ok(!fs.existsSync(path.join(installedRoot(fixture), 'hooks', 'hooks.json'))); + const inspection = inspectWithGrok(fixture); + if (inspection) { + const plugin = inspection.plugins.find((item) => item.name === 'ecc'); + assert.ok(plugin && plugin.enabled); + assert.strictEqual(plugin.provides.hooks, false); + assert.strictEqual(plugin.provides.mcpServers, 0); + } + } + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('canonical source preparation installs the pinned commit, not HEAD or dirty files', () => { + const fixture = createPinnedFixture(); + try { + fs.writeFileSync(path.join(fixture.sourceRoot, 'commands', 'plan.md'), 'dirty payload\n'); + fs.writeFileSync(path.join(fixture.sourceRoot, 'untracked.txt'), 'do not install\n'); + const plan = createPlan(fixture, { hooks: false, mcp: [] }); + applyInstallPlan(plan); + assert.strictEqual( + fs.readFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'utf8'), + 'pinned payload\n' + ); + assert.ok(!fs.existsSync(path.join(installedRoot(fixture), 'untracked.txt'))); + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('source identity preserves case-sensitive remote paths', () => { + const fixture = createPinnedFixture(); + try { + const marketplacePath = path.join(fixture.sourceRoot, '.grok-plugin', 'marketplace.json'); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf8')); + marketplace.plugins[0].source.url = 'https://example.com/Owner/ECC.git'; + writeJson(marketplacePath, marketplace); + assert.throws( + () => createPlan( + fixture, + { hooks: false, mcp: [] }, + { sourceUrl: 'https://example.com/owner/ECC.git' } + ), + /source URL does not match/ + ); + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('source identity rejects a marketplace version that does not match the pinned plugin', () => { + const fixture = createPinnedFixture(); + try { + const marketplacePath = path.join(fixture.sourceRoot, '.grok-plugin', 'marketplace.json'); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf8')); + marketplace.plugins[0].version = '9.9.9'; + writeJson(marketplacePath, marketplace); + assert.throws( + () => createPlan(fixture, { hooks: false, mcp: [] }), + /plugin version 2\.2\.1 does not match marketplace version 9\.9\.9/ + ); + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('marketplace-extracted trees require matching Grok registry evidence and refetch the pin', () => { + const fixture = createPinnedFixture(); + const extractedRoot = path.join(fixture.parent, 'extracted'); + try { + fs.mkdirSync(path.join(extractedRoot, '.grok-plugin'), { recursive: true }); + fs.copyFileSync( + path.join(fixture.sourceRoot, '.grok-plugin', 'marketplace.json'), + path.join(extractedRoot, '.grok-plugin', 'marketplace.json') + ); + assert.throws( + () => createInstallPlanFromRequest(request(), { + sourceRoot: extractedRoot, + projectRoot: extractedRoot, + homeDir: fixture.homeDir, + env: {}, + }), + /registry|verified source|unverifiable/i + ); + + writeJson(path.join(fixture.homeDir, '.grok', 'installed-plugins', 'registry.json'), { + version: 1, + repos: { + ecc: { + path: extractedRoot, + kind: { type: 'Git', url: fixture.sourceRoot, commit: fixture.payloadSha }, + plugins: { ecc: { version: '2.2.1' } }, + }, + }, + }); + const plan = createInstallPlanFromRequest(request({ hooks: false, mcp: [] }), { + sourceRoot: extractedRoot, + projectRoot: extractedRoot, + homeDir: fixture.homeDir, + env: {}, + }); + applyInstallPlan(plan); + assert.strictEqual( + fs.readFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'utf8'), + 'pinned payload\n' + ); + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('repair, uninstall, upgrade, and rollback stay on canonical install state', () => { + const fixture = createPinnedFixture(); + try { + const firstPlan = createPlan(fixture, { hooks: false, mcp: [] }); + applyInstallPlan(firstPlan); + const firstState = JSON.parse(fs.readFileSync(firstPlan.installStatePath, 'utf8')); + + fs.writeFileSync(path.join(fixture.sourceRoot, 'commands', 'plan.md'), 'upgraded payload\n'); + const upgradedSha = commit(fixture.sourceRoot, 'upgraded payload'); + const marketplacePath = path.join(fixture.sourceRoot, '.grok-plugin', 'marketplace.json'); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf8')); + marketplace.plugins[0].source.sha = upgradedSha; + writeJson(marketplacePath, marketplace); + commit(fixture.sourceRoot, 'upgraded catalog'); + const upgradePlan = createPlan(fixture, { hooks: false, mcp: [] }); + applyInstallPlan(upgradePlan); + assert.strictEqual( + fs.readFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'utf8'), + 'upgraded payload\n' + ); + + fs.writeFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'broken\n'); + const repair = repairInstalledStates({ + repoRoot: fixture.sourceRoot, + projectRoot: fixture.sourceRoot, + homeDir: fixture.homeDir, + targets: ['grok'], + env: {}, + }); + assert.strictEqual(repair.results[0].status, 'repaired', JSON.stringify(repair.results[0])); + assert.strictEqual( + fs.readFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'utf8'), + 'upgraded payload\n' + ); + + const rollbackPlan = createPlan( + fixture, + { hooks: false, mcp: [] }, + { sourceSha: firstState.source.repoCommit } + ); + applyInstallPlan(rollbackPlan); + const rolledBackState = JSON.parse(fs.readFileSync(rollbackPlan.installStatePath, 'utf8')); + assert.strictEqual(rolledBackState.source.repoCommit, firstState.source.repoCommit); + assert.strictEqual( + fs.readFileSync(path.join(installedRoot(fixture), 'commands', 'plan.md'), 'utf8'), + 'pinned payload\n' + ); + + const uninstall = uninstallInstalledStates({ + repoRoot: fixture.sourceRoot, + projectRoot: fixture.sourceRoot, + homeDir: fixture.homeDir, + targets: ['grok'], + env: {}, + }); + assert.strictEqual(uninstall.results[0].status, 'uninstalled'); + assert.ok(!fs.existsSync(firstPlan.installStatePath)); + assert.ok(!fs.existsSync(installedRoot(fixture))); + } finally { + fs.rmSync(fixture.parent, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/grok-harness-adapter.test.js b/tests/lib/grok-harness-adapter.test.js index af0ba8971..020c11fda 100644 --- a/tests/lib/grok-harness-adapter.test.js +++ b/tests/lib/grok-harness-adapter.test.js @@ -1,8 +1,5 @@ /** - * Conformance tests for the Grok harness adapter. - * - * These tests require the shipped adapter (`scripts/lib/grok-harness-adapter.js`) - * and the shared resolver. They do not reimplement adapter logic. + * Read-only Grok harness boundary tests. */ const assert = require('assert'); @@ -11,33 +8,26 @@ const os = require('os'); const path = require('path'); const adapter = require('../../scripts/lib/grok-harness-adapter'); -const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); const { - resolveEccRoot, - pluginRootFromEnv, - PLUGIN_ROOT_ENV_KEYS, + SHA_PATTERN, + readPinnedMarketplaceSource, +} = require('../../scripts/lib/grok-source-identity'); +const { INLINE_RESOLVE, + resolveEccRoot, } = require('../../scripts/lib/resolve-ecc-root'); const repoRoot = path.resolve(__dirname, '..', '..'); -const marketplacePath = path.join(repoRoot, '.grok-plugin', 'marketplace.json'); -const mcpPath = path.join(repoRoot, '.mcp.json'); -const ECC_SKILL_SENTINEL = path.join('skills', 'continuous-learning-v2'); - -const FIXTURE_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const OTHER_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const TASTEFORGE_SHA = 'd8409a4b0813771235555e32e3d8046a73988bfa'; - let passed = 0; let failed = 0; function test(name, fn) { try { fn(); - console.log(` ✓ ${name}`); + console.log(` \u2713 ${name}`); return true; } catch (error) { - console.log(` ✗ ${name}`); + console.log(` \u2717 ${name}`); console.log(` Error: ${error.message}`); return false; } @@ -47,128 +37,47 @@ function createTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-grok-adapter-')); } -function writeCompleteRoot(root) { +function writeCompleteRoot(root, sha = null) { fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true }); fs.writeFileSync(path.join(root, 'scripts', 'lib', 'utils.js'), '// stub\n'); - fs.mkdirSync(path.join(root, ECC_SKILL_SENTINEL), { recursive: true }); - fs.mkdirSync(path.join(root, 'hooks'), { recursive: true }); - fs.writeFileSync( - path.join(root, 'hooks', 'hooks.json'), - JSON.stringify({ - hooks: { - SessionStart: [{ - command: 'node -e "var e=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_PLUGIN_ROOT"', - }], - }, - }) - ); - return root; -} - -function sourceFixture(root, sha = FIXTURE_SHA) { - writeCompleteRoot(root); - fs.writeFileSync(path.join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { - 'chrome-devtools': { command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest'] }, - 'other-mcp': { command: 'echo' }, - }, - })); - return { - sourceRoot: root, - source: { - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha, - }, - mcpConfig: JSON.parse(fs.readFileSync(path.join(root, '.mcp.json'), 'utf8')), - version: '2.2.0', - }; -} - -function gitCommit(sourceRoot, message) { - const { execFileSync } = require('child_process'); - execFileSync('git', ['add', '.'], { cwd: sourceRoot, stdio: 'ignore' }); - execFileSync('git', [ - '-c', 'user.email=grok-adapter@test', - '-c', 'user.name=GrokAdapter', - 'commit', '-m', message, - ], { cwd: sourceRoot, stdio: 'ignore' }); - return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: sourceRoot, encoding: 'utf8' }).trim(); -} - -function gitSourceFixture(sourceRoot) { - const { execFileSync } = require('child_process'); - const fixture = sourceFixture(sourceRoot, FIXTURE_SHA); - fs.mkdirSync(path.join(sourceRoot, '.grok-plugin'), { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, '.grok-plugin', 'marketplace.json'), JSON.stringify({ - name: 'ecc', - plugins: [{ - name: 'ecc', - version: '2.2.0', - source: { - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha: OTHER_SHA, - }, - }], - })); - execFileSync('git', ['init'], { cwd: sourceRoot, stdio: 'ignore' }); - fixture.source.sha = gitCommit(sourceRoot, 'fixture'); - return fixture; + fs.mkdirSync(path.join(root, 'skills', 'continuous-learning-v2'), { recursive: true }); + if (sha) fs.writeFileSync(path.join(root, '.ecc-source.json'), JSON.stringify({ sha })); } function runTests() { - console.log('\n=== Grok harness adapter ===\n'); + console.log('\n=== Grok harness adapter boundary ===\n'); - if (test('shared PLUGIN_ROOT_ENV_KEYS does not include GROK_PLUGIN_ROOT', () => { - assert.ok(Array.isArray(PLUGIN_ROOT_ENV_KEYS)); - assert.ok(!PLUGIN_ROOT_ENV_KEYS.includes('GROK_PLUGIN_ROOT')); + if (test('shared root resolution has no Grok aliases or layout policy', () => { assert.ok(!INLINE_RESOLVE.includes('GROK_PLUGIN_ROOT')); - })) passed++; else failed++; - - if (test('shared pluginRootFromEnv ignores GROK_PLUGIN_ROOT', () => { - assert.strictEqual(pluginRootFromEnv({ - GROK_PLUGIN_ROOT: '/grok/root', - ECC_PLUGIN_ROOT: '/ecc/root', - }), '/ecc/root'); - assert.strictEqual(pluginRootFromEnv({ GROK_PLUGIN_ROOT: '/grok/root' }), ''); - })) passed++; else failed++; - - if (test('shared resolveEccRoot ignores GROK_PLUGIN_ROOT and ~/.grok layout', () => { const homeDir = createTempDir(); try { - const grokRoot = path.join(homeDir, '.grok', 'installed-plugins', 'ecc-deadbeef'); - writeCompleteRoot(grokRoot); - const previous = process.env.GROK_PLUGIN_ROOT; - const previousClaude = process.env.CLAUDE_PLUGIN_ROOT; - const previousPlugin = process.env.PLUGIN_ROOT; - const previousEcc = process.env.ECC_PLUGIN_ROOT; - try { - process.env.GROK_PLUGIN_ROOT = grokRoot; - delete process.env.CLAUDE_PLUGIN_ROOT; - delete process.env.PLUGIN_ROOT; - delete process.env.ECC_PLUGIN_ROOT; - const result = resolveEccRoot({ homeDir, envRoot: '' }); - assert.strictEqual(result, path.join(homeDir, '.claude')); - assert.notStrictEqual(result, grokRoot); - } finally { - if (previous === undefined) delete process.env.GROK_PLUGIN_ROOT; - else process.env.GROK_PLUGIN_ROOT = previous; - if (previousClaude === undefined) delete process.env.CLAUDE_PLUGIN_ROOT; - else process.env.CLAUDE_PLUGIN_ROOT = previousClaude; - if (previousPlugin === undefined) delete process.env.PLUGIN_ROOT; - else process.env.PLUGIN_ROOT = previousPlugin; - if (previousEcc === undefined) delete process.env.ECC_PLUGIN_ROOT; - else process.env.ECC_PLUGIN_ROOT = previousEcc; - } + const root = path.join(homeDir, '.grok', 'installed-plugins', 'ecc-deadbeef'); + writeCompleteRoot(root); + assert.strictEqual(resolveEccRoot({ homeDir, envRoot: '' }), path.join(homeDir, '.claude')); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } })) passed++; else failed++; - if (test('shared hook runners do not require the Grok adapter', () => { + if (test('GROK_PLUGIN_ROOT is translated only at the adapter boundary', () => { + const mapped = adapter.toSharedPluginEnv({ + GROK_PLUGIN_ROOT: '/grok/root', + PATH: '/bin', + }); + assert.strictEqual(mapped.PLUGIN_ROOT, '/grok/root'); + assert.strictEqual(mapped.GROK_PLUGIN_ROOT, undefined); + const existing = adapter.toSharedPluginEnv({ + GROK_PLUGIN_ROOT: '/grok/root', + CLAUDE_PLUGIN_ROOT: '/claude/root', + }); + assert.strictEqual(existing.PLUGIN_ROOT, undefined); + assert.strictEqual(resolveEccRoot({ envRoot: existing.CLAUDE_PLUGIN_ROOT }), '/claude/root'); + })) passed++; else failed++; + + if (test('shared hook and resolver files contain no Grok provider policy', () => { const files = [ 'scripts/lib/hook-flags.js', + 'scripts/lib/resolve-ecc-root.js', 'scripts/hooks/plugin-hook-bootstrap.js', 'scripts/hooks/run-with-flags.js', 'scripts/hooks/observe-runner.js', @@ -176,670 +85,55 @@ function runTests() { ]; for (const relative of files) { const source = fs.readFileSync(path.join(repoRoot, relative), 'utf8'); - assert.ok(!source.includes('grok-harness-adapter'), `${relative} must not require the Grok adapter`); - assert.ok(!source.includes('GROK_PLUGIN_ROOT'), `${relative} must not select GROK_PLUGIN_ROOT`); - } - const resolveSource = fs.readFileSync(path.join(repoRoot, 'scripts/lib/resolve-ecc-root.js'), 'utf8'); - assert.ok(!resolveSource.includes('GROK_PLUGIN_ROOT')); - })) passed++; else failed++; - - if (test('toSharedPluginEnv maps GROK_PLUGIN_ROOT onto PLUGIN_ROOT and drops the Grok alias', () => { - const mapped = adapter.toSharedPluginEnv({ - GROK_PLUGIN_ROOT: '/grok/root', - PATH: '/bin', - }); - assert.strictEqual(mapped.PLUGIN_ROOT, '/grok/root'); - assert.strictEqual(mapped.GROK_PLUGIN_ROOT, undefined); - assert.strictEqual(pluginRootFromEnv(mapped), '/grok/root'); - })) passed++; else failed++; - - if (test('toSharedPluginEnv does not let GROK override an existing Claude/Codex root', () => { - const mapped = adapter.toSharedPluginEnv({ - CLAUDE_PLUGIN_ROOT: '/claude/root', - GROK_PLUGIN_ROOT: '/grok/root', - }); - assert.strictEqual(mapped.GROK_PLUGIN_ROOT, undefined); - assert.strictEqual(pluginRootFromEnv(mapped), '/claude/root'); - })) passed++; else failed++; - - if (test('adapter resolves GROK_PLUGIN_ROOT and ~/.grok installed-plugins', () => { - const homeDir = createTempDir(); - try { - assert.strictEqual( - adapter.resolveGrokPluginRoot({ env: { GROK_PLUGIN_ROOT: ' /from/grok ' }, homeDir }), - '/from/grok' - ); - const installed = path.join(homeDir, '.grok', 'installed-plugins', 'ecc-ab12cd34'); - writeCompleteRoot(installed); - assert.strictEqual(adapter.resolveGrokPluginRoot({ env: {}, homeDir }), installed); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); + assert.ok(!source.includes('GROK_PLUGIN_ROOT'), relative); + assert.ok(!source.includes('grok-harness-adapter'), relative); } })) passed++; else failed++; - if (test('runGrokInstall dry-run is the ECC consent path and denies Chrome DevTools by default', () => { - const homeDir = createTempDir(); - try { - const result = adapter.runGrokInstall({ dryRun: true, homeDir, repoRoot }); - assert.strictEqual(result.kind, 'grok-install-result'); - assert.strictEqual(result.dryRun, true); - assert.strictEqual(result.plan.attachChromeDevtools, false); - assert.strictEqual(result.plan.hooksEnabled, false); - assert.strictEqual(result.nativeMcpOptedOut, true); - assert.strictEqual(result.nativeHooksOptedOut, true); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('preview exposes canonical operations and their concrete destinations', () => { - const homeDir = createTempDir(); - try { - const plan = adapter.previewInstall({ - homeDir, - source: { source: 'url', url: 'https://github.com/affaan-m/ECC.git', sha: FIXTURE_SHA }, - trust: true, - consent: { hooks: true, mcp: { 'other-mcp': true } }, - mcpConfig: { mcpServers: { 'other-mcp': { command: 'echo' } } }, - }); - assert.ok(Array.isArray(plan.operations) && plan.operations.length > 0); - assert.ok(plan.operations.every((operation) => ( - ['copy-file', 'render-template', 'merge-json', 'remove'].includes(operation.kind) - ))); - assert.ok(plan.operations.every((operation) => path.isAbsolute(operation.destinationPath))); - assert.ok(plan.operations.some((operation) => operation.destinationPath.includes(path.join('.grok', 'plugins')))); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall copies the pinned git archive, not HEAD or untracked files', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - const pinnedSha = fixture.source.sha; - fs.writeFileSync(path.join(sourceRoot, 'untracked-extra.txt'), 'should-not-copy\n'); - fs.writeFileSync(path.join(sourceRoot, 'later-commit.txt'), 'later\n'); - gitCommit(sourceRoot, 'later'); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - assert.ok(!fs.existsSync(path.join(receipt.installedRoot, 'untracked-extra.txt'))); - assert.ok(!fs.existsSync(path.join(receipt.installedRoot, 'later-commit.txt'))); - assert.ok(fs.existsSync(path.join(receipt.installedRoot, 'scripts', 'lib', 'utils.js'))); - const identity = JSON.parse(fs.readFileSync( - path.join(receipt.installedRoot, adapter.SOURCE_IDENTITY_FILE), - 'utf8' - )); - assert.strictEqual(identity.sha, pinnedSha); - const installedCatalog = JSON.parse(fs.readFileSync( - path.join(receipt.installedRoot, '.grok-plugin', 'marketplace.json'), - 'utf8' - )); - assert.strictEqual(installedCatalog.plugins[0].source.sha, pinnedSha); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall rejects a git pin that is not in the source repository', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - fixture.source.sha = OTHER_SHA; - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - assert.throws( - () => adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }), - /not in source git/ - ); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall rejects a non-Git source tree with only self-asserted identity', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = sourceFixture(sourceRoot); - fs.mkdirSync(path.join(sourceRoot, '.grok-plugin'), { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, '.grok-plugin', 'marketplace.json'), JSON.stringify({ - plugins: [{ name: 'ecc', version: fixture.version, source: fixture.source }], - })); - const plan = adapter.previewInstall({ ...fixture, homeDir, trust: true }); - assert.throws( - () => adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }), - /unverifiable|verified source|source identity/i - ); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('runGrokInstall rejects malformed .mcp.json instead of silently disabling capabilities', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - fs.mkdirSync(path.join(sourceRoot, '.grok-plugin'), { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, '.grok-plugin', 'marketplace.json'), JSON.stringify({ - plugins: [{ - name: 'ecc', - version: '2.2.0', - source: { source: 'url', url: 'https://github.com/affaan-m/ECC.git', sha: FIXTURE_SHA }, - }], - })); - fs.writeFileSync(path.join(sourceRoot, '.grok-plugin', 'plugin.json'), JSON.stringify({ - name: 'ecc', - hooks: '', - mcpServers: '', - })); - fs.writeFileSync(path.join(sourceRoot, '.mcp.json'), '{not-json'); - assert.throws( - () => adapter.runGrokInstall({ dryRun: true, homeDir, repoRoot: sourceRoot }), - /\.mcp\.json|JSON/i - ); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('missing Grok root returns null', () => { - const homeDir = createTempDir(); - try { - assert.strictEqual(adapter.resolveGrokPluginRoot({ env: {}, homeDir }), null); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('in-repo marketplace source is a pinned Git URL', () => { - const { execFileSync } = require('child_process'); - const plugin = adapter.readMarketplaceSource(marketplacePath); - assert.strictEqual(plugin.source.url, 'https://github.com/affaan-m/ECC.git'); - assert.match(plugin.source.sha, adapter.SHA_PATTERN); - assert.notStrictEqual(plugin.source.sha, TASTEFORGE_SHA); - const tracked = execFileSync('git', ['ls-files', 'scripts/lib/grok-harness-adapter.js'], { - cwd: repoRoot, - encoding: 'utf8', - }).trim(); - if (tracked) { - execFileSync('git', ['show', `${plugin.source.sha}:scripts/lib/grok-harness-adapter.js`], { - cwd: repoRoot, - stdio: ['ignore', 'pipe', 'ignore'], - }); - } - assert.ok(adapter.sourceIdentity(plugin.source).includes(plugin.source.sha)); - })) passed++; else failed++; - - if (test('native Grok plugin manifest opts trusted installs out of chrome-devtools', () => { - const pluginJsonPath = path.join(repoRoot, '.grok-plugin', 'plugin.json'); - const manifest = adapter.readGrokPluginManifest(pluginJsonPath); - assert.strictEqual(manifest.mcpServers, ''); + if (test('native trusted manifest defaults deny hooks and root MCP', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, '.grok-plugin', 'plugin.json'), 'utf8')); assert.strictEqual(manifest.hooks, ''); - assert.strictEqual(adapter.nativePluginMcpOptedOut(manifest), true); - assert.deepStrictEqual(adapter.nativeTrustedMcpServerNames(manifest), []); - assert.strictEqual(adapter.nativeTrustedInstallAttachesChromeDevtools(manifest), false); - const rootMcp = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); - assert.ok(rootMcp.mcpServers['chrome-devtools'], 'Claude/Codex root .mcp.json may still list chrome-devtools'); + assert.strictEqual(manifest.mcpServers, ''); + const rootMcp = JSON.parse(fs.readFileSync(path.join(repoRoot, '.mcp.json'), 'utf8')); + assert.ok(rootMcp.mcpServers['chrome-devtools']); })) passed++; else failed++; - if (test('unpinned marketplace source is rejected', () => { - assert.throws( - () => adapter.assertPinnedSource({ source: 'url', url: 'https://github.com/affaan-m/ECC.git' }), - /40-character lowercase commit SHA/ - ); + if (test('marketplace source is the pinned whole repository', () => { + const source = readPinnedMarketplaceSource(repoRoot); + assert.strictEqual(source.url, 'https://github.com/affaan-m/ECC.git'); + assert.match(source.sha, SHA_PATTERN); })) passed++; else failed++; - if (test('preview names hooks and each MCP capability; trusted default denies Chrome DevTools', () => { - const mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); - const plan = adapter.previewInstall({ - source: { source: 'url', url: 'https://github.com/affaan-m/ECC.git', sha: FIXTURE_SHA }, - trust: true, - mcpConfig, - }); - const ids = plan.capabilities.map((item) => item.id); - assert.ok(ids.includes('hooks')); - assert.ok(ids.includes('chrome-devtools')); - assert.strictEqual(plan.hooksEnabled, false); - assert.strictEqual(plan.attachChromeDevtools, false); - assert.ok(!plan.mcpAttached.includes('chrome-devtools')); - assert.ok(plan.capabilities.every((item) => item.consented === false)); - })) passed++; else failed++; - - if (test('untrusted install cannot enable hooks or MCP even with consent', () => { - const plan = adapter.previewInstall({ - source: { source: 'url', url: 'https://github.com/affaan-m/ECC.git', sha: FIXTURE_SHA }, - trust: false, - consent: { hooks: true, mcp: { 'chrome-devtools': true } }, - mcpConfig: { mcpServers: { 'chrome-devtools': { command: 'npx' } } }, - }); - assert.strictEqual(plan.trust, false); - assert.strictEqual(plan.hooksEnabled, false); - assert.strictEqual(plan.attachChromeDevtools, false); - })) passed++; else failed++; - - if (test('capability denial keeps Chrome DevTools out of a trusted receipt', () => { + if (test('missing, disabled, native, cached, and pinned roots resolve deterministically', () => { const homeDir = createTempDir(); - const sourceRoot = createTempDir(); + const sha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; try { - const fixture = sourceFixture(sourceRoot); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true, mcp: { 'chrome-devtools': false, 'other-mcp': true } }, - }); - assert.strictEqual(plan.hooksEnabled, true); - assert.strictEqual(plan.attachChromeDevtools, false); - assert.deepStrictEqual(plan.mcpAttached, ['other-mcp']); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - const installedMcp = JSON.parse(fs.readFileSync(path.join(receipt.installedRoot, '.mcp.json'), 'utf8')); - assert.ok(!installedMcp.mcpServers['chrome-devtools']); - assert.ok(installedMcp.mcpServers['other-mcp']); - assert.ok(receipt.hooksEnabled); - assert.ok(!receipt.mcpAttached.includes('chrome-devtools')); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; + assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir, env: {} }), null); + const nativeRoot = path.join(homeDir, '.grok', 'plugins', 'ecc'); + writeCompleteRoot(nativeRoot); + assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir, env: {} }), nativeRoot); + assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir, enabled: false }), null); - if (test('trusted install without MCP consent does not attach root chrome-devtools', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = sourceFixture(sourceRoot); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - const installedMcp = JSON.parse(fs.readFileSync(path.join(receipt.installedRoot, '.mcp.json'), 'utf8')); - assert.deepStrictEqual(installedMcp.mcpServers, {}); - assert.strictEqual(receipt.attachChromeDevtools, undefined); - assert.deepStrictEqual(receipt.mcpAttached, []); - const statePath = adapter.grokInstallStatePath(homeDir); - assert.ok(fs.existsSync(statePath)); - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); - assert.strictEqual(state.schemaVersion, 'ecc.install.v1'); - assert.strictEqual(state.target.id, 'grok-home'); - assert.strictEqual(state.target.target, 'grok'); - assert.strictEqual(state.source.repoCommit, fixture.source.sha); - assert.ok(!fs.existsSync(path.join(homeDir, '.grok', 'ecc-adapter'))); - const mapped = adapter.toSharedPluginEnv({ GROK_PLUGIN_ROOT: receipt.installedRoot }); - assert.strictEqual(mapped.PLUGIN_ROOT, receipt.installedRoot); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall maps GROK_PLUGIN_ROOT at the installed hook boundary when hooks are consented', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = sourceFixture(sourceRoot); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - assert.strictEqual(receipt.trust, true); - const destHooks = fs.readFileSync(path.join(receipt.installedRoot, 'hooks', 'hooks.json'), 'utf8'); - assert.ok(destHooks.includes('GROK_PLUGIN_ROOT')); - const sharedEnv = adapter.readInstalledSharedEnv(receipt.installedRoot); - assert.strictEqual(sharedEnv.PLUGIN_ROOT, receipt.installedRoot); - const repoHooks = fs.readFileSync(path.join(repoRoot, 'hooks', 'hooks.json'), 'utf8'); - assert.ok(!repoHooks.includes('GROK_PLUGIN_ROOT')); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall rejects a source tree whose marketplace pin does not match the plan', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = sourceFixture(sourceRoot); - fs.mkdirSync(path.join(sourceRoot, '.grok-plugin'), { recursive: true }); - fs.writeFileSync(path.join(sourceRoot, '.grok-plugin', 'marketplace.json'), JSON.stringify({ - name: 'ecc', - plugins: [{ - name: 'ecc', - version: '2.2.0', - source: { - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha: OTHER_SHA, - }, - }], - })); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - assert.throws( - () => adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }), - /marketplace sha does not match/ - ); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('install is root-contained; Windows-style escape is rejected', () => { - const grokRoot = 'C:\\Users\\ecc\\.grok'; - assert.ok(adapter.isContained('C:\\Users\\ecc\\.grok\\installed-plugins\\ecc-aaa', grokRoot, path.win32)); - assert.ok(!adapter.isContained('C:\\Windows\\System32', grokRoot, path.win32)); - assert.ok(!adapter.isContained('C:\\Users\\ecc\\.grok\\..\\..\\Windows', grokRoot, path.win32)); - assert.throws( - () => adapter.assertRootContainment('C:\\Windows\\System32', grokRoot, path.win32), - /escapes Grok root/ - ); - })) passed++; else failed++; - - if (test('applyInstall rejects a symlinked plugin-directory ancestor without writing outside Grok home', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - const outsideRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - const grokRoot = path.join(homeDir, '.grok'); - fs.mkdirSync(grokRoot, { recursive: true }); - try { - fs.symlinkSync(outsideRoot, path.join(grokRoot, 'plugins'), 'dir'); - } catch { - console.log(' (directory symlink unsupported on this platform; skipping)'); - return; - } - const plan = adapter.previewInstall({ ...fixture, homeDir, trust: true }); - assert.throws(() => adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }), /outside|symlink|root/i); - assert.deepStrictEqual(fs.readdirSync(outsideRoot), []); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - fs.rmSync(outsideRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('applyInstall rejects a final destination symlink without modifying its target', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - const outsideRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - const plan = adapter.previewInstall({ ...fixture, homeDir, trust: true }); - const destination = adapter.installDestination(homeDir, plan); - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.writeFileSync(path.join(outsideRoot, 'sentinel.txt'), 'unchanged\n'); - try { - fs.symlinkSync(outsideRoot, destination, 'dir'); - } catch { - console.log(' (directory symlink unsupported on this platform; skipping)'); - return; - } - assert.throws(() => adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }), /outside|symlink|root/i); - assert.deepStrictEqual(fs.readdirSync(outsideRoot), ['sentinel.txt']); - assert.strictEqual(fs.readFileSync(path.join(outsideRoot, 'sentinel.txt'), 'utf8'), 'unchanged\n'); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - fs.rmSync(outsideRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('canonical lifecycle uninstalls a Grok receipt with only supported operation kinds', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - const projectRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - const plan = adapter.previewInstall({ ...fixture, homeDir, trust: true, consent: { hooks: true } }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - const state = JSON.parse(fs.readFileSync(adapter.grokInstallStatePath(homeDir), 'utf8')); - assert.ok(state.operations.every((operation) => ( - ['copy-file', 'render-template', 'merge-json', 'remove'].includes(operation.kind) - ))); - const result = uninstallInstalledStates({ - repoRoot, - homeDir, - projectRoot, - targets: ['grok'], - }); - assert.strictEqual(result.results[0].status, 'uninstalled'); - assert.ok(!fs.existsSync(receipt.installedRoot)); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('consented capabilities are installed in Grok native discovery and denied capabilities are absent', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = gitSourceFixture(sourceRoot); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true, mcp: { 'other-mcp': true, 'chrome-devtools': false } }, - }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - assert.ok(receipt.installedRoot.startsWith(path.join(homeDir, '.grok', 'plugins') + path.sep)); - assert.ok(fs.existsSync(path.join(receipt.installedRoot, 'hooks', 'hooks.json'))); - const installedManifest = JSON.parse(fs.readFileSync( - path.join(receipt.installedRoot, '.grok-plugin', 'plugin.json'), - 'utf8' - )); - assert.notStrictEqual(installedManifest.hooks, ''); - assert.notStrictEqual(installedManifest.mcpServers, ''); - const installedMcp = JSON.parse(fs.readFileSync(path.join(receipt.installedRoot, '.mcp.json'), 'utf8')); - assert.deepStrictEqual(Object.keys(installedMcp.mcpServers), ['other-mcp']); - assert.ok(!JSON.stringify(installedMcp).includes('chrome-devtools')); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('upgrade, uninstall, and rollback are receipt-backed on fixture trees', () => { - const homeDir = createTempDir(); - const firstRoot = createTempDir(); - const secondRoot = createTempDir(); - try { - const first = sourceFixture(firstRoot, FIXTURE_SHA); - const second = sourceFixture(secondRoot, OTHER_SHA); - const firstPlan = adapter.previewInstall({ - ...first, - homeDir, - trust: true, - consent: { hooks: true }, - }); - const firstReceipt = adapter.applyInstall(firstPlan, { mcpConfig: first.mcpConfig, now: '2026-09-03T00:00:00.000Z' }); - assert.strictEqual(firstReceipt.operation, 'install'); - assert.ok(fs.existsSync(firstReceipt.installedRoot)); - - const secondPlan = adapter.previewInstall({ - ...second, - homeDir, - trust: true, - consent: { hooks: true, mcp: { 'chrome-devtools': true } }, - }); - const upgraded = adapter.applyInstall(secondPlan, { mcpConfig: second.mcpConfig, now: '2026-09-03T01:00:00.000Z' }); - assert.strictEqual(upgraded.operation, 'upgrade'); - assert.strictEqual(upgraded.previousReceiptId, firstReceipt.id); - assert.notStrictEqual(upgraded.installedRoot, firstReceipt.installedRoot); - assert.ok(upgraded.mcpAttached.includes('chrome-devtools')); - assert.ok(fs.existsSync(firstReceipt.installedRoot)); - assert.ok(fs.existsSync(upgraded.installedRoot)); - assert.strictEqual(adapter.listManagedInstallRoots(homeDir).length, 2); - - const rolled = adapter.rollback(homeDir); - assert.strictEqual(rolled.operation, 'rollback'); - assert.strictEqual(rolled.id, firstReceipt.id); - assert.strictEqual(adapter.loadCurrentReceipt(homeDir).id, firstReceipt.id); - assert.deepStrictEqual(adapter.loadCurrentReceipt(homeDir).mcpAttached, []); - assert.ok(fs.existsSync(firstReceipt.installedRoot)); - assert.ok(!fs.existsSync(upgraded.installedRoot)); - - const removed = adapter.uninstall(homeDir); - assert.strictEqual(removed.operation, 'uninstall'); - assert.ok(!fs.existsSync(firstReceipt.installedRoot)); - assert.ok(!fs.existsSync(upgraded.installedRoot)); - assert.deepStrictEqual(adapter.listManagedInstallRoots(homeDir), []); - assert.strictEqual(adapter.loadCurrentReceipt(homeDir), null); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(firstRoot, { recursive: true, force: true }); - fs.rmSync(secondRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('disabled plugin is not resolved as the active Grok root', () => { - const homeDir = createTempDir(); - const sourceRoot = createTempDir(); - try { - const fixture = sourceFixture(sourceRoot); - const plan = adapter.previewInstall({ - ...fixture, - homeDir, - trust: true, - consent: { hooks: true }, - }); - const receipt = adapter.applyInstall(plan, { mcpConfig: fixture.mcpConfig }); - assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir }), receipt.installedRoot); - adapter.setPluginEnabled(homeDir, false); - assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir }), null); - assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir, requireEnabled: false }), receipt.installedRoot); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(sourceRoot, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('multiple cached versions select only the pinned SHA', () => { - const homeDir = createTempDir(); - try { - const cacheBase = path.join(homeDir, '.grok', 'plugins', 'cache', 'ecc', 'affaan-m'); - const oldRoot = path.join(cacheBase, '2.1.0'); - const newRoot = path.join(cacheBase, '2.2.0'); - writeCompleteRoot(oldRoot); - writeCompleteRoot(newRoot); - fs.writeFileSync(path.join(oldRoot, adapter.SOURCE_IDENTITY_FILE), JSON.stringify({ - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha: OTHER_SHA, - })); - fs.writeFileSync(path.join(newRoot, adapter.SOURCE_IDENTITY_FILE), JSON.stringify({ - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha: FIXTURE_SHA, - })); - const versions = adapter.listCachedGrokVersions(homeDir); - assert.strictEqual(versions.length, 2); - const selected = adapter.selectPinnedCachedVersion(versions, FIXTURE_SHA); - assert.strictEqual(selected.installedRoot, newRoot); - assert.strictEqual( - adapter.resolveGrokPluginRoot({ homeDir, env: {}, pinnedSha: FIXTURE_SHA }), - newRoot - ); - assert.strictEqual(adapter.selectPinnedCachedVersion(versions, 'cccccccccccccccccccccccccccccccccccccccc'), null); + const cachedRoot = path.join(homeDir, '.grok', 'plugins', 'cache', 'ecc', 'affaan-m', '2.2.1'); + writeCompleteRoot(cachedRoot, sha); + assert.strictEqual(adapter.selectPinnedCachedVersion(adapter.listCachedGrokVersions(homeDir), sha).installedRoot, cachedRoot); + assert.strictEqual(adapter.resolveGrokPluginRoot({ homeDir, pinnedSha: sha }), cachedRoot); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } })) passed++; else failed++; - if (test('Windows install destination stays under .grok\\installed-plugins', () => { - const dest = adapter.installDestination( - 'C:\\Users\\ecc', - { pluginId: 'ecc', source: { sha: FIXTURE_SHA } }, - path.win32 - ); + if (test('Windows Grok roots stay adapter-owned', () => { assert.strictEqual( - dest, - path.win32.join('C:\\Users\\ecc', '.grok', 'installed-plugins', `ecc-${FIXTURE_SHA.slice(0, 12)}`) + adapter.grokInstallStatePath('C:\\Users\\ecc', path.win32), + path.win32.join('C:\\Users\\ecc', '.grok', 'ecc', 'install-state.json') ); - assert.ok(adapter.isContained(dest, 'C:\\Users\\ecc\\.grok', path.win32)); assert.strictEqual(adapter.resolveHomeDir({ ECC_GROK_HOME: 'D:\\Users\\ecc' }), 'D:\\Users\\ecc'); })) passed++; else failed++; - if (test('previewInstall and grok-home.planOperations use the same hook/MCP consent gate', () => { - const grokHome = require('../../scripts/lib/install-targets/grok-home'); - const mcpConfig = { - mcpServers: { - 'chrome-devtools': { command: 'npx' }, - 'other-mcp': { command: 'echo' }, - }, - }; - const source = { - source: 'url', - url: 'https://github.com/affaan-m/ECC.git', - sha: FIXTURE_SHA, - }; - const modules = [ - { id: 'hooks-runtime', paths: ['hooks', 'hooks/hooks.json'] }, - { id: 'mcp', paths: ['.mcp.json', 'mcp-configs'] }, - { id: 'skills', paths: ['skills'] }, - ]; - const consent = { hooks: true, mcp: { 'other-mcp': true, 'chrome-devtools': false } }; - const plan = adapter.previewInstall({ - source, - trust: true, - consent, - mcpConfig, - }); - const operations = grokHome.planOperations({ - homeDir: '/Users/example', - modules, - trust: true, - consent, - }); - const opPaths = operations.map((operation) => String(operation.sourceRelativePath).replace(/\\/g, '/')); - assert.strictEqual(plan.hooksEnabled, true); - assert.deepStrictEqual(plan.mcpAttached, ['other-mcp']); - assert.ok(opPaths.includes('hooks') || opPaths.includes('hooks/hooks.json')); - assert.ok(!opPaths.some((item) => item === '.mcp.json' || item.startsWith('mcp-configs'))); - assert.ok(opPaths.includes('skills')); - })) passed++; else failed++; - - if (test('native plugin.json opt-out is the grok plugin --trust contract', () => { - const { spawnSync } = require('child_process'); - const pluginJsonPath = path.join(repoRoot, '.grok-plugin', 'plugin.json'); - const manifest = adapter.readGrokPluginManifest(pluginJsonPath); - assert.strictEqual(adapter.nativeTrustedInstallAttachesChromeDevtools(manifest), false); - assert.deepStrictEqual(adapter.nativeTrustedMcpServerNames(manifest), []); - const validated = spawnSync('grok', ['plugin', 'validate', '.'], { - cwd: repoRoot, - encoding: 'utf8', - }); - if (!validated.error) { - assert.strictEqual(validated.status, 0, validated.stderr || validated.stdout); - assert.ok(!/chrome-devtools/i.test(validated.stdout || '')); + if (test('the adapter exports no private install lifecycle', () => { + for (const name of ['previewInstall', 'applyInstall', 'uninstall', 'rollback', 'runGrokInstall']) { + assert.strictEqual(adapter[name], undefined, name); } })) passed++; else failed++; diff --git a/tests/lib/harness-capabilities.test.js b/tests/lib/harness-capabilities.test.js index 9949de678..537e16972 100644 --- a/tests/lib/harness-capabilities.test.js +++ b/tests/lib/harness-capabilities.test.js @@ -103,6 +103,7 @@ function runTests() { adal: ['project', './.adal'], hermes: ['home', '~/.hermes'], openclaw: ['home', '~/.openclaw'], + grok: ['native', '~/.grok'], }; for (const [id, [scopeId, root]] of Object.entries(expected)) { @@ -175,7 +176,7 @@ function runTests() { assert.throws(() => normalizeHarnessSelection('none'), /At least one guided harness/); assert.throws(() => normalizeHarnessSelection('all,codex'), /cannot be combined/i); assert.throws(() => normalizeHarnessSelection('cursor'), /advanced.*not guided-ready/i); - assert.throws(() => normalizeHarnessSelection('grok'), /Unknown guided harness selection/); + assert.throws(() => normalizeHarnessSelection('grok'), /advanced.*not guided-ready/i); })) passed++; else failed++; if (test('exports deeply frozen records while list helpers return safe array copies', () => { diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 276591f68..563239b6d 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -52,6 +52,23 @@ function runTests() { assert.deepStrictEqual(parsed.languages, []); })) passed++; else failed++; + if (test('parses and validates the Grok rollback source pin', () => { + const sha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const parsed = parseInstallArgs([ + 'node', + 'scripts/install-apply.js', + '--target', 'grok', + '--profile', 'core', + '--source-sha', sha, + ]); + assert.strictEqual(parsed.sourceSha, sha); + assert.strictEqual(normalizeInstallRequest(parsed).sourceSha, sha); + assert.throws( + () => normalizeInstallRequest({ ...parsed, target: 'claude' }), + /requires --target grok/ + ); + })) passed++; else failed++; + if (test('parses --locale argument', () => { const parsed = parseInstallArgs([ 'node', diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index a0eadffb1..b5f9899bb 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -87,10 +87,46 @@ function runTests() { consent: { mcp: { 'chrome-devtools': true } }, }); const mcpPaths = mcpAllowed.map((operation) => String(operation.sourceRelativePath).replace(/\\/g, '/')); - assert.ok(!mcpPaths.some((item) => item === '.mcp.json' || item.startsWith('mcp-configs'))); + assert.ok(mcpPaths.includes('.mcp.json')); + assert.ok(mcpAllowed.some((operation) => ( + operation.contentTransform === 'grok-mcp-consent' + && operation.grokMcpIds.includes('chrome-devtools') + ))); assert.ok(!mcpPaths.some((item) => item === 'hooks' || item.startsWith('hooks/'))); })) passed++; else failed++; + if (test('grok MCP consent rejects malformed configs and unknown capabilities', () => { + const grok = getInstallTargetAdapter('grok'); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-grok-mcp-')); + try { + fs.writeFileSync(path.join(tempRoot, '.mcp.json'), '{"mcpServers": []}\n'); + assert.throws( + () => grok.planOperations({ + homeDir: tempRoot, + repoRoot: tempRoot, + modules: [], + trust: true, + consent: { mcp: { 'unknown-mcp': true } }, + }), + /mcpServers object/ + ); + + fs.writeFileSync(path.join(tempRoot, '.mcp.json'), '{"mcpServers": {"known-mcp": {}}}\n'); + assert.throws( + () => grok.planOperations({ + homeDir: tempRoot, + repoRoot: tempRoot, + modules: [], + trust: true, + consent: { mcp: { 'unknown-mcp': true } }, + }), + /Unknown Grok MCP capability: unknown-mcp/ + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('resolves cursor adapter root and install-state path from project root', () => { const adapter = getInstallTargetAdapter('cursor'); const projectRoot = '/workspace/app'; diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index ebf0d411c..9673baa73 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -2,12 +2,11 @@ * Tests for scripts/lib/resolve-ecc-root.js * * Covers the ECC root resolution fallback chain: - * 1. GROK_PLUGIN_ROOT / CLAUDE_PLUGIN_ROOT / ECC_PLUGIN_ROOT env vars + * 1. CLAUDE_PLUGIN_ROOT env var * 2. Standard install (~/.claude/) * 3. Exact legacy plugin roots under ~/.claude/plugins/ * 4. Plugin cache auto-detection - * 5. Grok plugin homes (~/.grok/plugins/, ~/.grok/installed-plugins/) - * 6. Fallback to ~/.claude/ + * 5. Fallback to ~/.claude/ */ const assert = require('assert'); @@ -18,11 +17,7 @@ const CURRENT_PACKAGE_VERSION = JSON.parse( fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8') ).version; -const { - resolveEccRoot, - INLINE_RESOLVE, - pluginRootFromEnv, -} = require('../../scripts/lib/resolve-ecc-root'); +const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root'); // Sentinel ECC skill that resolveEccRoot() requires (alongside the script tree) // before accepting a root for skill consumers. Kept in sync with the module's @@ -62,24 +57,6 @@ function setupLegacyPluginInstall(homeDir, segments) { fs.mkdirSync(path.join(legacyDir, ECC_SKILL_SENTINEL), { recursive: true }); return legacyDir; } -function setupVendorPluginInstall(homeDir, vendor, segments) { - const pluginDir = path.join(homeDir, vendor, 'plugins', ...segments); - const scriptDir = path.join(pluginDir, 'scripts', 'lib'); - fs.mkdirSync(scriptDir, { recursive: true }); - fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub'); - fs.mkdirSync(path.join(pluginDir, ECC_SKILL_SENTINEL), { recursive: true }); - return pluginDir; -} - -function setupGrokInstalledPlugin(homeDir, dirName) { - const pluginDir = path.join(homeDir, '.grok', 'installed-plugins', dirName); - const scriptDir = path.join(pluginDir, 'scripts', 'lib'); - fs.mkdirSync(scriptDir, { recursive: true }); - fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub'); - fs.mkdirSync(path.join(pluginDir, ECC_SKILL_SENTINEL), { recursive: true }); - return pluginDir; -} - function setupPluginCache(homeDir, pluginSlug, orgName, version) { const cacheDir = path.join( homeDir, '.claude', 'plugins', 'cache', @@ -537,119 +514,6 @@ module.exports = { resolveEccRoot() { assert.strictEqual(process.env.HOME, ${JSO } })) passed++; else failed++; - if (test('pluginRootFromEnv prefers GROK_PLUGIN_ROOT then CLAUDE then ECC', () => { - assert.strictEqual( - pluginRootFromEnv({ - GROK_PLUGIN_ROOT: '/grok/root', - CLAUDE_PLUGIN_ROOT: '/claude/root', - ECC_PLUGIN_ROOT: '/ecc/root', - }), - '/grok/root' - ); - assert.strictEqual( - pluginRootFromEnv({ - CLAUDE_PLUGIN_ROOT: '/claude/root', - ECC_PLUGIN_ROOT: '/ecc/root', - }), - '/claude/root' - ); - assert.strictEqual(pluginRootFromEnv({ ECC_PLUGIN_ROOT: '/ecc/root' }), '/ecc/root'); - assert.strictEqual(pluginRootFromEnv({ GROK_PLUGIN_ROOT: ' /trimmed/grok ' }), '/trimmed/grok'); - assert.strictEqual(pluginRootFromEnv({ GROK_PLUGIN_ROOT: ' ' }), ''); - assert.strictEqual(pluginRootFromEnv({}), ''); - })) passed++; else failed++; - - if (test('reads GROK_PLUGIN_ROOT from process.env when envRoot is omitted', () => { - const previous = process.env.GROK_PLUGIN_ROOT; - const previousClaude = process.env.CLAUDE_PLUGIN_ROOT; - const previousEcc = process.env.ECC_PLUGIN_ROOT; - const homeDir = createTempDir(); - try { - process.env.GROK_PLUGIN_ROOT = '/from/grok/env'; - delete process.env.CLAUDE_PLUGIN_ROOT; - delete process.env.ECC_PLUGIN_ROOT; - assert.strictEqual(resolveEccRoot({ homeDir }), '/from/grok/env'); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - if (previous === undefined) delete process.env.GROK_PLUGIN_ROOT; - else process.env.GROK_PLUGIN_ROOT = previous; - if (previousClaude === undefined) delete process.env.CLAUDE_PLUGIN_ROOT; - else process.env.CLAUDE_PLUGIN_ROOT = previousClaude; - if (previousEcc === undefined) delete process.env.ECC_PLUGIN_ROOT; - else process.env.ECC_PLUGIN_ROOT = previousEcc; - } - })) passed++; else failed++; - - if (test('finds Grok plugin install at ~/.grok/plugins/ecc', () => { - const homeDir = createTempDir(); - try { - const expected = setupVendorPluginInstall(homeDir, '.grok', ['ecc']); - const result = resolveEccRoot({ envRoot: '', homeDir }); - assert.strictEqual(result, expected); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('finds Grok plugin install at ~/.grok/plugins/ecc@ecc', () => { - const homeDir = createTempDir(); - try { - const expected = setupVendorPluginInstall(homeDir, '.grok', ['ecc@ecc']); - const result = resolveEccRoot({ envRoot: '', homeDir }); - assert.strictEqual(result, expected); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('finds Grok marketplace plugin install at ~/.grok/plugins/marketplaces/ecc', () => { - const homeDir = createTempDir(); - try { - const expected = setupVendorPluginInstall(homeDir, '.grok', ['marketplaces', 'ecc']); - const result = resolveEccRoot({ envRoot: '', homeDir }); - assert.strictEqual(result, expected); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('finds Grok installed-plugins hash directory when that is the only complete root', () => { - const homeDir = createTempDir(); - try { - const expected = setupGrokInstalledPlugin(homeDir, 'ecc-ab12cd34'); - const result = resolveEccRoot({ envRoot: '', homeDir }); - assert.strictEqual(result, expected); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('prefers a complete Claude root over a complete Grok root when env is unset', () => { - const homeDir = createTempDir(); - try { - const claudeDir = setupStandardInstall(homeDir); - setupGrokInstalledPlugin(homeDir, 'ecc-ab12cd34'); - const result = resolveEccRoot({ envRoot: '', homeDir }); - assert.strictEqual(result, claudeDir); - } finally { - fs.rmSync(homeDir, { recursive: true, force: true }); - } - })) passed++; else failed++; - - if (test('INLINE_RESOLVE returns GROK_PLUGIN_ROOT when set', () => { - const { execFileSync } = require('child_process'); - const env = { ...process.env, GROK_PLUGIN_ROOT: '/inline/grok/root' }; - delete env.CLAUDE_PLUGIN_ROOT; - delete env.ECC_PLUGIN_ROOT; - const result = execFileSync('node', [ - '-e', `console.log(${INLINE_RESOLVE})`, - ], { - env, - encoding: 'utf8', - }).trim(); - assert.strictEqual(result, '/inline/grok/root'); - })) passed++; else failed++; - if (test('INLINE_RESOLVE falls back to ~/.claude/ when nothing found', () => { const homeDir = createTempDir(); try { diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index e889fbfcd..8803acde3 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -733,7 +733,7 @@ test('grok plugin.json keeps Claude-incompatible fields out of the Grok manifest assert.strictEqual( grokPlugin.hooks, '', - 'Empty hooks string opts the native Grok CLI surface out of hooks/hooks.json; ECC hook consent is applyInstall' + 'Empty hooks string opts the native Grok CLI surface out of hooks/hooks.json; ECC hook consent is canonical install state' ); }); @@ -805,8 +805,8 @@ test('package.json files includes the Grok plugin directory', () => { test('.grok-plugin README documents install, trust, enable, MCP, and hook profile', () => { const readme = fs.readFileSync(grokPluginReadmePath, 'utf8'); - assert.ok(readme.includes('previewInstall'), 'ECC trusted install is previewInstall'); - assert.ok(readme.includes('applyInstall'), 'ECC trusted install is applyInstall'); + assert.ok(readme.includes('canonical installer'), 'ECC trusted install uses the canonical installer'); + assert.ok(readme.includes('install-apply.js --target grok'), 'Grok entrypoint delegates to install-apply'); assert.ok(readme.includes('grok-install.js'), 'Operator ECC consent path is scripts/grok-install.js'); assert.ok(readme.includes('grok plugin marketplace add affaan-m/ECC'), 'Expected marketplace add for the canonical ECC repo'); assert.ok(readme.includes('grok plugin enable ecc'), 'Grok plugins stay off until enabled');