Files
everything-claude-code/scripts/lib/mcp-inventory/canonical-mcp.js
Affaan Mustafa c8caf193c4 feat: worktree-lifecycle service (deterministic conflict prediction + safe GC) (#2164)
* feat: add worktree-lifecycle service (ecc.worktree-lifecycle.v1)

The "unowned moat" from the orchestrator landscape research: no existing
tool ships deterministic merge-conflict prediction or a safe worktree GC.

- scripts/lib/worktree-lifecycle/git.js: injectable, hermetic git layer.
  Predicts merge conflicts WITHOUT touching the working tree via
  `git merge-tree`. Strips inherited GIT_* env so it is safe inside hooks.
- scripts/lib/worktree-lifecycle/lifecycle.js: deterministic state machine
  (main/dirty/conflict/merge-ready/merged/stale/idle) + planCleanup that
  buckets worktrees into remove / salvage / keep. Only fully-merged trees
  are auto-removable; stale (unmerged+inactive) => salvage, never deleted.
- scripts/worktree-lifecycle.js: CLI (--json/--conflicts/--stale/
  --cleanup-plan/--base/--stale-days/--repo).
- tests/lib/worktree-lifecycle.test.js: 11 tests (fake-git + real-git).

Safety model mirrors the reference-arch salvage rule, validated by the
2026-06-05 MacBook->Mac Mini consolidation. Tests: 11/0.

* fix: hermetic git env in session adapters + mcp-inventory lint

- session adapters (codex-worktree, opencode): resolveGitBranch stripped
  no git env, so the "outside a repo" path returned the host branch when
  run inside a git hook (GIT_DIR set). Strip GIT_* before rev-parse.
- mcp-inventory: fix eslint no-unused-vars (signatures) and a stale
  eslint-disable directive in the merged code.

* test: run each test with inherited git env stripped (hermetic runner)

When the suite runs inside a git hook (pre-push), git sets GIT_DIR/
GIT_WORK_TREE, which hijack 'git -C <dir>' calls in tests that exercise
real git, making them operate on the host repo. Strip GIT_* before
spawning each test so the suite is isolated from ambient git state.

---------

Co-authored-by: ECC Test <ecc@example.test>
2026-06-07 13:00:08 +08:00

285 lines
9.2 KiB
JavaScript

'use strict';
const MCP_SCHEMA_VERSION = 'ecc.mcp.v1';
// Env keys whose values are almost always secrets. Used only to flag a server
// as carrying credentials; values are NEVER copied into the canonical record.
const SECRET_KEY_PATTERN = /(token|secret|key|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private)/i;
const REDACTED = '***';
// Known secret value prefixes (provider API keys) plus a high-entropy fallback.
const SECRET_VALUE_PATTERNS = [
/^sk-[A-Za-z0-9_-]{16,}$/i, // OpenAI / Anthropic (sk-ant-...)
/^ghp_[A-Za-z0-9]{16,}$/, // GitHub PAT (classic)
/^github_pat_[A-Za-z0-9_]{16,}$/, // GitHub PAT (fine-grained)
/^gh[oprs]_[A-Za-z0-9]{16,}$/, // other GitHub tokens
/^sm_[A-Za-z0-9_-]{16,}$/, // Supermemory
/^AIza[A-Za-z0-9_-]{16,}$/, // Google API key
/^xox[baprs]-[A-Za-z0-9-]{10,}$/, // Slack
/^(pb|sk|pk|rk)_(live|test)_[A-Za-z0-9]{12,}$/i // Stripe / PostBridge-style
];
// A CLI flag whose following value is a secret (e.g. --modelApiKey sk-...).
const SECRET_FLAG_PATTERN = /(^|[-_])(api[-_]?key|apikey|token|secret|password|passwd|auth|credential|access[-_]?key|private[-_]?key)$/i;
function looksLikeSecretValue(value) {
if (typeof value !== 'string') {
return false;
}
if (SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))) {
return true;
}
// High-entropy fallback: a long opaque token (letters AND digits, no path or
// package separators) is almost certainly a credential, not a flag value.
return value.length >= 32
&& /^[A-Za-z0-9_+/=.-]+$/.test(value)
&& /[A-Za-z]/.test(value)
&& /[0-9]/.test(value)
&& !value.includes('/')
&& !value.includes('@');
}
// Redact secret values from a command arg vector: any token that looks like a
// credential, or any token that immediately follows a secret-named flag. The
// flag names themselves are preserved so the command shape stays legible.
function redactArgs(args) {
const list = Array.isArray(args) ? args : [];
const result = [];
for (let index = 0; index < list.length; index += 1) {
const current = list[index];
if (typeof current !== 'string') {
continue;
}
// Inline form: --flag=secret
const inlineMatch = current.match(/^(--?[A-Za-z0-9_-]+)=(.+)$/);
if (inlineMatch && (SECRET_FLAG_PATTERN.test(inlineMatch[1].replace(/^--?/, '')) || looksLikeSecretValue(inlineMatch[2]))) {
result.push(`${inlineMatch[1]}=${REDACTED}`);
continue;
}
const previous = index > 0 ? list[index - 1] : null;
const followsSecretFlag = typeof previous === 'string'
&& /^--?[A-Za-z0-9_-]+$/.test(previous)
&& SECRET_FLAG_PATTERN.test(previous.replace(/^--?/, ''));
if (followsSecretFlag || looksLikeSecretValue(current)) {
result.push(REDACTED);
continue;
}
result.push(current);
}
return result;
}
// Redact embedded credentials in a server URL (userinfo + token query params).
function redactUrl(url) {
if (typeof url !== 'string' || url.length === 0) {
return url;
}
let safe = url.replace(/\/\/[^/@]+@/, `//${REDACTED}@`);
safe = safe.replace(/([?&](?:token|key|api[_-]?key|access[_-]?token|secret)=)[^&]+/gi, `$1${REDACTED}`);
return safe;
}
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function asNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asStringArray(value) {
if (!Array.isArray(value)) {
return [];
}
return value.filter(item => typeof item === 'string');
}
// Normalize a transport label across harnesses:
// Claude: type "stdio" | "http" | "sse"
// OpenCode: type "local" (stdio) | "remote" (http/sse)
// Codex: no type; presence of url => http, else stdio
function normalizeTransport(rawType, { url } = {}) {
const type = typeof rawType === 'string' ? rawType.toLowerCase() : '';
if (type === 'http' || type === 'streamable-http' || type === 'streamable_http') {
return 'http';
}
if (type === 'sse') {
return 'sse';
}
if (type === 'stdio' || type === 'local') {
return 'stdio';
}
if (type === 'remote') {
return url ? 'http' : 'stdio';
}
return url ? 'http' : 'stdio';
}
// Extract env KEY names only (never values). Flags whether any key looks secret.
function summarizeEnv(env) {
if (!isObject(env)) {
return { envKeys: [], hasSecrets: false };
}
const envKeys = Object.keys(env).sort();
const hasSecrets = envKeys.some(key => SECRET_KEY_PATTERN.test(key));
return { envKeys, hasSecrets };
}
// A stable identity for de-duplication across harnesses. Two server configs
// with the same transport + command + args + url collapse to one logical
// server even if their names differ slightly.
function buildSignature({ transport, command, args, url }) {
if (transport === 'http' || transport === 'sse') {
return `${transport}:${url || ''}`;
}
const argString = asStringArray(args).join(' ');
return `stdio:${[command, argString].filter(Boolean).join(' ')}`.trim();
}
// Normalize a single raw server entry (from any reader) to ecc.mcp.v1 shape.
// rawServer fields the readers already pre-split: name, type, command, args,
// url, env, enabled, source { harness, scope, configPath }.
function normalizeServerEntry(rawServer) {
const name = asNonEmptyString(rawServer.name) || 'unknown';
const command = asNonEmptyString(rawServer.command);
const rawUrl = asNonEmptyString(rawServer.url);
const rawArgs = asStringArray(rawServer.args);
const transport = normalizeTransport(rawServer.type, { url: rawUrl });
const { envKeys, hasSecrets } = summarizeEnv(rawServer.env);
// Secrets can hide in args (e.g. --modelApiKey sk-...) and URLs, not just
// env. Redact before anything is stored or hashed into the signature.
const args = redactArgs(rawArgs);
const url = redactUrl(rawUrl);
const argsCarrySecret = rawArgs.length !== args.length
|| rawArgs.some((value, index) => value !== args[index]);
const urlCarriesSecret = rawUrl !== url;
const source = isObject(rawServer.source) ? rawServer.source : {};
return {
name,
transport,
command: transport === 'stdio' ? command : null,
args: transport === 'stdio' ? args : [],
url: transport === 'stdio' ? null : url,
envKeys,
hasSecrets: hasSecrets || argsCarrySecret || urlCarriesSecret,
enabled: rawServer.enabled === false ? false : true,
signature: buildSignature({ transport, command, args, url }),
sources: [{
harness: asNonEmptyString(source.harness) || 'unknown',
scope: asNonEmptyString(source.scope) || 'user',
configPath: asNonEmptyString(source.configPath) || null
}]
};
}
// Merge many per-harness server records into a deduplicated inventory keyed by
// logical server name. Records that share a name are merged; their sources are
// concatenated and their signatures compared for drift.
function mergeServers(serverRecords) {
const byName = new Map();
for (const record of serverRecords) {
const existing = byName.get(record.name);
if (!existing) {
byName.set(record.name, {
...record,
signatures: [record.signature],
sources: [...record.sources]
});
continue;
}
existing.sources.push(...record.sources);
existing.signatures.push(record.signature);
existing.hasSecrets = existing.hasSecrets || record.hasSecrets;
// Union of env keys observed across harnesses.
existing.envKeys = Array.from(new Set([...existing.envKeys, ...record.envKeys])).sort();
}
return Array.from(byName.values()).map(server => {
const uniqueSignatures = Array.from(new Set(server.signatures));
const { signatures: _signatures, ...rest } = server;
return {
...rest,
harnessCount: server.sources.length,
consistent: uniqueSignatures.length <= 1
};
});
}
function buildFragmentation(mergedServers) {
return mergedServers
.filter(server => server.harnessCount > 1)
.map(server => ({
name: server.name,
harnessCount: server.harnessCount,
harnesses: server.sources.map(source => source.harness),
consistent: server.consistent
}))
.sort((a, b) => b.harnessCount - a.harnessCount || a.name.localeCompare(b.name));
}
function buildInventory(serverRecords) {
const merged = mergeServers(serverRecords).sort((a, b) => a.name.localeCompare(b.name));
const fragmentation = buildFragmentation(merged);
const harnesses = new Set();
let serversWithSecrets = 0;
for (const server of merged) {
server.sources.forEach(source => harnesses.add(source.harness));
if (server.hasSecrets) {
serversWithSecrets += 1;
}
}
return {
schemaVersion: MCP_SCHEMA_VERSION,
servers: merged,
fragmentation,
aggregates: {
serverCount: merged.length,
harnessCount: harnesses.size,
duplicateServerCount: fragmentation.length,
inconsistentServerCount: fragmentation.filter(item => !item.consistent).length,
serversWithSecrets
}
};
}
module.exports = {
MCP_SCHEMA_VERSION,
SECRET_KEY_PATTERN,
REDACTED,
looksLikeSecretValue,
redactArgs,
redactUrl,
normalizeTransport,
summarizeEnv,
buildSignature,
normalizeServerEntry,
mergeServers,
buildFragmentation,
buildInventory
};