diff --git a/plugins/reporting-governance/src/adapters/orchestrator.mjs b/plugins/reporting-governance/src/adapters/orchestrator.mjs index d537b8d..496ea1c 100644 --- a/plugins/reporting-governance/src/adapters/orchestrator.mjs +++ b/plugins/reporting-governance/src/adapters/orchestrator.mjs @@ -6,6 +6,7 @@ import { loadDeploymentProfileArtifact, createDeploymentBindingContract, assertU export function runOrchestratorAdapter({ scriptPath = null, runtimeBinding = null, + deploymentBinding = null, profileArtifact = null, profileArtifactPath = null, profileId = null, @@ -28,26 +29,28 @@ export function runOrchestratorAdapter({ claim = false, dryRun = false, } = {}) { - const deploymentBinding = profileArtifact || profileArtifactPath || profileId - ? createDeploymentBindingContract({ - artifact: profileArtifact ?? loadDeploymentProfileArtifact({ artifactPath: profileArtifactPath, profileId }).artifact, - repoRootOverride, - }) - : null; + const resolvedDeploymentBinding = deploymentBinding ?? ( + profileArtifact || profileArtifactPath || profileId + ? createDeploymentBindingContract({ + artifact: profileArtifact ?? loadDeploymentProfileArtifact({ artifactPath: profileArtifactPath, profileId }).artifact, + repoRootOverride, + }) + : null + ); const binding = runtimeBinding ?? createRuntimeBinding({ cwd: repoRootOverride, - scripts: deploymentBinding?.scripts, + scripts: resolvedDeploymentBinding?.scripts, }); - const resolvedScriptPath = path.resolve(scriptPath ?? deploymentBinding?.entrypoint ?? resolveScriptPath('orchestrator', { runtimeBinding: binding })); + const resolvedScriptPath = path.resolve(scriptPath ?? resolvedDeploymentBinding?.entrypoint ?? resolveScriptPath('orchestrator', { runtimeBinding: binding })); const resolvedWatchdogScript = path.resolve(watchdogScript ?? resolveScriptPath('watchdog', { runtimeBinding: binding })); const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding })); const resolvedSupervisorScript = path.resolve(supervisorScript ?? resolveScriptPath('bridgeSupervisor', { runtimeBinding: binding })); const resolvedQueueDir = queueDir ? path.resolve(queueDir) - : deploymentBinding?.artifactRoots?.queueItems - ? assertUseTimePathWithinRepoRoot(deploymentBinding.artifactRoots.queueItems, 'orchestrator adapter queueDir', { repoRootOverride, allowMissingLeaf: true }) + : resolvedDeploymentBinding?.artifactRoots?.queueItems + ? assertUseTimePathWithinRepoRoot(resolvedDeploymentBinding.artifactRoots.queueItems, 'orchestrator adapter queueDir', { repoRootOverride, allowMissingLeaf: true }) : null; const resolvedSpoolDir = spoolDir ? path.resolve(spoolDir) diff --git a/plugins/reporting-governance/src/core/runtime-integrated.mjs b/plugins/reporting-governance/src/core/runtime-integrated.mjs index e4cbbb7..24c4c04 100644 --- a/plugins/reporting-governance/src/core/runtime-integrated.mjs +++ b/plugins/reporting-governance/src/core/runtime-integrated.mjs @@ -1,6 +1,75 @@ import { executeGovernanceContract } from './execute-governance-contract.mjs'; import { runOrchestratorAdapter } from '../adapters/orchestrator.mjs'; +const ACTION_ADAPTER_ROUTING = Object.freeze({ + notify_operator: { + adapter: 'orchestrator', + reason: 'deployment binding + adapter action routed into orchestrator adapter', + run: ({ governance, repoRootOverride, runtime }) => runOrchestratorAdapter({ + deploymentBinding: governance.deploymentBinding, + repoRootOverride, + ...runtime, + }), + }, +}); + +function resolveRuntimeRoute({ governance, runtime, repoRootOverride }) { + if (!runtime) { + return { + attempted: false, + adapter: null, + action: null, + reason: 'runtime execution not attempted', + runtimeExecution: null, + }; + } + + if (governance.preflight?.status !== 'pass') { + return { + attempted: false, + adapter: null, + action: null, + reason: 'runtime execution not attempted: compatibility preflight did not pass', + runtimeExecution: null, + }; + } + + if (!governance.deploymentBinding) { + return { + attempted: false, + adapter: null, + action: null, + reason: 'runtime execution not attempted: deployment binding is missing', + runtimeExecution: null, + }; + } + + const adapterActions = Array.isArray(governance.contract?.adapter_actions) + ? governance.contract.adapter_actions + : []; + + for (const action of adapterActions) { + const route = ACTION_ADAPTER_ROUTING[action]; + if (!route) continue; + + return { + attempted: true, + adapter: route.adapter, + action, + reason: route.reason, + runtimeExecution: route.run({ governance, runtime, repoRootOverride }), + }; + } + + return { + attempted: false, + adapter: null, + action: null, + reason: 'runtime execution not attempted: no routed adapter action matched runtime integration table', + runtimeExecution: null, + }; +} + export function executeRuntimeIntegratedGovernance({ event, evidence = [], @@ -23,30 +92,25 @@ export function executeRuntimeIntegratedGovernance({ repoRootOverride, }); - const shouldRunOrchestrator = Boolean( - runtime - && governance.preflight?.status === 'pass' - && governance.deploymentBinding - && governance.contract?.adapter_actions?.includes('notify_operator') - ); - - const runtimeExecution = shouldRunOrchestrator - ? runOrchestratorAdapter({ - profileArtifact: profile, - repoRootOverride, - ...runtime, - }) - : null; + const routeResult = resolveRuntimeRoute({ + governance, + runtime, + repoRootOverride, + }); return { ...governance, - runtimeExecution, + runtimeExecution: routeResult.runtimeExecution, runtimeIntegration: { - attempted: shouldRunOrchestrator, - adapter: shouldRunOrchestrator ? 'orchestrator' : null, - reason: shouldRunOrchestrator - ? 'deployment binding + notify_operator adapter action routed into orchestrator adapter' - : 'runtime execution not attempted', + attempted: routeResult.attempted, + adapter: routeResult.adapter, + action: routeResult.action, + reason: routeResult.reason, }, }; } + +export const __testables = { + ACTION_ADAPTER_ROUTING, + resolveRuntimeRoute, +}; diff --git a/plugins/reporting-governance/test/runtime-integrated.integration.test.mjs b/plugins/reporting-governance/test/runtime-integrated.integration.test.mjs index c296e48..81c34ae 100644 --- a/plugins/reporting-governance/test/runtime-integrated.integration.test.mjs +++ b/plugins/reporting-governance/test/runtime-integrated.integration.test.mjs @@ -168,6 +168,7 @@ test('runtime-integrated path wires executeGovernanceContract deployment binding assert.equal(result.contract.decision, 'force_checkpoint'); assert.equal(result.runtimeIntegration.attempted, true); assert.equal(result.runtimeIntegration.adapter, 'orchestrator'); + assert.equal(result.runtimeIntegration.action, 'notify_operator'); assert.equal(result.runtimeExecution.ok, true); assert.equal(result.runtimeExecution.result.dispatcher.dispatchedCount, 1); assert.equal(result.runtimeExecution.result.supervisor.pendingCount, 1); diff --git a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs index cbf92b9..e47b64c 100644 --- a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs +++ b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs @@ -8,6 +8,7 @@ import { runOrchestratorAdapter, runWatchdogChain, } from '../src/index.mjs'; +import { createDeploymentBindingContract } from '../src/storage/profile-artifact.mjs'; const packageRoot = path.resolve(import.meta.dirname, '..'); @@ -176,3 +177,77 @@ test('orchestrator adapter can use artifact_roots.queueItems as the default queu fs.rmSync(root, { recursive: true, force: true }); } }); + +test('orchestrator adapter fails closed at use time when artifact_roots.queueItems drifts through symlink escape', () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-orchestrator-drift-')); + try { + const fakeRepoRoot = path.join(sandbox, 'repo'); + const outsideRoot = path.join(sandbox, 'outside'); + const realRepoRoot = path.resolve(packageRoot, '..', '..'); + fs.mkdirSync(path.join(fakeRepoRoot, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(fakeRepoRoot, 'state', 'operator-notify-queue'), { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + for (const name of [ + 'watchdog_auto_notify_orchestrator.mjs', + 'long_task_watchdog.mjs', + 'operator_notify_dispatcher.mjs', + 'operator_notify_bridge_supervisor.mjs', + 'operator_notify_sender_binding.mjs', + ]) { + fs.copyFileSync(path.join(realRepoRoot, 'scripts', name), path.join(fakeRepoRoot, 'scripts', name)); + } + + const deploymentBinding = createDeploymentBindingContract({ + artifact: { + kind: 'DeploymentProfileArtifact', + apiVersion: 'reporting-governance/v1alpha1', + metadata: { + id: 'drifted-queue-profile', + runtime: 'openclaw', + compatibility_mode: 'strict_envelope', + }, + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + bindings: { + runtime: 'openclaw', + entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + scripts: { + watchdog: 'scripts/long_task_watchdog.mjs', + dispatcher: 'scripts/operator_notify_dispatcher.mjs', + bridgeSupervisor: 'scripts/operator_notify_bridge_supervisor.mjs', + senderBinding: 'scripts/operator_notify_sender_binding.mjs', + orchestrator: 'scripts/watchdog_auto_notify_orchestrator.mjs' + }, + artifact_roots: { + queueItems: 'state/operator-notify-queue' + } + } + } + }, + repoRootOverride: fakeRepoRoot, + }); + + fs.rmSync(path.join(fakeRepoRoot, 'state', 'operator-notify-queue'), { recursive: true, force: true }); + fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'state', 'operator-notify-queue'), 'dir'); + + const statePath = writeState(sandbox); + + assert.throws( + () => runOrchestratorAdapter({ + deploymentBinding, + repoRootOverride: fakeRepoRoot, + state: statePath, + evidenceDir: path.join(sandbox, 'evidence'), + eventDir: path.join(sandbox, 'events'), + spoolDir: path.join(sandbox, 'spool'), + receiptDir: path.join(sandbox, 'receipts'), + writeState: true, + dryRun: true, + now: '2026-05-07T08:20:00.000Z', + }), + /orchestrator adapter queueDir must stay within repo root: symlink resolution escapes realpath boundary/ + ); + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }); + } +});