From 3405074a625408ca52d0f3c6d5ef69fed30c59d1 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:35:59 +0200 Subject: [PATCH 1/6] ci(release): require npm trusted publishing (#505) ## Summary - promote the OIDC-only npm publishing policy from dev to main - release workflow no longer passes NPM_TOKEN - publishing docs now require the safe bootstrap path: one manual interactive 2FA publish, then npm trusted publishing ## Verification already completed - PR #504 CI passed - dev merge queue CI passed - npm publish --dry-run passed locally - GitHub Actions NPM_TOKEN secret was deleted - @the-open-engine/zeroshot@5.4.0 exists on npm with latest pointing at 5.4.0 ## Hold before merge Package creation is complete. Do not merge this PR until npm trusted publishing is configured for: - owner: the-open-engine - repo: zeroshot - workflow filename: release.yml - action: npm publish --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind Co-authored-by: CI Test Co-authored-by: Codex --- .github/workflows/ci.yml | 18 ++++---- .github/workflows/release.yml | 3 -- PUBLISHING.md | 78 +++++++++++++++-------------------- 3 files changed, 45 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df8995f4..a92a5ae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,13 +101,17 @@ jobs: install-matrix: needs: check if: | - github.ref == 'refs/heads/main' || - (github.event_name == 'pull_request' && github.base_ref == 'main') || - (github.event_name == 'merge_group' && ( - github.event.merge_group.base_ref == 'main' || - github.event.merge_group.base_ref == 'refs/heads/main' || - startsWith(github.ref, 'refs/heads/gh-readonly-queue/main/') - )) + always() && + needs.check.result == 'success' && + ( + github.ref == 'refs/heads/main' || + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'merge_group' && ( + github.event.merge_group.base_ref == 'main' || + github.event.merge_group.base_ref == 'refs/heads/main' || + startsWith(github.ref, 'refs/heads/gh-readonly-queue/main/') + )) + ) strategy: fail-fast: false matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 608a1daf..9e2f1ba9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,9 +164,6 @@ jobs: - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Trusted publishing is attempted first; keep the npm automation token - # as fallback for first publish / unconfigured trusted-publisher cases. - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # SAFETY: Manual triggers MUST be dry-run only if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then diff --git a/PUBLISHING.md b/PUBLISHING.md index b60957db..36e79e63 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -7,8 +7,10 @@ This document explains how to set up automated NPM publishing using semantic-rel Before you can publish the package, you need to: 1. **Create the @the-open-engine npm organization** (if it doesn't exist yet) -2. **Configure npm trusted publishing** for this GitHub Actions workflow -3. **Keep an NPM automation token fallback** until trusted publishing has been verified +2. **Create the initial @the-open-engine/zeroshot package** with an interactive 2FA publish if it does not exist yet +3. **Configure npm trusted publishing** for this GitHub Actions workflow + +Do not add an `NPM_TOKEN` publish fallback. This repository is configured to fail closed if OIDC trusted publishing is not available. ## Step 1: Create the @the-open-engine npm Organization @@ -43,7 +45,19 @@ If you get an error or "organization not found", you need to create it: npm org ls @the-open-engine ``` -## Step 2: Configure Trusted Publishing +## Step 2: Create the Initial Package with 2FA + +npm trusted publishing is configured per package. If `@the-open-engine/zeroshot` does not exist yet, npm cannot attach a trusted publisher to it. Create the package once with an interactive maintainer publish: + +```bash +npm login +npm ci +npm publish --access public --otp +``` + +Run this from a clean checkout of the `main` commit that should seed the new package scope. Do not create or store an automation publish token for this step. + +## Step 3: Configure Trusted Publishing The release workflow is configured for npm trusted publishing via GitHub Actions OIDC. Configure the package on npm with: @@ -54,29 +68,6 @@ The release workflow is configured for npm trusted publishing via GitHub Actions The package's `repository.url` in `package.json` must continue to match `git+https://github.com/the-open-engine/zeroshot.git`. -If `@the-open-engine/zeroshot` does not exist yet, use the `NPM_TOKEN` fallback in Step 3 for the first publish, then add the trusted publisher in npm package settings and keep the token only as an emergency fallback. - -## Step 3: Add NPM_TOKEN Fallback - -The semantic-release workflow attempts trusted publishing first and falls back to `NPM_TOKEN` when npm OIDC cannot publish, such as the first publish of a new package or a missing trusted-publisher configuration. - -### Generate the token: - -1. Log in to npmjs.com -2. Open **Access Tokens** -3. Generate an **Automation** token named `GitHub Actions - zeroshot` -4. Copy the token immediately - -### Add the secret: - -1. Go to https://github.com/the-open-engine/zeroshot -2. Navigate to **Settings -> Secrets and variables -> Actions** -3. Click **New repository secret** -4. Set: - - **Name:** `NPM_TOKEN` - - **Value:** the automation token -5. Save the secret - ## Step 4: Verify Package Configuration The package.json is already configured correctly: @@ -110,18 +101,18 @@ npm publish --dry-run This shows what would be published without actually publishing. -### Manual publish fallback: +### Manual first publish: ```bash npm login -npm publish --access public +npm publish --access public --otp ``` -Use manual publish only for emergency recovery. Normal releases should go through GitHub Actions. +Use manual publish only for the initial package creation or emergency recovery. Normal releases should go through GitHub Actions trusted publishing. ## Step 6: How Automated Publishing Works -Once trusted publishing or the `NPM_TOKEN` fallback is configured, publishing happens automatically from `main` after CI passes. +Once trusted publishing is configured, publishing happens automatically from `main` after CI passes. ### Trigger a release: @@ -182,25 +173,25 @@ git commit -m "feat: new API" -m "BREAKING CHANGE: removes old API" ### Error: "npm ERR! 404 Not Found - PUT https://registry.npmjs.org/@the-open-engine%2fzeroshot" -**Cause:** The @the-open-engine organization or package is missing, trusted publishing is not configured for `the-open-engine/zeroshot` + `release.yml`, or the token fallback lacks publish access. +**Cause:** The @the-open-engine organization or package is missing, or trusted publishing is not configured for `the-open-engine/zeroshot` + `release.yml`. -**Fix:** Create the organization, verify `package.json#repository.url`, configure trusted publishing, or update `NPM_TOKEN`. +**Fix:** Create the organization, create the first package version with interactive 2FA if needed, verify `package.json#repository.url`, and configure trusted publishing. ### Error: "npm ERR! 403 Forbidden" -**Cause:** The token fallback does not have permission to publish to @the-open-engine. +**Cause:** Your npm account does not have permission to publish to @the-open-engine, or the trusted publisher is not allowed to publish this package. **Fix:** 1. Verify you're a member of the @the-open-engine npm organization -2. Regenerate the automation token -3. Update the GitHub secret +2. Verify the package trusted publisher is configured for `the-open-engine/zeroshot` + `release.yml` +3. Verify the package is public and `package.json#repository.url` matches the GitHub repository ### Error: "npm ERR! need auth This command requires you to be logged in" -**Cause:** Trusted publishing is not configured and the `NPM_TOKEN` fallback is missing or invalid. +**Cause:** Trusted publishing is not configured or the package cannot be matched to the configured publisher. -**Fix:** Configure trusted publishing or verify the secret exists in GitHub Settings -> Secrets -> Actions. +**Fix:** Configure trusted publishing in npm package settings and verify the workflow filename is `release.yml`. ### No release created @@ -220,7 +211,7 @@ npm login npm version patch # or 'minor' or 'major' # Publish -npm publish --access public +npm publish --access public --otp # Open PRs through the protected dev -> main flow, # then let the release workflow own normal publication again. @@ -229,16 +220,16 @@ npm publish --access public ## Security Best Practices 1. Prefer trusted publishing over long-lived tokens. -2. Keep `NPM_TOKEN` only as a first-publish or emergency fallback. -3. Store tokens only in GitHub Secrets. +2. Do not add an `NPM_TOKEN` publish fallback. +3. Use interactive 2FA for the one-time initial package creation. 4. Enable 2FA on npm maintainer accounts. -5. Revoke or rotate tokens immediately if compromised. +5. Revoke any historical publish tokens that can access this package. ## Next Steps 1. ✅ Create @the-open-engine npm organization (if needed) -2. ✅ Configure trusted publishing for `the-open-engine/zeroshot` + `release.yml` -3. ✅ Add or refresh `NPM_TOKEN` as fallback +2. ✅ Create the initial `@the-open-engine/zeroshot` package with interactive 2FA if needed +3. ✅ Configure trusted publishing for `the-open-engine/zeroshot` + `release.yml` 4. ✅ Make a commit with `feat:` or `fix:` 5. ✅ Merge dev to main through the protected PR flow 6. ✅ Watch GitHub Actions run the release @@ -248,7 +239,6 @@ npm publish --access public - [npm Organizations](https://docs.npmjs.com/organizations) - [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) -- [npm Tokens](https://docs.npmjs.com/about-access-tokens) - [Conventional Commits](https://www.conventionalcommits.org/) - [semantic-release](https://semantic-release.gitbook.io/) - [GitHub Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) From 79862c0dfb23dca5e8c48e6b341faafeab9f120d Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:09:35 +0200 Subject: [PATCH 2/6] fix(validation): model git-pusher repair hook outputs (#512) ## Summary - Promote the current dev snapshot to main for the critical PR-mode validation fix. - The functional release delta is the config-validator hook output contract fix from #511. - Also includes the already-merged doc/license updates currently on dev. ## Expected release - semantic-release should classify this as a patch release from the squash title: 6.0.0 -> 6.0.1. ## Validation - PR #511 CI passed on the branch PR. - Dev merge queue CI passed: https://github.com/the-open-engine/zeroshot/actions/runs/27843629736 - Local validation before #511 merge: - npm test -- tests/unit/pr-mode-cluster-validation.test.js - npm test -- tests/unit/pr-mode-cluster-validation.test.js tests/two-stage-validation.test.js tests/unit/template-validation-pr-mode.test.js tests/unit/template-simulation.test.js - npm test -- tests/unit/pr-mode-cluster-validation.test.js tests/two-stage-validation.test.js tests/unit/template-validation-pr-mode.test.js tests/unit/template-simulation.test.js tests/verify-github-pr-hook.test.js - npm run validate:templates - npm run lint - npm run typecheck - npm test --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind Co-authored-by: CI Test Co-authored-by: Codex --- LICENSE | 2 +- README.md | 2 +- src/config-validator.js | 42 ++++++++- tests/unit/pr-mode-cluster-validation.test.js | 94 ++++++++++++++++--- 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/LICENSE b/LICENSE index e74b31e1..a6c5b458 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Covibes +Copyright (c) 2026 The Open Engine Company Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b829ad3b..f9b258ed 100644 --- a/README.md +++ b/README.md @@ -503,6 +503,6 @@ The TUI is not included in this release. Use `zeroshot logs -f`, `zeroshot logs --- -MIT - [Covibes](https://github.com/the-open-engine) +MIT - [The Open Engine Company](https://github.com/the-open-engine) Built on [Claude Code](https://claude.com/product/claude-code) by Anthropic. diff --git a/src/config-validator.js b/src/config-validator.js index c6211a26..b53ab5d8 100644 --- a/src/config-validator.js +++ b/src/config-validator.js @@ -17,6 +17,13 @@ const { getProvider } = require('./providers'); const { CAPABILITIES } = require('./providers/capabilities'); const { GUIDANCE_TOPICS } = require('./guidance-topics'); +const HOOK_ACTION_TOPIC_CONTRACTS = Object.freeze({ + verify_pull_request: Object.freeze([ + { topic: 'CLUSTER_COMPLETE', keys: null }, + { topic: 'PUSH_BLOCKED', keys: null }, + ]), +}); + /** * Check if config is a conductor-bootstrap style config * Conductor configs dynamically spawn agents via CLUSTER_OPERATIONS @@ -288,6 +295,25 @@ function ensureTopicList(map, topic) { return map.get(topic); } +function getHookActionTopicContracts(hook) { + if (!hook?.action) { + return []; + } + return HOOK_ACTION_TOPIC_CONTRACTS[hook.action] || []; +} + +function recordOutputTopic(agent, topic, topicProducers, agentOutputTopics, producerId = agent.id) { + const producers = ensureTopicList(topicProducers, topic); + if (!producers.includes(producerId)) { + producers.push(producerId); + } + + const outputs = agentOutputTopics.get(agent.id); + if (!outputs.includes(topic)) { + outputs.push(topic); + } +} + function recordAgentTriggers(agent, topicConsumers, agentInputTopics) { for (const trigger of agent.triggers || []) { const topic = trigger.topic; @@ -321,16 +347,19 @@ function extractDynamicTopicsFromScript(script, ctx) { } function recordAgentOutputs(agent, topicProducers, agentOutputTopics) { + const hook = agent.hooks?.onComplete; const outputTopic = agent.hooks?.onComplete?.config?.topic; if (outputTopic) { - ensureTopicList(topicProducers, outputTopic).push(agent.id); - agentOutputTopics.get(agent.id).push(outputTopic); + recordOutputTopic(agent, outputTopic, topicProducers, agentOutputTopics); } const ctx = { outputTopic, agentId: agent.id, topicProducers, agentOutputTopics }; - const hook = agent.hooks?.onComplete; extractDynamicTopicsFromScript(hook?.logic?.script, ctx); extractDynamicTopicsFromScript(hook?.transform?.script, ctx); + + for (const contract of getHookActionTopicContracts(hook)) { + recordOutputTopic(agent, contract.topic, topicProducers, agentOutputTopics); + } } function buildMessageFlowGraph(config) { @@ -683,6 +712,13 @@ function collectTopicContracts(config) { addContract(topic, { agentId: agent.id, keys: null }); } } + + for (const contract of getHookActionTopicContracts(hook)) { + addContract(contract.topic, { + agentId: agent.id, + keys: contract.keys instanceof Set ? new Set(contract.keys) : contract.keys, + }); + } } return contracts; diff --git a/tests/unit/pr-mode-cluster-validation.test.js b/tests/unit/pr-mode-cluster-validation.test.js index d6914558..1700ab2b 100644 --- a/tests/unit/pr-mode-cluster-validation.test.js +++ b/tests/unit/pr-mode-cluster-validation.test.js @@ -1,22 +1,42 @@ const assert = require('node:assert'); const Orchestrator = require('../../src/orchestrator'); +const { generateGitPusherAgent } = require('../../src/agents/git-pusher-template'); const { getConfig } = require('../../src/config-router'); +function createPrCluster() { + const gitPusher = generateGitPusherAgent('github'); + return { + id: 'cluster-pr', + autoPr: true, + agents: [{ id: 'git-pusher', config: gitPusher }], + config: { + agents: [gitPusher], + }, + messageBus: { + publish: () => {}, + }, + }; +} + +function addPrRepairTrigger(agent) { + if (agent.role !== 'implementation') { + return agent; + } + const hasRepairTrigger = agent.triggers?.some((trigger) => trigger.topic === 'PUSH_BLOCKED'); + if (hasRepairTrigger) { + return agent; + } + return { + ...agent, + triggers: [...(agent.triggers || []), { topic: 'PUSH_BLOCKED', action: 'execute_task' }], + }; +} + describe('pr-mode cluster validation', function () { it('accepts trivial task topology in autoPr mode', function () { const orchestrator = new Orchestrator({ quiet: true, skipLoad: true }); - const cluster = { - id: 'cluster-pr', - autoPr: true, - agents: [{ id: 'git-pusher', config: { id: 'git-pusher' } }], - config: { - agents: [{ id: 'git-pusher', role: 'completion-detector' }], - }, - messageBus: { - publish: () => {}, - }, - }; + const cluster = createPrCluster(); const operations = [ { action: 'load_config', @@ -30,4 +50,56 @@ describe('pr-mode cluster validation', function () { orchestrator._validateProposedConfig(cluster.id, cluster, proposed, operations); }); }); + + it('accepts critical staged validation when git-pusher can publish PUSH_BLOCKED', function () { + const orchestrator = new Orchestrator({ quiet: true, skipLoad: true }); + const cluster = createPrCluster(); + const gitPusher = cluster.config.agents[0]; + + const fullWorkflowAgents = orchestrator + ._buildProposedAgentConfigs( + [], + [ + { + action: 'load_config', + config: getConfig('CRITICAL', 'TASK', { autoPr: true }), + }, + ] + ) + .map(addPrRepairTrigger); + + const existingAgents = [gitPusher, ...fullWorkflowAgents]; + const operations = [ + { + action: 'load_config', + config: { + base: 'quick-validation', + params: { validator_level: 'level2', max_tokens: 150000, timeout: 0 }, + }, + }, + { + action: 'publish', + topic: 'IMPLEMENTATION_READY', + content: { + text: 'done', + data: { completionStatus: { canValidate: true } }, + }, + metadata: { _republished: true }, + }, + ]; + const proposed = orchestrator._buildProposedAgentConfigs(existingAgents, operations); + + assert(proposed.some((agent) => agent.id === 'meta-coordinator')); + assert(proposed.some((agent) => agent.id === 'consensus-coordinator')); + assert( + proposed + .find((agent) => agent.id === 'worker') + ?.triggers?.some((trigger) => trigger.topic === 'PUSH_BLOCKED'), + 'worker should keep the PR repair trigger that the runtime injected' + ); + + assert.doesNotThrow(() => { + orchestrator._validateProposedConfig(cluster.id, cluster, proposed, operations); + }); + }); }); From 99c218b163340ee2b730b030ccd3293bf578beac Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:21:34 +0200 Subject: [PATCH 3/6] fix(update): bridge legacy covibes package migration (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Promote dev to main after PR #513 fixed the updater install-prefix bug and legacy @covibes bridge. - The new CLI updater now targets the owning global npm prefix instead of whichever npm is first on PATH. - Adds the @covibes/zeroshot bridge package/workflow so legacy installs show migration guidance and Current version: 6.0.1 Checking for updates... Latest version: 6.0.1 ✓ You are already on the latest version! force-installs @the-open-engine/zeroshot. ## Expected release - semantic-release should classify this as a patch release: 6.0.1 -> 6.0.2. ## Validation - PR #513 CI passed. - Dev merge queue CI passed: https://github.com/the-open-engine/zeroshot/actions/runs/27845636128 - Local validation before #513 merge: - npm test -- tests/update-checker.test.js tests/legacy-covibes-bridge.test.js - npm test -- tests/preflight.test.js - npm run test:coverage - npx prettier --check cli/index.js tests/run-tests.js - npm run lint - npm run typecheck - npm run validate:templates - npm test - npm publish --dry-run --access public in legacy/covibes-zeroshot-bridge - temp-prefix old-to-bridge and bridge-to-new CLI migration proof --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind Co-authored-by: CI Test Co-authored-by: Codex --- .github/workflows/publish-covibes-bridge.yml | 51 +++++ PUBLISHING.md | 27 +++ cli/index.js | 14 +- cli/lib/update-checker.js | 229 +++++++++++++++++-- legacy/covibes-zeroshot-bridge/LICENSE | 21 ++ legacy/covibes-zeroshot-bridge/README.md | 18 ++ legacy/covibes-zeroshot-bridge/cli/index.js | 170 ++++++++++++++ legacy/covibes-zeroshot-bridge/package.json | 35 +++ tests/legacy-covibes-bridge.test.js | 62 +++++ tests/run-tests.js | 2 + tests/update-checker.test.js | 141 ++++++++++++ 11 files changed, 740 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/publish-covibes-bridge.yml create mode 100644 legacy/covibes-zeroshot-bridge/LICENSE create mode 100644 legacy/covibes-zeroshot-bridge/README.md create mode 100644 legacy/covibes-zeroshot-bridge/cli/index.js create mode 100644 legacy/covibes-zeroshot-bridge/package.json create mode 100644 tests/legacy-covibes-bridge.test.js diff --git a/.github/workflows/publish-covibes-bridge.yml b/.github/workflows/publish-covibes-bridge.yml new file mode 100644 index 00000000..5eef3ff5 --- /dev/null +++ b/.github/workflows/publish-covibes-bridge.yml @@ -0,0 +1,51 @@ +name: Publish Covibes Bridge + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Run npm publish in dry-run mode' + required: true + default: true + type: boolean + +permissions: + contents: read + id-token: write + +jobs: + publish-bridge: + runs-on: ubuntu-latest + defaults: + run: + working-directory: legacy/covibes-zeroshot-bridge + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Verify bridge metadata + run: | + node -e " + const pkg = require('./package.json'); + if (pkg.name !== '@covibes/zeroshot') throw new Error('wrong package name'); + if (pkg.version !== '5.4.1') throw new Error('wrong bridge version'); + if (pkg.bin.zeroshot !== 'cli/index.js') throw new Error('missing zeroshot bin'); + console.log(pkg.name + '@' + pkg.version); + " + + - name: Pack bridge + run: npm pack --dry-run + + - name: Dry-run publish bridge + if: inputs.dry_run == true + run: npm publish --dry-run --access public + + - name: Publish bridge + if: inputs.dry_run == false + run: npm publish --provenance --access public diff --git a/PUBLISHING.md b/PUBLISHING.md index 36e79e63..ff631aab 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -169,6 +169,33 @@ git commit -m "feat!: remove deprecated API" git commit -m "feat: new API" -m "BREAKING CHANGE: removes old API" ``` +## Legacy @covibes Bridge + +The old `@covibes/zeroshot` npm package is deprecated in favor of +`@the-open-engine/zeroshot`. + +The bridge package lives in `legacy/covibes-zeroshot-bridge` and publishes as +`@covibes/zeroshot@5.4.1`. Its CLI prints a migration notice on every run, delegates normal +commands to `@the-open-engine/zeroshot`, and makes `zeroshot update` install +`@the-open-engine/zeroshot@latest` with `--force` so the global `zeroshot` bin moves to the new +package. + +Publish the bridge with the manual `Publish Covibes Bridge` workflow +(`.github/workflows/publish-covibes-bridge.yml`). It defaults to dry-run. Before running with +`dry_run=false`, configure npm trusted publishing for: + +- package: `@covibes/zeroshot` +- repository: `the-open-engine/zeroshot` +- workflow filename: `publish-covibes-bridge.yml` + +After the bridge version is published, deprecate the old package versions with an npm maintainer +account: + +```bash +npm deprecate '@covibes/zeroshot@<=5.4.0' \ + 'Zeroshot has moved to @the-open-engine/zeroshot. Run: npm install -g @the-open-engine/zeroshot' +``` + ## Troubleshooting ### Error: "npm ERR! 404 Not Found - PUT https://registry.npmjs.org/@the-open-engine%2fzeroshot" diff --git a/cli/index.js b/cli/index.js index bbb0b6e9..190a1f17 100755 --- a/cli/index.js +++ b/cli/index.js @@ -67,7 +67,7 @@ const { registerDetachedSetupCluster, } = require('../lib/detached-startup'); // Setup wizard removed - use: zeroshot settings set -const { checkForUpdates } = require('./lib/update-checker'); +const { checkForUpdates, printLegacyDistroNotice } = require('./lib/update-checker'); const { StatusFooter, AGENT_STATE, ACTIVE_STATES } = require('../src/status-footer'); // ============================================================================= @@ -5387,13 +5387,15 @@ function printMessage(msg, showClusterId = false, watchMode = false, isActive = // Main async entry point async function main() { - const isQuiet = - process.argv.includes('-q') || - process.argv.includes('--quiet') || - process.env.NODE_ENV === 'test'; + const isTest = process.env.NODE_ENV === 'test'; + const isQuiet = process.argv.includes('-q') || process.argv.includes('--quiet') || isTest; + + printLegacyDistroNotice(); // Check for updates (non-blocking if offline) - await checkForUpdates({ quiet: isQuiet }); + if (!isTest) { + await checkForUpdates({ quiet: isQuiet }); + } let args = process.argv.slice(2); diff --git a/cli/lib/update-checker.js b/cli/lib/update-checker.js index df983389..b4b155a8 100644 --- a/cli/lib/update-checker.js +++ b/cli/lib/update-checker.js @@ -9,10 +9,16 @@ */ const https = require('https'); -const { spawn } = require('child_process'); +const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); const readline = require('readline'); const { loadSettings, saveSettings } = require('../../lib/settings'); +const NEW_PACKAGE_NAME = '@the-open-engine/zeroshot'; +const LEGACY_PACKAGE_NAME = '@covibes/zeroshot'; +const NEW_PACKAGE_SPEC = `${NEW_PACKAGE_NAME}@latest`; + // 24 hours in milliseconds const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -20,15 +26,177 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 5000; // npm registry URL -const REGISTRY_URL = 'https://registry.npmjs.org/@the-open-engine/zeroshot/latest'; +const REGISTRY_URL = `https://registry.npmjs.org/${NEW_PACKAGE_NAME}/latest`; + +function getPackageMetadata() { + return require('../../package.json'); +} + +function getCurrentPackageName() { + return getPackageMetadata().name || NEW_PACKAGE_NAME; +} /** * Get current package version * @returns {string} */ function getCurrentVersion() { - const pkg = require('../../package.json'); - return pkg.version; + return getPackageMetadata().version; +} + +function isLegacyDistro(packageName = getCurrentPackageName()) { + return packageName === LEGACY_PACKAGE_NAME; +} + +function printLegacyDistroNotice(packageName = getCurrentPackageName()) { + if (!isLegacyDistro(packageName)) { + return false; + } + + console.error( + `\n⚠️ ${LEGACY_PACKAGE_NAME} has moved to ${NEW_PACKAGE_NAME}. ` + + 'Run `zeroshot update` to switch this installation.\n' + ); + return true; +} + +function getPackageRoot() { + return path.dirname(require.resolve('../../package.json')); +} + +function hasPathSuffix(parts, suffix) { + if (suffix.length > parts.length) { + return false; + } + + const start = parts.length - suffix.length; + return suffix.every((part, index) => parts[start + index] === part); +} + +function joinPathParts(parts) { + const joined = parts.join(path.sep); + return joined === '' ? path.parse(process.cwd()).root : joined; +} + +function deriveInstallPrefixFromPackageRoot(packageRoot, packageName) { + const parts = path.resolve(packageRoot).split(path.sep); + const packageParts = packageName.split('/'); + + if (!hasPathSuffix(parts, packageParts)) { + return null; + } + + const nodeModulesIndex = parts.length - packageParts.length - 1; + if (nodeModulesIndex < 0 || parts[nodeModulesIndex] !== 'node_modules') { + return null; + } + + if (parts[nodeModulesIndex - 1] === 'lib') { + return joinPathParts(parts.slice(0, nodeModulesIndex - 1)); + } + + return joinPathParts(parts.slice(0, nodeModulesIndex)); +} + +function resolveNpmCommand(installPrefix = null) { + const npmName = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const candidates = []; + + if (installPrefix) { + candidates.push(path.join(installPrefix, 'bin', npmName)); + } + + candidates.push(path.join(path.dirname(process.execPath), npmName)); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return npmName; +} + +function getNpmConfiguredPrefix(npmCommand = resolveNpmCommand()) { + return childProcess + .execFileSync(npmCommand, ['config', 'get', 'prefix'], { + encoding: 'utf8', + }) + .trim(); +} + +function getInstallPrefix(options = {}) { + if (options.installPrefix) { + return options.installPrefix; + } + + const packageName = options.packageName || getCurrentPackageName(); + const packageRoot = options.packageRoot || getPackageRoot(); + const derivedPrefix = deriveInstallPrefixFromPackageRoot(packageRoot, packageName); + + if (derivedPrefix) { + return derivedPrefix; + } + + return getNpmConfiguredPrefix(options.npmCommand); +} + +function getGlobalModulesDir(installPrefix) { + const unixGlobalModulesDir = path.join(installPrefix, 'lib', 'node_modules'); + if (fs.existsSync(unixGlobalModulesDir)) { + return unixGlobalModulesDir; + } + + return path.join(installPrefix, 'node_modules'); +} + +function shellQuote(value) { + if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) { + return value; + } + + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildManualInstallCommand(installPrefix = null, useSudo = false) { + const command = [ + useSudo ? 'sudo' : null, + 'npm', + 'install', + '-g', + installPrefix ? '--prefix' : null, + installPrefix ? shellQuote(installPrefix) : null, + NEW_PACKAGE_SPEC, + ].filter(Boolean); + + return command.join(' '); +} + +function getUpdateTarget(options = {}) { + const packageName = options.packageName || getCurrentPackageName(); + const legacy = isLegacyDistro(packageName); + const installPrefix = getInstallPrefix(options); + const npmCommand = options.npmCommand || resolveNpmCommand(installPrefix); + + return { + packageName, + legacy, + installPrefix, + npmCommand, + globalModulesDir: getGlobalModulesDir(installPrefix), + }; +} + +function buildInstallArgs(updateTarget) { + const args = ['install', '-g', '--prefix', updateTarget.installPrefix]; + + if (updateTarget.legacy) { + // npm refuses to replace the legacy package's `zeroshot` bin without this. + args.push('--force'); + } + + args.push(NEW_PACKAGE_SPEC); + return args; } /** @@ -119,17 +287,10 @@ function promptForUpdate(currentVersion, latestVersion) { * Check if we have write permission to npm global directory * @returns {boolean} True if we can write to npm global prefix */ -function canWriteToNpmGlobal() { - const { execSync } = require('child_process'); - const fs = require('fs'); - +function canWriteToNpmGlobal(options = {}) { try { - // Get npm global prefix (e.g., /usr/lib or /home/user/.nvm/versions/node/...) - const prefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim(); - const globalModulesDir = require('path').join(prefix, 'lib', 'node_modules'); - - // Check if directory exists and is writable - fs.accessSync(globalModulesDir, fs.constants.W_OK); + const updateTarget = getUpdateTarget(options); + fs.accessSync(updateTarget.globalModulesDir, fs.constants.W_OK); return true; } catch { return false; @@ -140,22 +301,32 @@ function canWriteToNpmGlobal() { * Run npm install to update the package * @returns {Promise} True if update succeeded */ -function runUpdate() { +function runUpdate(options = {}) { return new Promise((resolve) => { + let updateTarget; + try { + updateTarget = getUpdateTarget(options); + } catch { + console.log('❌ Update failed. Try manually:'); + console.log(` ${buildManualInstallCommand()}\n`); + resolve(false); + return; + } + // Check permissions BEFORE attempting update - if (!canWriteToNpmGlobal()) { + if (!canWriteToNpmGlobal(options)) { console.log('\n⚠️ Cannot auto-update: no write permission to npm global directory.'); console.log(' Run manually with sudo:'); - console.log(' sudo npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` ${buildManualInstallCommand(updateTarget.installPrefix, true)}\n`); resolve(false); return; } console.log('\n📥 Installing update...'); - const proc = spawn('npm', ['install', '-g', '@the-open-engine/zeroshot@latest'], { + const proc = childProcess.spawn(updateTarget.npmCommand, buildInstallArgs(updateTarget), { stdio: 'inherit', - shell: true, + shell: false, }); proc.on('close', (code) => { @@ -165,14 +336,14 @@ function runUpdate() { resolve(true); } else { console.log('❌ Update failed. Try manually:'); - console.log(' sudo npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` ${buildManualInstallCommand(updateTarget.installPrefix, true)}\n`); resolve(false); } }); proc.on('error', () => { console.log('❌ Update failed. Try manually:'); - console.log(' sudo npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` ${buildManualInstallCommand(updateTarget.installPrefix, true)}\n`); resolve(false); }); }); @@ -246,9 +417,9 @@ async function checkForUpdates(options = {}) { if (options.quiet) { console.log(`📦 Update available: ${currentVersion} → ${latestVersion}`); if (hasWriteAccess) { - console.log(' Run: npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` Run: ${buildManualInstallCommand(getInstallPrefix(), false)}\n`); } else { - console.log(' Run: sudo npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` Run: ${buildManualInstallCommand(getInstallPrefix(), true)}\n`); } return; } @@ -257,7 +428,7 @@ async function checkForUpdates(options = {}) { // (they'd say yes then get an error, which is frustrating UX) if (!hasWriteAccess) { console.log(`\n📦 Update available: ${currentVersion} → ${latestVersion}`); - console.log(' Run: sudo npm install -g @the-open-engine/zeroshot@latest\n'); + console.log(` Run: ${buildManualInstallCommand(getInstallPrefix(), true)}\n`); return; } @@ -271,7 +442,17 @@ async function checkForUpdates(options = {}) { module.exports = { checkForUpdates, // Exported for testing and CLI update command + NEW_PACKAGE_NAME, + LEGACY_PACKAGE_NAME, getCurrentVersion, + getCurrentPackageName, + isLegacyDistro, + printLegacyDistroNotice, + deriveInstallPrefixFromPackageRoot, + getInstallPrefix, + resolveNpmCommand, + getUpdateTarget, + buildInstallArgs, isNewerVersion, fetchLatestVersion, runUpdate, diff --git a/legacy/covibes-zeroshot-bridge/LICENSE b/legacy/covibes-zeroshot-bridge/LICENSE new file mode 100644 index 00000000..a6c5b458 --- /dev/null +++ b/legacy/covibes-zeroshot-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Open Engine Company + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/legacy/covibes-zeroshot-bridge/README.md b/legacy/covibes-zeroshot-bridge/README.md new file mode 100644 index 00000000..d90420f9 --- /dev/null +++ b/legacy/covibes-zeroshot-bridge/README.md @@ -0,0 +1,18 @@ +# @covibes/zeroshot + +This package has moved to `@the-open-engine/zeroshot`. + +Install the maintained package directly: + +```bash +npm install -g @the-open-engine/zeroshot +``` + +If this bridge package is already installed, run: + +```bash +zeroshot update +``` + +The bridge prints a migration notice on every CLI invocation and delegates normal commands to +`@the-open-engine/zeroshot`. diff --git a/legacy/covibes-zeroshot-bridge/cli/index.js b/legacy/covibes-zeroshot-bridge/cli/index.js new file mode 100644 index 00000000..7ed01181 --- /dev/null +++ b/legacy/covibes-zeroshot-bridge/cli/index.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const LEGACY_PACKAGE_NAME = '@covibes/zeroshot'; +const NEW_PACKAGE_NAME = '@the-open-engine/zeroshot'; +const NEW_PACKAGE_SPEC = `${NEW_PACKAGE_NAME}@latest`; + +function printNotice() { + console.error( + `\n⚠️ ${LEGACY_PACKAGE_NAME} has moved to ${NEW_PACKAGE_NAME}. ` + + 'Run `zeroshot update` to switch this installation.\n' + ); +} + +function hasPathSuffix(parts, suffix) { + if (suffix.length > parts.length) { + return false; + } + + const start = parts.length - suffix.length; + return suffix.every((part, index) => parts[start + index] === part); +} + +function joinPathParts(parts) { + const joined = parts.join(path.sep); + return joined === '' ? path.parse(process.cwd()).root : joined; +} + +function deriveInstallPrefixFromPackageRoot(packageRoot, packageName = LEGACY_PACKAGE_NAME) { + const parts = path.resolve(packageRoot).split(path.sep); + const packageParts = packageName.split('/'); + + if (!hasPathSuffix(parts, packageParts)) { + return null; + } + + const nodeModulesIndex = parts.length - packageParts.length - 1; + if (nodeModulesIndex < 0 || parts[nodeModulesIndex] !== 'node_modules') { + return null; + } + + if (parts[nodeModulesIndex - 1] === 'lib') { + return joinPathParts(parts.slice(0, nodeModulesIndex - 1)); + } + + return joinPathParts(parts.slice(0, nodeModulesIndex)); +} + +function getPackageRoot() { + return path.dirname(path.dirname(__filename)); +} + +function resolveNpmCommand(installPrefix) { + const npmName = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const candidates = []; + + if (installPrefix) { + candidates.push(path.join(installPrefix, 'bin', npmName)); + } + + candidates.push(path.join(path.dirname(process.execPath), npmName)); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return npmName; +} + +function getInstallPrefix(packageRoot = getPackageRoot()) { + const derivedPrefix = deriveInstallPrefixFromPackageRoot(packageRoot); + if (derivedPrefix) { + return derivedPrefix; + } + + return childProcess + .execFileSync(resolveNpmCommand(null), ['config', 'get', 'prefix'], { encoding: 'utf8' }) + .trim(); +} + +function buildInstallArgs(installPrefix) { + return ['install', '-g', '--prefix', installPrefix, '--force', NEW_PACKAGE_SPEC]; +} + +function runUpdate(options = {}) { + const installPrefix = options.installPrefix || getInstallPrefix(options.packageRoot); + const npmCommand = options.npmCommand || resolveNpmCommand(installPrefix); + const spawn = options.spawn || childProcess.spawn; + + console.error(`Installing ${NEW_PACKAGE_NAME} into ${installPrefix}...`); + + return new Promise((resolve) => { + const proc = spawn(npmCommand, buildInstallArgs(installPrefix), { + stdio: 'inherit', + shell: false, + }); + + proc.on('close', (code) => { + if (code === 0) { + console.error(`\nDone. ${NEW_PACKAGE_NAME} now owns the global zeroshot command.\n`); + resolve(true); + return; + } + + console.error(`\nUpdate failed. Run manually: npm install -g --force ${NEW_PACKAGE_SPEC}\n`); + resolve(false); + }); + + proc.on('error', () => { + console.error(`\nUpdate failed. Run manually: npm install -g --force ${NEW_PACKAGE_SPEC}\n`); + resolve(false); + }); + }); +} + +function resolveNewCli() { + return require.resolve(`${NEW_PACKAGE_NAME}/cli/index.js`); +} + +function delegateToNewCli(args, options = {}) { + const spawn = options.spawn || childProcess.spawn; + const cliPath = options.cliPath || resolveNewCli(); + + const proc = spawn(process.execPath, [cliPath, ...args], { + stdio: 'inherit', + shell: false, + }); + + proc.on('close', (code) => { + process.exit(code ?? 1); + }); + + proc.on('error', () => { + console.error(`Could not start ${NEW_PACKAGE_NAME}. Run: zeroshot update`); + process.exit(1); + }); +} + +async function main(args = process.argv.slice(2)) { + printNotice(); + + if (args[0] === 'update') { + const success = await runUpdate(); + process.exit(success ? 0 : 1); + return; + } + + delegateToNewCli(args); +} + +if (require.main === module) { + main(); +} + +module.exports = { + LEGACY_PACKAGE_NAME, + NEW_PACKAGE_NAME, + NEW_PACKAGE_SPEC, + printNotice, + deriveInstallPrefixFromPackageRoot, + getInstallPrefix, + buildInstallArgs, + runUpdate, + delegateToNewCli, +}; diff --git a/legacy/covibes-zeroshot-bridge/package.json b/legacy/covibes-zeroshot-bridge/package.json new file mode 100644 index 00000000..e6e0f946 --- /dev/null +++ b/legacy/covibes-zeroshot-bridge/package.json @@ -0,0 +1,35 @@ +{ + "name": "@covibes/zeroshot", + "version": "5.4.1", + "description": "Migration bridge from @covibes/zeroshot to @the-open-engine/zeroshot", + "bin": { + "zeroshot": "cli/index.js" + }, + "scripts": { + "test": "node cli/index.js --version" + }, + "dependencies": { + "@the-open-engine/zeroshot": "^6.0.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "cli/", + "README.md", + "LICENSE" + ], + "license": "MIT", + "homepage": "https://github.com/the-open-engine/zeroshot", + "repository": { + "type": "git", + "url": "git+https://github.com/the-open-engine/zeroshot.git" + }, + "bugs": { + "url": "https://github.com/the-open-engine/zeroshot/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/tests/legacy-covibes-bridge.test.js b/tests/legacy-covibes-bridge.test.js new file mode 100644 index 00000000..a15bf716 --- /dev/null +++ b/tests/legacy-covibes-bridge.test.js @@ -0,0 +1,62 @@ +const assert = require('assert'); +const EventEmitter = require('events'); + +const bridge = require('../legacy/covibes-zeroshot-bridge/cli/index.js'); + +describe('Covibes Zeroshot legacy bridge', function () { + it('derives the legacy package npm prefix from a global package root', function () { + const prefix = bridge.deriveInstallPrefixFromPackageRoot( + '/opt/homebrew/lib/node_modules/@covibes/zeroshot' + ); + + assert.strictEqual(prefix, '/opt/homebrew'); + }); + + it('builds the forced install command that lets the new package take over the bin', function () { + assert.deepStrictEqual(bridge.buildInstallArgs('/tmp/zeroshot-prefix'), [ + 'install', + '-g', + '--prefix', + '/tmp/zeroshot-prefix', + '--force', + '@the-open-engine/zeroshot@latest', + ]); + }); + + it('runs update through npm with the resolved prefix', async function () { + let spawnCommand = null; + let spawnArgs = null; + let spawnOptions = null; + + const spawn = (command, args, options) => { + spawnCommand = command; + spawnArgs = args; + spawnOptions = options; + + const proc = new EventEmitter(); + process.nextTick(() => proc.emit('close', 0)); + return proc; + }; + + const success = await bridge.runUpdate({ + installPrefix: '/tmp/zeroshot-prefix', + npmCommand: '/tmp/npm-for-test', + spawn, + }); + + assert.strictEqual(success, true); + assert.strictEqual(spawnCommand, '/tmp/npm-for-test'); + assert.deepStrictEqual(spawnArgs, [ + 'install', + '-g', + '--prefix', + '/tmp/zeroshot-prefix', + '--force', + '@the-open-engine/zeroshot@latest', + ]); + assert.deepStrictEqual(spawnOptions, { + stdio: 'inherit', + shell: false, + }); + }); +}); diff --git a/tests/run-tests.js b/tests/run-tests.js index 49c5418d..3747fce9 100644 --- a/tests/run-tests.js +++ b/tests/run-tests.js @@ -9,6 +9,8 @@ */ const { spawn } = require('child_process'); +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; + const testFileAliases = new Map([ ['provider-cli-builder', ['tests/provider-cli-builder.test.js']], ['providers', ['tests/providers/**/*.test.js']], diff --git a/tests/update-checker.test.js b/tests/update-checker.test.js index 6278d256..f93d6ad6 100644 --- a/tests/update-checker.test.js +++ b/tests/update-checker.test.js @@ -12,6 +12,8 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const assert = require('assert'); +const EventEmitter = require('events'); +const childProcess = require('child_process'); // Test storage directory (isolated) const TEST_STORAGE_DIR = path.join(os.tmpdir(), 'zeroshot-update-checker-test-' + Date.now()); @@ -144,6 +146,145 @@ describe('Update Checker', function () { }); }); + describe('legacy package migration', function () { + it('detects the legacy covibes package name', function () { + assert.strictEqual(updateChecker.isLegacyDistro('@covibes/zeroshot'), true); + assert.strictEqual(updateChecker.isLegacyDistro('@the-open-engine/zeroshot'), false); + }); + + it('prints the legacy distro notice to stderr', function () { + const originalError = console.error; + const messages = []; + console.error = (message) => messages.push(message); + + try { + const printed = updateChecker.printLegacyDistroNotice('@covibes/zeroshot'); + + assert.strictEqual(printed, true); + assert.ok(messages.join('\n').includes('@covibes/zeroshot has moved')); + assert.ok(messages.join('\n').includes('@the-open-engine/zeroshot')); + } finally { + console.error = originalError; + } + }); + + it('does not print the legacy distro notice for the new package', function () { + const originalError = console.error; + const messages = []; + console.error = (message) => messages.push(message); + + try { + const printed = updateChecker.printLegacyDistroNotice('@the-open-engine/zeroshot'); + + assert.strictEqual(printed, false); + assert.deepStrictEqual(messages, []); + } finally { + console.error = originalError; + } + }); + }); + + describe('install target resolution', function () { + it('derives a Unix npm prefix from a scoped global package root', function () { + const prefix = updateChecker.deriveInstallPrefixFromPackageRoot( + '/opt/homebrew/lib/node_modules/@the-open-engine/zeroshot', + '@the-open-engine/zeroshot' + ); + + assert.strictEqual(prefix, '/opt/homebrew'); + }); + + it('derives a Unix npm prefix from the legacy scoped package root', function () { + const prefix = updateChecker.deriveInstallPrefixFromPackageRoot( + '/Users/tom/.hermes/node/lib/node_modules/@covibes/zeroshot', + '@covibes/zeroshot' + ); + + assert.strictEqual(prefix, '/Users/tom/.hermes/node'); + }); + + it('returns null for a local development checkout', function () { + const prefix = updateChecker.deriveInstallPrefixFromPackageRoot( + '/Users/tom/code/covibes/zeroshot', + '@the-open-engine/zeroshot' + ); + + assert.strictEqual(prefix, null); + }); + + it('uses --force only when migrating the legacy package', function () { + const legacyArgs = updateChecker.buildInstallArgs({ + installPrefix: '/tmp/prefix', + legacy: true, + }); + const newArgs = updateChecker.buildInstallArgs({ + installPrefix: '/tmp/prefix', + legacy: false, + }); + + assert.deepStrictEqual(legacyArgs, [ + 'install', + '-g', + '--prefix', + '/tmp/prefix', + '--force', + '@the-open-engine/zeroshot@latest', + ]); + assert.deepStrictEqual(newArgs, [ + 'install', + '-g', + '--prefix', + '/tmp/prefix', + '@the-open-engine/zeroshot@latest', + ]); + }); + + it('runs npm with the resolved prefix when updating the legacy package', async function () { + const originalSpawn = childProcess.spawn; + const installPrefix = path.join(TEST_STORAGE_DIR, 'legacy-prefix'); + fs.mkdirSync(path.join(installPrefix, 'lib', 'node_modules'), { recursive: true }); + + let spawnCommand = null; + let spawnArgs = null; + let spawnOptions = null; + + childProcess.spawn = (command, args, options) => { + spawnCommand = command; + spawnArgs = args; + spawnOptions = options; + + const proc = new EventEmitter(); + process.nextTick(() => proc.emit('close', 0)); + return proc; + }; + + try { + const success = await updateChecker.runUpdate({ + packageName: '@covibes/zeroshot', + installPrefix, + npmCommand: '/tmp/npm-for-test', + }); + + assert.strictEqual(success, true); + assert.strictEqual(spawnCommand, '/tmp/npm-for-test'); + assert.deepStrictEqual(spawnArgs, [ + 'install', + '-g', + '--prefix', + installPrefix, + '--force', + '@the-open-engine/zeroshot@latest', + ]); + assert.deepStrictEqual(spawnOptions, { + stdio: 'inherit', + shell: false, + }); + } finally { + childProcess.spawn = originalSpawn; + } + }); + }); + describe('fetchLatestVersion()', function () { it('should return null or string (network dependent)', async function () { this.timeout(10000); // Network request may take time From ac2c205d77e8dea14df2695f6d377146cea05a0c Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:25:22 +0200 Subject: [PATCH 4/6] fix(provider-detection): detect CLIs on Windows via where (#517) ## Summary Promote the Windows provider-detection fix from `dev` to `main` for release. ## Included change - #516: detect provider CLIs on Windows using `where` instead of POSIX-only `command -v` - Closes the same bug family tracked by #515 and #456 ## Release impact This should produce the next patch release after `v6.0.2` via semantic-release once the main CI and release workflow complete. ## Verification - #516 `check` passed before merge to `dev` - `main..dev` diff contains only `lib/provider-detection.js` and `tests/provider-detection.test.js` --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind Co-authored-by: CI Test Co-authored-by: Codex Co-authored-by: Atharv Singh <132380045+atharvwasthere@users.noreply.github.com> --- lib/provider-detection.js | 9 ++-- tests/provider-detection.test.js | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/provider-detection.test.js diff --git a/lib/provider-detection.js b/lib/provider-detection.js index 69526496..65573868 100644 --- a/lib/provider-detection.js +++ b/lib/provider-detection.js @@ -7,8 +7,9 @@ function commandExists(command) { if (command.includes(path.sep)) { return fs.existsSync(command); } + const probe = process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; try { - execSync(`command -v ${command}`, { stdio: 'pipe' }); + execSync(probe, { stdio: 'pipe' }); return true; } catch { return false; @@ -20,9 +21,11 @@ function getCommandPath(command) { if (command.includes(path.sep)) { return fs.existsSync(command) ? command : null; } + const probe = process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; try { - const output = execSync(`command -v ${command}`, { encoding: 'utf8', stdio: 'pipe' }); - return output.trim() || null; + const output = execSync(probe, { encoding: 'utf8', stdio: 'pipe' }); + // `where` can return multiple matches (one per line); take the first. + return output.split(/\r?\n/)[0].trim() || null; } catch { return null; } diff --git a/tests/provider-detection.test.js b/tests/provider-detection.test.js new file mode 100644 index 00000000..396468d5 --- /dev/null +++ b/tests/provider-detection.test.js @@ -0,0 +1,86 @@ +/** + * Tests for provider-detection cross-platform CLI lookup. + * + * Regression: on Windows the lookup used the POSIX builtin `command -v`, + * which does not exist under cmd.exe, so every provider was reported as + * "not found". The lookup must use `where` on win32 and `command -v` elsewhere. + */ +const { expect } = require('chai'); +const sinon = require('sinon'); +const childProcess = require('child_process'); + +function withPlatform(value, fn) { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value, configurable: true }); + try { + fn(); + } finally { + Object.defineProperty(process, 'platform', original); + } +} + +describe('provider-detection', () => { + let execSyncStub; + let detection; + + beforeEach(() => { + // Stub before requiring the module so its destructured reference is the stub. + execSyncStub = sinon.stub(childProcess, 'execSync'); + delete require.cache[require.resolve('../lib/provider-detection.js')]; + detection = require('../lib/provider-detection.js'); + }); + + afterEach(() => { + sinon.restore(); + delete require.cache[require.resolve('../lib/provider-detection.js')]; + }); + + describe('commandExists', () => { + it('returns false for empty command without probing', () => { + expect(detection.commandExists('')).to.equal(false); + expect(execSyncStub.called).to.equal(false); + }); + + it('uses `where` on win32', () => { + execSyncStub.returns('C:\\bin\\claude.exe'); + withPlatform('win32', () => { + expect(detection.commandExists('claude')).to.equal(true); + }); + expect(execSyncStub.firstCall.args[0]).to.equal('where claude'); + }); + + it('uses `command -v` on non-win32', () => { + execSyncStub.returns('/usr/bin/claude'); + withPlatform('linux', () => { + expect(detection.commandExists('claude')).to.equal(true); + }); + expect(execSyncStub.firstCall.args[0]).to.equal('command -v claude'); + }); + + it('returns false when the probe throws (command missing)', () => { + execSyncStub.throws(new Error('not found')); + withPlatform('win32', () => { + expect(detection.commandExists('nope')).to.equal(false); + }); + }); + }); + + describe('getCommandPath', () => { + it('returns the first match line on win32 (`where` may return many)', () => { + execSyncStub.returns('C:\\a\\claude.exe\r\nC:\\b\\claude.cmd\r\n'); + let result; + withPlatform('win32', () => { + result = detection.getCommandPath('claude'); + }); + expect(result).to.equal('C:\\a\\claude.exe'); + expect(execSyncStub.firstCall.args[0]).to.equal('where claude'); + }); + + it('returns null when the probe throws', () => { + execSyncStub.throws(new Error('not found')); + withPlatform('linux', () => { + expect(detection.getCommandPath('nope')).to.equal(null); + }); + }); + }); +}); From 8e8d4d83d1c2e6d75420cff2d159f0e80d1351ed Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:32:06 +0200 Subject: [PATCH 5/6] feat(cmdproof): release proof-backed command reuse (#523) ## Summary Promote `dev` to `main` for the cmdproof command-reuse release. ## Included changes - #522: proof-backed command reuse via configurable `zeroshot-command-proofs` JSON blocks and repo settings - #521: isolate trigger sandbox globals ## Release impact This should produce the next minor release after `v6.0.3` via semantic-release once the main merge queue, CI, and release workflow complete. ## Verification - #522 PR CI passed - #522 merge-group CI passed - `main..dev` diff checked before opening --------- Co-authored-by: Eivind Meyer Co-authored-by: Claude Opus 4.5 Co-authored-by: Eivind Meyer Co-authored-by: Michael Eichelbeck <141341133+mkceichelbeck@users.noreply.github.com> Co-authored-by: Michael Eichelbeck Co-authored-by: Ubuntu Co-authored-by: Eivind Co-authored-by: CI Test Co-authored-by: Codex Co-authored-by: Atharv Singh <132380045+atharvwasthere@users.noreply.github.com> --- CHANGELOG.md | 6 + README.md | 46 ++++ cli/commands/cmdproof.js | 268 +++++++++++++++++++ cli/index.js | 20 ++ src/agent/agent-command-proofs-context.js | 55 ++++ src/agent/agent-context-builder.js | 3 + src/agent/agent-quality-gate-schema.js | 9 + src/agent/agent-quality-gates-context.js | 5 + src/agent/agent-task-executor.js | 19 ++ src/command-proofs.js | 169 ++++++++++++ src/logic-engine.js | 33 +-- src/orchestrator.js | 69 ++++- src/quality-gates.js | 5 + tests/command-proofs-cluster.test.js | 132 +++++++++ tests/integration/trigger-evaluation.test.js | 37 +++ tests/required-quality-gates-context.test.js | 32 +++ tests/unit/cmdproof-command.test.js | 147 ++++++++++ tests/unit/command-proofs.test.js | 158 +++++++++++ 18 files changed, 1185 insertions(+), 28 deletions(-) create mode 100644 cli/commands/cmdproof.js create mode 100644 src/agent/agent-command-proofs-context.js create mode 100644 src/command-proofs.js create mode 100644 tests/command-proofs-cluster.test.js create mode 100644 tests/unit/cmdproof-command.test.js create mode 100644 tests/unit/command-proofs.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac46ad2..db47ce9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Bug Fixes + +- **logic-engine:** prevent trigger sandbox evaluation from freezing host prototypes while preserving `return`-based logic scripts; reported in [#496](https://github.com/the-open-engine/zeroshot/pull/496). + # [5.3.0](https://github.com/covibes/zeroshot/compare/v5.2.1...v5.3.0) (2026-01-12) ### Bug Fixes diff --git a/README.md b/README.md index f9b258ed..7d59751d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,52 @@ The `id` and optional `scope` are generic. A repo may bind `repo-quality` to any The pusher fails closed before commit, push, PR creation, or merge when a configured gate is missing, failing, unavailable, stale, older than `IMPLEMENTATION_READY`, or lacks usable evidence. If no `requiredQualityGates` are configured, Zeroshot preserves its existing validator consensus behavior. +## Cmdproof Command Reuse + +Clusters can make expensive exact commands reusable across workers and validators with `cmdproof`. Zeroshot does not intercept arbitrary shell commands; it gives agents a cluster-scoped helper for configured command proofs. + +Issue bodies can opt in per run: + +````markdown +```zeroshot-command-proofs +[ + { + "id": "repo-ci", + "profile": "ci-equivalent", + "scope": "repo", + "description": "Repository local CI-equivalent gate", + "command": "bash ./scripts/ci/run-local-ci-equivalent.sh" + } +] +``` +```` + +Repos can also configure the same commands in `.zeroshot/settings.json`: + +```json +{ + "ship": { + "commandProofs": [ + { + "id": "repo-ci", + "profile": "ci-equivalent", + "scope": "repo", + "description": "Repository local CI-equivalent gate", + "command": "bash ./scripts/ci/run-local-ci-equivalent.sh" + } + ] + } +} +``` + +Agents receive instructions to use: + +```bash +zeroshot cmdproof check repo-ci +``` + +The helper uses a cluster-local cache and trusted-agent key under `~/.zeroshot/cmdproof//`, runs `cmdproof verify` first, and falls back to `cmdproof prove --fallback run` on misses. In `--ship` flows, configured command proofs are also required handoff quality gates. + ## When to Use Zeroshot Zeroshot performs best when tasks have clear acceptance criteria. diff --git a/cli/commands/cmdproof.js b/cli/commands/cmdproof.js new file mode 100644 index 00000000..80df3938 --- /dev/null +++ b/cli/commands/cmdproof.js @@ -0,0 +1,268 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { + normalizeCommandProofs, + resolveConfiguredCommandProofs, +} = require('../../src/command-proofs'); + +function pushCurrentArg(state) { + if (!state.current) { + return; + } + state.args.push(state.current); + state.current = ''; +} + +function consumeEscapedChar(state, char) { + state.current += char; + state.escaped = false; +} + +function consumeQuotedChar(state, char) { + if (char === state.quote) { + state.quote = null; + return; + } + state.current += char; +} + +function consumeUnquotedChar(state, char) { + if (char === '\\') { + state.escaped = true; + return; + } + if (char === '"' || char === "'") { + state.quote = char; + return; + } + if (/\s/.test(char)) { + pushCurrentArg(state); + return; + } + state.current += char; +} + +function consumeCommandChar(state, char) { + if (state.escaped) { + consumeEscapedChar(state, char); + return; + } + if (state.quote) { + consumeQuotedChar(state, char); + return; + } + consumeUnquotedChar(state, char); +} + +function parseCommandToArgv(command) { + if (typeof command !== 'string' || command.trim() === '') { + return []; + } + + const state = { args: [], current: '', quote: null, escaped: false }; + + for (const char of command.trim()) { + consumeCommandChar(state, char); + } + + if (state.escaped) { + state.current += '\\'; + } + if (state.quote) { + throw new Error(`Unterminated quote in command: ${command}`); + } + pushCurrentArg(state); + return state.args; +} + +function buildCmdproofArgs(mode, proof, paths) { + const argv = parseCommandToArgv(proof.command); + if (argv.length === 0) { + throw new Error(`Command proof ${proof.id} has an empty command`); + } + + if (mode === 'prove') { + return [ + 'prove', + '--profile', + proof.profile, + '--cas', + paths.cacheDir, + '--fallback', + 'run', + '--signing-key', + paths.privateKeyPath, + '--', + ...argv, + ]; + } + + if (mode === 'verify') { + return [ + 'verify', + '--profile', + proof.profile, + '--cas', + paths.cacheDir, + '--trusted-key', + paths.publicKeyPath, + '--', + ...argv, + ]; + } + + throw new Error(`Unsupported cmdproof mode: ${mode}`); +} + +function parseEnvProofs(env) { + if (!env.ZEROSHOT_COMMAND_PROOFS) { + return []; + } + try { + return normalizeCommandProofs(JSON.parse(env.ZEROSHOT_COMMAND_PROOFS)); + } catch (error) { + throw new Error(`Invalid ZEROSHOT_COMMAND_PROOFS JSON: ${error.message}`); + } +} + +function resolveProofs(env, cwd) { + const envProofs = parseEnvProofs(env); + if (envProofs.length > 0) { + return envProofs; + } + return resolveConfiguredCommandProofs({}, { cwd }); +} + +function resolveProof(id, env, cwd) { + const proofs = resolveProofs(env, cwd); + const proof = proofs.find((candidate) => candidate.id === id); + if (!proof) { + throw new Error(`Unknown command proof id "${id}"`); + } + return proof; +} + +function resolvePaths(env) { + const clusterId = env.ZEROSHOT_CLUSTER_ID || 'local'; + const root = path.join(os.homedir(), '.zeroshot', 'cmdproof', clusterId); + const cacheDir = env.CMDPROOF_CACHE_DIR || path.join(root, 'cache'); + const keyDir = env.CMDPROOF_KEY_DIR || path.join(root, 'keys'); + return { + cacheDir, + keyDir, + privateKeyPath: path.join(keyDir, 'private-key.json'), + publicKeyPath: path.join(keyDir, 'public-key.json'), + }; +} + +function statusOf(result) { + if (typeof result.status === 'number') { + return result.status; + } + if (result.error) { + return 127; + } + return 1; +} + +function spawnCmdproof(args, options) { + const result = options.spawnSyncFn('cmdproof', args, { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + }); + if (result.error && result.error.code === 'ENOENT') { + throw new Error('cmdproof binary not found on PATH'); + } + return result; +} + +function ensureKeypair(paths, options) { + fs.mkdirSync(paths.cacheDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(paths.keyDir, { recursive: true, mode: 0o700 }); + if (fs.existsSync(paths.privateKeyPath) && fs.existsSync(paths.publicKeyPath)) { + return; + } + + const result = spawnCmdproof( + [ + 'keygen', + '--purpose', + 'trusted-agent', + '--out', + paths.privateKeyPath, + '--public-out', + paths.publicKeyPath, + ], + options + ); + if (statusOf(result) !== 0) { + throw new Error(`cmdproof keygen failed with exit code ${statusOf(result)}`); + } +} + +function writeResult(result, stdout, stderr) { + if (result.stdout) stdout.write(result.stdout); + if (result.stderr) stderr.write(result.stderr); +} + +function parseJsonObject(text) { + try { + return JSON.parse(String(text || '').trim()); + } catch { + return null; + } +} + +function shouldFallbackFromVerify(result) { + const parsed = parseJsonObject(result.stdout); + if (parsed?.status === 'reused_proof') { + return false; + } + return statusOf(result) === 2; +} + +function runMode(mode, proof, paths, options) { + const result = spawnCmdproof(buildCmdproofArgs(mode, proof, paths), options); + writeResult(result, options.stdout, options.stderr); + return statusOf(result); +} + +function runCmdproof({ + mode, + id, + env = process.env, + cwd = process.cwd(), + spawnSyncFn = spawnSync, + stdout = process.stdout, + stderr = process.stderr, +}) { + if (!['prove', 'verify', 'check'].includes(mode)) { + throw new Error(`Unsupported cmdproof mode: ${mode}`); + } + + const proof = resolveProof(id, env, cwd); + const paths = resolvePaths(env); + const options = { cwd, env, spawnSyncFn, stdout, stderr }; + ensureKeypair(paths, options); + + if (mode === 'check') { + const verifyResult = spawnCmdproof(buildCmdproofArgs('verify', proof, paths), options); + writeResult(verifyResult, stdout, stderr); + if (!shouldFallbackFromVerify(verifyResult)) { + return statusOf(verifyResult); + } + return runMode('prove', proof, paths, options); + } + + return runMode(mode, proof, paths, options); +} + +module.exports = { + buildCmdproofArgs, + parseCommandToArgv, + runCmdproof, +}; diff --git a/cli/index.js b/cli/index.js index 190a1f17..f10eec49 100755 --- a/cli/index.js +++ b/cli/index.js @@ -62,6 +62,7 @@ const { const { requirePreflight } = require('../src/preflight'); const { providersCommand, setDefaultCommand, setupCommand } = require('./commands/providers'); const { runInspectCommand } = require('./commands/inspect'); +const { runCmdproof } = require('./commands/cmdproof'); const { markDetachedSetupFailed, registerDetachedSetupCluster, @@ -2540,6 +2541,25 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps) // Task run - single-agent background task const taskCmd = program.command('task').description('Single-agent task management'); +const cmdproofCmd = program + .command('cmdproof') + .description('Run configured cmdproof command proofs'); + +for (const mode of ['prove', 'verify', 'check']) { + cmdproofCmd + .command(`${mode} `) + .description(`${mode} a configured command proof`) + .action((id) => { + try { + const exitCode = runCmdproof({ mode, id }); + process.exit(exitCode); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } + }); +} + taskCmd .command('run ') .description('Run a single-agent background task') diff --git a/src/agent/agent-command-proofs-context.js b/src/agent/agent-command-proofs-context.js new file mode 100644 index 00000000..e1d15a9e --- /dev/null +++ b/src/agent/agent-command-proofs-context.js @@ -0,0 +1,55 @@ +const { normalizeCommandProofs } = require('../command-proofs'); + +function appendProofDetails(lines, proof, index) { + const scope = proof.scope ? `, scope: ${proof.scope}` : ''; + lines.push(`${index + 1}. id: ${proof.id}, profile: ${proof.profile}${scope}`); + if (proof.description) { + lines.push(` description: ${proof.description}`); + } + lines.push(` command: ${proof.command}`); + lines.push(` helper: zeroshot cmdproof check ${proof.id}`); +} + +function buildWorkerInstructions(lines) { + lines.push( + '', + 'For these exact commands:', + '- Run `zeroshot cmdproof check ` instead of the raw command.', + '- Treat the helper exit code as the command exit code.', + '- If you need to mention evidence, include the helper output and the configured command id.', + '' + ); +} + +function buildValidatorInstructions(lines) { + lines.push( + '', + 'For proof-backed validation:', + '- Run `zeroshot cmdproof check ` before considering the raw command.', + '- Use the helper output as quality-gate evidence.', + '- Only run the raw command directly if the helper itself is unavailable.', + '' + ); +} + +function buildCommandProofsSection(config) { + const proofs = normalizeCommandProofs(config.commandProofs); + if (proofs.length === 0) { + return ''; + } + + const lines = ['## Reusable Command Proofs', '', 'Configured proof-backed commands:']; + proofs.forEach((proof, index) => appendProofDetails(lines, proof, index)); + + if (config.role === 'validator') { + buildValidatorInstructions(lines); + } else { + buildWorkerInstructions(lines); + } + + return lines.join('\n'); +} + +module.exports = { + buildCommandProofsSection, +}; diff --git a/src/agent/agent-context-builder.js b/src/agent/agent-context-builder.js index 500d7cd9..e92f948b 100644 --- a/src/agent/agent-context-builder.js +++ b/src/agent/agent-context-builder.js @@ -30,6 +30,7 @@ const { buildValidatorSkipSection, } = require('./agent-context-sections'); const { buildRequiredQualityGatesSection } = require('./agent-quality-gates-context'); +const { buildCommandProofsSection } = require('./agent-command-proofs-context'); function pushStaticPack({ packs, packId, section, text, order, options = {} }) { if (!text) { @@ -82,6 +83,7 @@ function buildStaticSections(params) { header: buildHeaderContext({ id, role, iteration, isIsolated }), instructions: buildInstructionsSection({ config, selectedPrompt, id }), repoTooling: buildRepoToolingSection({ config, worktree }), + commandProofs: buildCommandProofsSection(config), legacyOutputSchema: buildLegacyOutputSchemaSection(config), queuedGuidance: queuedGuidance || '', requiredQualityGates: buildRequiredQualityGatesSection(config), @@ -100,6 +102,7 @@ function buildPacks(params) { 'header', 'instructions', 'repoTooling', + 'commandProofs', 'queuedGuidance', 'legacyOutputSchema', 'requiredQualityGates', diff --git a/src/agent/agent-quality-gate-schema.js b/src/agent/agent-quality-gate-schema.js index 96a46b82..094e70b5 100644 --- a/src/agent/agent-quality-gate-schema.js +++ b/src/agent/agent-quality-gate-schema.js @@ -28,6 +28,15 @@ function buildQualityGateSchema() { command: { type: 'string' }, exitCode: { type: 'integer' }, output: { type: 'string' }, + proof: { + type: 'object', + description: 'Optional command proof metadata when the gate used cmdproof.', + properties: { + profile: { type: 'string' }, + reused: { type: 'boolean' }, + status: { type: 'string' }, + }, + }, }, required: ['command', 'exitCode', 'output'], }, diff --git a/src/agent/agent-quality-gates-context.js b/src/agent/agent-quality-gates-context.js index 9e6ba822..59c74593 100644 --- a/src/agent/agent-quality-gates-context.js +++ b/src/agent/agent-quality-gates-context.js @@ -8,6 +8,10 @@ function appendGateDetails(lines, gate, index) { if (gate.command) { lines.push(` command: ${gate.command}`); } + if (gate.profile || gate.proofProfile) { + lines.push(` cmdproof profile: ${gate.profile || gate.proofProfile}`); + lines.push(` cmdproof helper: zeroshot cmdproof check ${gate.id}`); + } } function buildRequiredQualityGatesSection(config) { @@ -35,6 +39,7 @@ function buildRequiredQualityGatesSection(config) { '', 'For each configured gate:', '- Run the configured command when one is provided by repo or cluster config.', + '- For gates with a cmdproof profile, run `zeroshot cmdproof check ` before considering a raw command.', '- Put the command, numeric exit code, and string output in `evidence`.', '- Set `status` to `PASS` only when the gate completes successfully.', '- If a required gate fails, set `approved` to false and publish status `FAIL`.', diff --git a/src/agent/agent-task-executor.js b/src/agent/agent-task-executor.js index 3391ccab..91f9aee9 100644 --- a/src/agent/agent-task-executor.js +++ b/src/agent/agent-task-executor.js @@ -780,6 +780,25 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) { const { claudeConfigDir = null } = options; const spawnEnv = { ...process.env }; const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd(); + const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID; + + if (clusterId) { + spawnEnv.ZEROSHOT_CLUSTER_ID = clusterId; + const cmdproofRoot = path.join(os.homedir(), '.zeroshot', 'cmdproof', clusterId); + if (!spawnEnv.CMDPROOF_CACHE_DIR) { + spawnEnv.CMDPROOF_CACHE_DIR = path.join(cmdproofRoot, 'cache'); + } + if (!spawnEnv.CMDPROOF_KEY_DIR) { + spawnEnv.CMDPROOF_KEY_DIR = path.join(cmdproofRoot, 'keys'); + } + } + + const commandProofs = Array.isArray(agent.config?.commandProofs) + ? agent.config.commandProofs + : agent.cluster?.commandProofs || []; + if (commandProofs.length > 0) { + spawnEnv.ZEROSHOT_COMMAND_PROOFS = JSON.stringify(commandProofs); + } if (providerName === 'claude') { Object.assign(spawnEnv, buildClaudeEnv(modelSpec)); diff --git a/src/command-proofs.js b/src/command-proofs.js new file mode 100644 index 00000000..66c9eaab --- /dev/null +++ b/src/command-proofs.js @@ -0,0 +1,169 @@ +const { readRepoSettings } = require('../lib/repo-settings'); + +const PROOF_BLOCK_PATTERN = /^```zeroshot-command-proofs\s*\n([\s\S]*?)^```/m; + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value || {}, key); +} + +function trimString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function setOptionalString(target, source, key) { + const value = trimString(source[key]); + if (value) { + target[key] = value; + } +} + +function normalizeCommandProof(proof) { + if (!proof || typeof proof !== 'object') { + return null; + } + + const id = trimString(proof.id || proof.name); + const profile = trimString(proof.profile || proof.proofProfile); + const command = trimString(proof.command); + if (!id || !profile || !command) { + return null; + } + + const normalized = { id, profile, command }; + setOptionalString(normalized, proof, 'scope'); + setOptionalString(normalized, proof, 'description'); + return normalized; +} + +function normalizeCommandProofs(value) { + if (!Array.isArray(value)) { + return []; + } + return value.map(normalizeCommandProof).filter(Boolean); +} + +function parseCommandProofsFromText(text) { + if (typeof text !== 'string' || text.trim() === '') { + return []; + } + + const match = text.match(PROOF_BLOCK_PATTERN); + if (!match) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(match[1]); + } catch (error) { + throw new Error(`Invalid zeroshot-command-proofs JSON: ${error.message}`); + } + + return normalizeCommandProofs(parsed); +} + +function mergeCommandProofs(...sources) { + const byId = new Map(); + const order = []; + + for (const source of sources) { + for (const proof of normalizeCommandProofs(source)) { + if (!byId.has(proof.id)) { + order.push(proof.id); + } + byId.set(proof.id, proof); + } + } + + return order.map((id) => byId.get(id)); +} + +function commandProofToQualityGate(proof) { + const normalized = normalizeCommandProof(proof); + if (!normalized) { + return null; + } + + const gate = { + id: normalized.id, + profile: normalized.profile, + command: normalized.command, + commandProof: true, + }; + setOptionalString(gate, normalized, 'scope'); + setOptionalString(gate, normalized, 'description'); + return gate; +} + +function getCommandProofSource(options, repoSettings) { + if (hasOwn(options, 'commandProofs')) { + return options.commandProofs; + } + + if (options.ship && typeof options.ship === 'object' && hasOwn(options.ship, 'commandProofs')) { + return options.ship.commandProofs; + } + + const settingsShip = repoSettings?.ship; + if (settingsShip && typeof settingsShip === 'object' && hasOwn(settingsShip, 'commandProofs')) { + return settingsShip.commandProofs; + } + + if (hasOwn(repoSettings, 'commandProofs')) { + return repoSettings.commandProofs; + } + + return []; +} + +function getClusterCommandProofSource(config, options) { + if (hasOwn(options, 'commandProofs')) { + return options.commandProofs; + } + + if (options.ship && typeof options.ship === 'object' && hasOwn(options.ship, 'commandProofs')) { + return options.ship.commandProofs; + } + + if (config?.ship && typeof config.ship === 'object' && hasOwn(config.ship, 'commandProofs')) { + return config.ship.commandProofs; + } + + if (hasOwn(config, 'commandProofs')) { + return config.commandProofs; + } + + return undefined; +} + +function resolveConfiguredCommandProofs(config = {}, options = {}) { + const configuredSource = getClusterCommandProofSource(config, options); + if (configuredSource !== undefined) { + return normalizeCommandProofs(configuredSource); + } + + const repoSettingsResult = readRepoSettings(options.cwd || process.cwd()); + const repoSettings = repoSettingsResult.settings || {}; + return normalizeCommandProofs(getCommandProofSource(options, repoSettings)); +} + +function resolveClusterCommandProofs(config = {}, options = {}, inputText = '') { + return mergeCommandProofs( + resolveConfiguredCommandProofs(config, options), + parseCommandProofsFromText(inputText) + ); +} + +function commandProofsToQualityGates(commandProofs) { + return normalizeCommandProofs(commandProofs).map(commandProofToQualityGate).filter(Boolean); +} + +module.exports = { + commandProofToQualityGate, + commandProofsToQualityGates, + mergeCommandProofs, + normalizeCommandProofs, + parseCommandProofsFromText, + resolveClusterCommandProofs, + resolveConfiguredCommandProofs, +}; diff --git a/src/logic-engine.js b/src/logic-engine.js index b9a108b5..d1e639d4 100644 --- a/src/logic-engine.js +++ b/src/logic-engine.js @@ -82,10 +82,6 @@ function buildAgentContext(agent) { }; } -function getSafeBuiltins() { - return { Set, Map, Array, Object, String, Number, Boolean, Math, Date, JSON }; -} - function getQuietConsole() { return { log: () => {}, @@ -114,33 +110,19 @@ class LogicEngine { // Build sandbox context const context = this._buildContext(agent, message); - // Create isolated context with frozen prototypes - // This prevents prototype pollution attacks - const isolatedContext = {}; - - // Freeze Object, Array, Function prototypes in the sandbox - isolatedContext.Object = Object.freeze({ ...Object }); - isolatedContext.Array = Array; - isolatedContext.Function = Function; + // Contextify before execution so scripts use VM-owned globals instead of + // host constructors. Keep the function-body contract: trigger scripts use + // return statements throughout built-in templates and user configs. + const sandbox = { ...context }; + vm.createContext(sandbox); - // Copy safe context properties - Object.assign(isolatedContext, context); - - // Wrap script to prevent prototype access const wrappedScript = `(function() { 'use strict'; - // Prevent prototype pollution - const frozenObject = Object; - const frozenArray = Array; - Object.freeze(frozenObject.prototype); - Object.freeze(frozenArray.prototype); - ${script} })()`; - // Create and run in context - vm.createContext(isolatedContext); - const result = vm.runInContext(wrappedScript, isolatedContext, { + // Run in context + const result = vm.runInContext(wrappedScript, sandbox, { timeout: this.timeout, displayErrors: true, }); @@ -168,7 +150,6 @@ class LogicEngine { ledger: ledgerAPI, cluster: buildClusterAPI(this.cluster, clusterId), helpers: buildHelpers(ledgerAPI), - ...getSafeBuiltins(), console: getQuietConsole(), }; } diff --git a/src/orchestrator.js b/src/orchestrator.js index 9d0c08af..eb1d4a0f 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -49,6 +49,11 @@ const { normalizeProviderName } = require('../lib/provider-names'); const { getProvider } = require('./providers'); const StateSnapshotter = require('./state-snapshotter'); const { resolveClusterRequiredQualityGates } = require('./quality-gates'); +const { + commandProofsToQualityGates, + mergeCommandProofs, + resolveClusterCommandProofs, +} = require('./command-proofs'); const crypto = require('crypto'); function applyModelOverride(agentConfig, modelOverride) { @@ -118,6 +123,46 @@ function applyRequiredQualityGatesToValidators(config, requiredQualityGates) { } } +function applyCommandProofsToAgent(agentConfig, commandProofs) { + if (!Array.isArray(commandProofs) || commandProofs.length === 0) { + return; + } + + agentConfig.commandProofs = mergeCommandProofs(agentConfig.commandProofs, commandProofs); +} + +function applyCommandProofsToAgents(config, commandProofs) { + if (!Array.isArray(commandProofs) || commandProofs.length === 0) { + return; + } + + for (const agentConfig of config.agents || []) { + applyCommandProofsToAgent(agentConfig, commandProofs); + } +} + +function mergeQualityGates(...sources) { + const byId = new Map(); + const order = []; + + for (const source of sources) { + if (!Array.isArray(source)) { + continue; + } + for (const gate of source) { + if (!gate?.id) { + continue; + } + if (!byId.has(gate.id)) { + order.push(gate.id); + } + byId.set(gate.id, gate); + } + } + + return order.map((id) => byId.get(id)); +} + function getTriggerTopic(trigger) { return typeof trigger === 'string' ? trigger : trigger?.topic; } @@ -521,6 +566,7 @@ class Orchestrator { isolation, autoPr: clusterData.autoPr || false, prOptions: clusterData.prOptions || null, + commandProofs: clusterData.commandProofs || [], issue: clusterData.issue || null, }; @@ -801,6 +847,8 @@ class Orchestrator { autoPr: cluster.autoPr || false, // Persist PR options for resume prOptions: cluster.prOptions || null, + // Persist cluster-scoped command proof configuration for resume and dynamic agents + commandProofs: cluster.commandProofs || [], // Persist model override for consistent agent spawning on resume modelOverride: cluster.modelOverride || null, // Persist issue number for heroshot/external tools @@ -1007,9 +1055,9 @@ class Orchestrator { config, clusterId ); - const requiredQualityGates = resolveClusterRequiredQualityGates(config, options); + let requiredQualityGates = resolveClusterRequiredQualityGates(config, options); + let commandProofs = resolveClusterCommandProofs(config, options); options.requiredQualityGates = requiredQualityGates; - applyRequiredQualityGatesToValidators(config, requiredQualityGates); // Build cluster object // CRITICAL: initComplete promise ensures ISSUE_OPENED is published before stop() completes @@ -1034,6 +1082,7 @@ class Orchestrator { _resolveInitComplete: resolveInitComplete, autoPr: options.autoPr || false, requiredQualityGates, + commandProofs, // PR configuration options (persisted for resume) prOptions: buildPrOptions(options, requiredQualityGates), // Model override for all agents (applied to dynamically added agents) @@ -1134,6 +1183,21 @@ class Orchestrator { throw new Error('Either issue, file, or text input is required'); } + commandProofs = mergeCommandProofs( + commandProofs, + resolveClusterCommandProofs(config, options, inputData.context) + ); + requiredQualityGates = mergeQualityGates( + requiredQualityGates, + commandProofsToQualityGates(commandProofs) + ); + options.requiredQualityGates = requiredQualityGates; + cluster.requiredQualityGates = requiredQualityGates; + cluster.commandProofs = commandProofs; + cluster.prOptions = buildPrOptions(options, requiredQualityGates); + applyCommandProofsToAgents(config, commandProofs); + applyRequiredQualityGatesToValidators(config, requiredQualityGates); + // Detect git platform for --pr mode (independent of issue provider) if (options.autoPr) { const { detectGitContext } = require('../lib/git-remote-utils'); @@ -3188,6 +3252,7 @@ Continue from where you left off. Review your previous output to understand what } applyRequiredQualityGatesToAgent(agentConfig, cluster.requiredQualityGates); + applyCommandProofsToAgent(agentConfig, cluster.commandProofs); if (cluster.autoPr) { applyPushBlockedRepairTrigger(agentConfig); } diff --git a/src/quality-gates.js b/src/quality-gates.js index bf237c6d..3d55a020 100644 --- a/src/quality-gates.js +++ b/src/quality-gates.js @@ -45,6 +45,11 @@ function normalizeObjectGate(gate) { setOptionalString(normalized, gate, 'scope'); setOptionalString(normalized, gate, 'description'); setOptionalString(normalized, gate, 'command'); + setOptionalString(normalized, gate, 'profile'); + setOptionalString(normalized, gate, 'proofProfile'); + if (gate.commandProof === true) { + normalized.commandProof = true; + } return normalized; } diff --git a/tests/command-proofs-cluster.test.js b/tests/command-proofs-cluster.test.js new file mode 100644 index 00000000..7abac47e --- /dev/null +++ b/tests/command-proofs-cluster.test.js @@ -0,0 +1,132 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Orchestrator = require('../src/orchestrator'); +const MockTaskRunner = require('./helpers/mock-task-runner'); + +describe('command proofs cluster integration', function () { + let tempDir; + let orchestrator; + let mockRunner; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-command-proofs-')); + mockRunner = new MockTaskRunner(); + orchestrator = new Orchestrator({ + quiet: true, + storageDir: tempDir, + taskRunner: mockRunner, + }); + }); + + afterEach(async () => { + if (orchestrator) { + for (const cluster of orchestrator.listClusters()) { + try { + await orchestrator.kill(cluster.id); + } catch { + // Best effort cleanup. + } + } + orchestrator.close(); + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('uses issue-body command proofs as cluster-scoped required gates and agent instructions', async function () { + const config = { + ship: { + commandProofs: [ + { + id: 'repo-lint', + profile: 'lint-equivalent', + command: 'npm run lint', + }, + ], + }, + agents: [ + { + id: 'worker', + role: 'implementation', + timeout: 0, + triggers: [{ topic: 'ISSUE_OPENED', action: 'execute_task' }], + prompt: 'Implement the requested change.', + hooks: { + onComplete: { + action: 'publish_message', + config: { topic: 'TASK_COMPLETE', content: { text: 'Done' } }, + }, + }, + }, + { + id: 'validator', + role: 'validator', + timeout: 0, + triggers: [{ topic: 'TASK_COMPLETE', action: 'execute_task' }], + prompt: 'Validate the change.', + }, + ], + }; + + const issueText = [ + 'Implement opcore change.', + '', + '```zeroshot-command-proofs', + '[', + ' {', + ' "id": "opcore-ci",', + ' "profile": "ci-equivalent",', + ' "scope": "repo",', + ' "description": "Opcore local CI",', + ' "command": "bash ./scripts/ci/run-local-ci-equivalent.sh"', + ' }', + ']', + '```', + ].join('\n'); + + mockRunner.when('worker').returns(JSON.stringify({ summary: 'Done' })); + mockRunner.when('validator').returns(JSON.stringify({ approved: true })); + + const result = await orchestrator.start(config, { text: issueText }); + const cluster = orchestrator.getCluster(result.id); + + assert.deepStrictEqual(cluster.commandProofs, [ + { id: 'repo-lint', profile: 'lint-equivalent', command: 'npm run lint' }, + { + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ]); + assert.deepStrictEqual(cluster.requiredQualityGates, [ + { + id: 'repo-lint', + profile: 'lint-equivalent', + command: 'npm run lint', + commandProof: true, + }, + { + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + commandProof: true, + }, + ]); + + const workerContext = mockRunner.getCalls('worker')[0].context; + assert.match(workerContext, /Reusable Command Proofs/); + assert.match(workerContext, /zeroshot cmdproof check repo-lint/); + assert.match(workerContext, /zeroshot cmdproof check opcore-ci/); + assert.match(workerContext, /bash \.\/scripts\/ci\/run-local-ci-equivalent\.sh/); + + const validatorConfig = cluster.config.agents.find((agent) => agent.id === 'validator'); + assert.deepStrictEqual(validatorConfig.requiredQualityGates, cluster.requiredQualityGates); + assert.deepStrictEqual(validatorConfig.commandProofs, cluster.commandProofs); + }); +}); diff --git a/tests/integration/trigger-evaluation.test.js b/tests/integration/trigger-evaluation.test.js index 36ecd22c..6543e2e7 100644 --- a/tests/integration/trigger-evaluation.test.js +++ b/tests/integration/trigger-evaluation.test.js @@ -6,6 +6,7 @@ */ const assert = require('node:assert'); +const { spawnSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -417,6 +418,42 @@ function defineScriptValidationTests() { assert.strictEqual(validResult.valid, true); assert.strictEqual(invalidResult.valid, false); }); + + it('should not freeze host prototypes when evaluating scripts', () => { + const childScript = ` + const LogicEngine = require(${JSON.stringify(path.resolve(__dirname, '../../src/logic-engine'))}); + const messageBus = { + query: () => [], + findLast: () => null, + count: () => 0, + since: () => [], + }; + const engine = new LogicEngine(messageBus, { id: 'test-cluster', agents: [] }); + const result = engine.evaluate( + 'return true;', + { id: 'evaluator', cluster_id: 'test-cluster' }, + { topic: 'TRIGGER' } + ); + console.log(JSON.stringify({ + result, + objectPrototypeFrozen: Object.isFrozen(Object.prototype), + arrayPrototypeFrozen: Object.isFrozen(Array.prototype), + })); + `; + + const child = spawnSync(process.execPath, ['-e', childScript], { + encoding: 'utf8', + }); + + assert.strictEqual(child.status, 0, child.stderr || child.stdout); + const output = JSON.parse(child.stdout.trim()); + + assert.deepStrictEqual(output, { + result: true, + objectPrototypeFrozen: false, + arrayPrototypeFrozen: false, + }); + }); }); } diff --git a/tests/required-quality-gates-context.test.js b/tests/required-quality-gates-context.test.js index 1d2d360e..a39ecd53 100644 --- a/tests/required-quality-gates-context.test.js +++ b/tests/required-quality-gates-context.test.js @@ -16,6 +16,31 @@ function baseContextParams(config) { } describe('required handoff quality gate context', function () { + it('adds reusable command proof instructions for implementation agents', function () { + const config = validateAgentConfig({ + id: 'worker', + role: 'implementation', + outputFormat: 'json', + commandProofs: [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ], + prompt: 'Do the work.', + }); + + const context = buildContext(baseContextParams(config)); + + assert.match(context, /Reusable Command Proofs/); + assert.match(context, /id: opcore-ci, profile: ci-equivalent, scope: repo/); + assert.match(context, /bash \.\/scripts\/ci\/run-local-ci-equivalent\.sh/); + assert.match(context, /zeroshot cmdproof check opcore-ci/); + }); + it('adds generic configured gate instructions and schema for validators', function () { const config = validateAgentConfig({ id: 'validator', @@ -34,6 +59,8 @@ describe('required handoff quality gate context', function () { scope: 'workspace', description: 'Run the configured workspace quality gate', command: 'quality-check --scope workspace', + profile: 'ci-equivalent', + commandProof: true, }, ], }); @@ -45,10 +72,15 @@ describe('required handoff quality gate context', function () { config.jsonSchema.properties.qualityGates.items.properties.evidence.required, ['command', 'exitCode', 'output'] ); + assert.ok( + config.jsonSchema.properties.qualityGates.items.properties.evidence.properties.proof, + 'schema should accept optional cmdproof evidence metadata' + ); assert.match(context, /Required Handoff Quality Gates/); assert.match(context, /id: repo-quality, scope: workspace/); assert.match(context, /Run the configured workspace quality gate/); assert.match(context, /quality-check --scope workspace/); + assert.match(context, /zeroshot cmdproof check repo-quality/); assert.match(context, /publish one `qualityGates` entry/); assert.match(context, /approved` to false and publish status `FAIL`/); assert.match(context, /approved` to false and publish status `UNAVAILABLE`/); diff --git a/tests/unit/cmdproof-command.test.js b/tests/unit/cmdproof-command.test.js new file mode 100644 index 00000000..741d4149 --- /dev/null +++ b/tests/unit/cmdproof-command.test.js @@ -0,0 +1,147 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + buildCmdproofArgs, + parseCommandToArgv, + runCmdproof, +} = require('../../cli/commands/cmdproof'); + +describe('zeroshot cmdproof command', function () { + it('parses configured command strings into argv', function () { + assert.deepStrictEqual(parseCommandToArgv('bash ./scripts/ci/run-local-ci-equivalent.sh'), [ + 'bash', + './scripts/ci/run-local-ci-equivalent.sh', + ]); + assert.deepStrictEqual(parseCommandToArgv('npm run "test:unit"'), ['npm', 'run', 'test:unit']); + }); + + it('builds cmdproof prove and verify argv for a proof', function () { + const proof = { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }; + const paths = { + cacheDir: '/tmp/cache', + privateKeyPath: '/tmp/keys/private-key.json', + publicKeyPath: '/tmp/keys/public-key.json', + }; + + assert.deepStrictEqual(buildCmdproofArgs('prove', proof, paths), [ + 'prove', + '--profile', + 'ci-equivalent', + '--cas', + '/tmp/cache', + '--fallback', + 'run', + '--signing-key', + '/tmp/keys/private-key.json', + '--', + 'bash', + './scripts/ci/run-local-ci-equivalent.sh', + ]); + + assert.deepStrictEqual(buildCmdproofArgs('verify', proof, paths), [ + 'verify', + '--profile', + 'ci-equivalent', + '--cas', + '/tmp/cache', + '--trusted-key', + '/tmp/keys/public-key.json', + '--', + 'bash', + './scripts/ci/run-local-ci-equivalent.sh', + ]); + }); + + it('ensures keys and runs prove from environment command proofs', function () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-cmdproof-test-')); + const calls = []; + const env = { + ZEROSHOT_CLUSTER_ID: 'cluster-1', + ZEROSHOT_COMMAND_PROOFS: JSON.stringify([ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ]), + CMDPROOF_CACHE_DIR: path.join(tempDir, 'cache'), + CMDPROOF_KEY_DIR: path.join(tempDir, 'keys'), + }; + + try { + const exitCode = runCmdproof({ + mode: 'prove', + id: 'opcore-ci', + env, + cwd: tempDir, + spawnSyncFn: (command, args) => { + calls.push({ command, args }); + if (args[0] === 'keygen') { + fs.writeFileSync(path.join(tempDir, 'keys', 'private-key.json'), '{}'); + fs.writeFileSync(path.join(tempDir, 'keys', 'public-key.json'), '{}'); + } + return { status: 0, stdout: '', stderr: '' }; + }, + }); + + assert.strictEqual(exitCode, 0); + assert.strictEqual(calls[0].args[0], 'keygen'); + assert.deepStrictEqual(calls[1].args.slice(0, 2), ['prove', '--profile']); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('falls back to prove when check misses verification', function () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-cmdproof-test-')); + const calls = []; + const env = { + ZEROSHOT_COMMAND_PROOFS: JSON.stringify([ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ]), + CMDPROOF_CACHE_DIR: path.join(tempDir, 'cache'), + CMDPROOF_KEY_DIR: path.join(tempDir, 'keys'), + }; + + try { + fs.mkdirSync(env.CMDPROOF_KEY_DIR, { recursive: true }); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'private-key.json'), '{}'); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'public-key.json'), '{}'); + + const exitCode = runCmdproof({ + mode: 'check', + id: 'opcore-ci', + env, + cwd: tempDir, + spawnSyncFn: (command, args) => { + calls.push({ command, args }); + if (args[0] === 'verify') { + return { status: 2, stdout: '{"status":"miss"}\n', stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + stdout: { write: () => {} }, + stderr: { write: () => {} }, + }); + + assert.strictEqual(exitCode, 0); + assert.deepStrictEqual( + calls.map((call) => call.args[0]), + ['verify', 'prove'] + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/command-proofs.test.js b/tests/unit/command-proofs.test.js new file mode 100644 index 00000000..50a09dc1 --- /dev/null +++ b/tests/unit/command-proofs.test.js @@ -0,0 +1,158 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { + commandProofToQualityGate, + mergeCommandProofs, + normalizeCommandProofs, + parseCommandProofsFromText, + resolveConfiguredCommandProofs, +} = require('../../src/command-proofs'); + +describe('command proofs', function () { + it('parses fenced zeroshot-command-proofs JSON from issue text', function () { + const text = [ + '# Task', + '', + '```zeroshot-command-proofs', + '[', + ' {', + ' "id": "opcore-ci",', + ' "profile": "ci-equivalent",', + ' "scope": "repo",', + ' "description": "Opcore local CI",', + ' "command": "bash ./scripts/ci/run-local-ci-equivalent.sh"', + ' }', + ']', + '```', + ].join('\n'); + + assert.deepStrictEqual(parseCommandProofsFromText(text), [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ]); + }); + + it('returns no proofs when the issue text has no proof block', function () { + assert.deepStrictEqual(parseCommandProofsFromText('# Task\n\nNo proof config.'), []); + }); + + it('throws a clear error for malformed proof blocks', function () { + assert.throws( + () => parseCommandProofsFromText('```zeroshot-command-proofs\n{ nope\n```'), + /Invalid zeroshot-command-proofs JSON/ + ); + }); + + it('normalizes only proofs with id, profile, and command', function () { + assert.deepStrictEqual( + normalizeCommandProofs([ + { + id: ' opcore-ci ', + profile: ' ci-equivalent ', + command: ' bash ./scripts/ci/run-local-ci-equivalent.sh ', + }, + { id: 'missing-command', profile: 'ci-equivalent' }, + 'not-an-object', + ]), + [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ] + ); + }); + + it('merges proofs by id with later sources taking precedence', function () { + assert.deepStrictEqual( + mergeCommandProofs( + [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'npm test', + description: 'repo default', + }, + ], + [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + description: 'issue override', + }, + ] + ), + [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + description: 'issue override', + }, + ] + ); + }); + + it('converts proofs into required quality gates', function () { + assert.deepStrictEqual( + commandProofToQualityGate({ + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }), + { + id: 'opcore-ci', + profile: 'ci-equivalent', + scope: 'repo', + description: 'Opcore local CI', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + commandProof: true, + } + ); + }); + + it('resolves command proofs from repo settings', function () { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-command-proofs-')); + try { + execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); + fs.mkdirSync(path.join(repoDir, '.zeroshot'), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, '.zeroshot', 'settings.json'), + JSON.stringify({ + ship: { + commandProofs: [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ], + }, + }) + ); + + assert.deepStrictEqual(resolveConfiguredCommandProofs({}, { cwd: repoDir }), [ + { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }, + ]); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); From 3077e372746e3897b3e89dd99dabab20ed7dceb7 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:18:25 +0200 Subject: [PATCH 6/6] feat(cmdproof): serialize proof fallback by action key (#528) ## Summary - release the cmdproof action-key single-flight fix on top of current `main` - prevents concurrent validators from launching duplicate raw fallback CI for the same cmdproof action key - keeps fallback behavior unchanged when verify output has no action key ## Why this PR uses `tomdps:dev` The normal upstream `dev -> main` PR (#525) is conflicted because `dev` lost the main ancestry edge when the merge queue squashed the no-file sync PR. The repo guard allows `main` PRs only from a branch named `dev`; this fork branch is named `dev` and is based directly on current `main`, so the release diff is clean and limited to the intended two files. ## Validation - #524 feature PR CI passed - #524 merge-group CI passed into upstream dev - This PR should run the required main CI/install matrix on the clean main-based patch --- cli/commands/cmdproof.js | 186 +++++++++++++++++++++++++++- tests/unit/cmdproof-command.test.js | 127 +++++++++++++++++++ 2 files changed, 307 insertions(+), 6 deletions(-) diff --git a/cli/commands/cmdproof.js b/cli/commands/cmdproof.js index 80df3938..5a2ff511 100644 --- a/cli/commands/cmdproof.js +++ b/cli/commands/cmdproof.js @@ -1,6 +1,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const crypto = require('crypto'); const { spawnSync } = require('child_process'); const { @@ -153,6 +154,7 @@ function resolvePaths(env) { return { cacheDir, keyDir, + lockDir: env.ZEROSHOT_CMDPROOF_LOCK_DIR || path.join(root, 'locks'), privateKeyPath: path.join(keyDir, 'private-key.json'), publicKeyPath: path.join(keyDir, 'public-key.json'), }; @@ -180,6 +182,114 @@ function spawnCmdproof(args, options) { return result; } +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value || ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function proofLockName(proof, verifyReport = null) { + const actionKey = getVerifyActionKey(verifyReport); + const digest = crypto + .createHash('sha256') + .update(`${proof.id}\0${proof.profile}\0${proof.command}\0${actionKey}`) + .digest('hex') + .slice(0, 16); + const safeId = proof.id.replace(/[^a-zA-Z0-9._-]/g, '_'); + return `${safeId}-${digest}.lock`; +} + +function getVerifyActionKey(verifyReport) { + return verifyReport?.actionKey || verifyReport?.action_key || ''; +} + +function proofLockPath(proof, paths, verifyReport = null) { + return path.join(paths.lockDir, proofLockName(proof, verifyReport)); +} + +function removeLock(lockPath) { + fs.rmSync(lockPath, { recursive: true, force: true }); +} + +function lockOwnerState(lockPath) { + try { + const metadata = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')); + if (!Number.isInteger(metadata.pid) || metadata.pid <= 0) { + return 'unknown'; + } + try { + process.kill(metadata.pid, 0); + return 'alive'; + } catch (error) { + return error?.code === 'ESRCH' ? 'gone' : 'unknown'; + } + } catch { + return 'unknown'; + } +} + +function lockIsStale(lockPath, staleMs) { + try { + const stat = fs.statSync(lockPath); + const ownerState = lockOwnerState(lockPath); + if (ownerState === 'alive') { + return false; + } + if (ownerState === 'gone') { + return true; + } + return Date.now() - stat.mtimeMs > staleMs; + } catch { + return false; + } +} + +function writeLockMetadata(lockPath, proof) { + const metadata = { + pid: process.pid, + proofId: proof.id, + profile: proof.profile, + command: proof.command, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(path.join(lockPath, 'owner.json'), `${JSON.stringify(metadata, null, 2)}\n`, { + mode: 0o600, + }); +} + +function tryAcquireProofLock(proof, paths, env, verifyReport = null) { + const lockPath = proofLockPath(proof, paths, verifyReport); + const staleMs = parsePositiveInt(env.ZEROSHOT_CMDPROOF_LOCK_STALE_MS, 6 * 60 * 60 * 1000); + fs.mkdirSync(paths.lockDir, { recursive: true, mode: 0o700 }); + + try { + fs.mkdirSync(lockPath, { mode: 0o700 }); + writeLockMetadata(lockPath, proof); + return { acquired: true, lockPath }; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + if (!lockIsStale(lockPath, staleMs)) { + return { acquired: false, lockPath }; + } + removeLock(lockPath); + try { + fs.mkdirSync(lockPath, { mode: 0o700 }); + writeLockMetadata(lockPath, proof); + return { acquired: true, lockPath }; + } catch (retryError) { + if (retryError?.code === 'EEXIST') { + return { acquired: false, lockPath }; + } + throw retryError; + } + } +} + function ensureKeypair(paths, options) { fs.mkdirSync(paths.cacheDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(paths.keyDir, { recursive: true, mode: 0o700 }); @@ -231,6 +341,74 @@ function runMode(mode, proof, paths, options) { return statusOf(result); } +function runProveWithLock(proof, paths, options, lockPath) { + try { + return runMode('prove', proof, paths, options); + } finally { + removeLock(lockPath); + } +} + +function verifyProof(proof, paths, options, { write = true } = {}) { + const result = spawnCmdproof(buildCmdproofArgs('verify', proof, paths), options); + if (write) { + writeResult(result, options.stdout, options.stderr); + } + return result; +} + +function waitForProofOrProve(proof, paths, options, verifyReport) { + const startedAt = Date.now(); + const waitMs = parsePositiveInt(options.env.ZEROSHOT_CMDPROOF_WAIT_MS, 30 * 60 * 1000); + const pollMs = parsePositiveInt(options.env.ZEROSHOT_CMDPROOF_POLL_MS, 2500); + + while (Date.now() - startedAt < waitMs) { + sleepMs(pollMs); + const verifyResult = verifyProof(proof, paths, options, { write: false }); + const latestReport = parseJsonObject(verifyResult.stdout) || verifyReport; + if (!shouldFallbackFromVerify(verifyResult)) { + writeResult(verifyResult, options.stdout, options.stderr); + return statusOf(verifyResult); + } + + const lockReport = getVerifyActionKey(latestReport) ? latestReport : verifyReport; + const lock = tryAcquireProofLock(proof, paths, options.env, lockReport); + if (lock.acquired) { + options.stderr.write( + `zeroshot cmdproof check ${proof.id}: no reusable proof appeared; acquired proof lock after wait.\n` + ); + return runProveWithLock(proof, paths, options, lock.lockPath); + } + } + + options.stderr.write( + `zeroshot cmdproof check ${proof.id}: timed out waiting ${waitMs}ms for in-flight proof; leaving miss for caller.\n` + ); + const finalVerify = verifyProof(proof, paths, options, { write: true }); + return statusOf(finalVerify); +} + +function runCheck(proof, paths, options) { + const verifyResult = verifyProof(proof, paths, options); + if (!shouldFallbackFromVerify(verifyResult)) { + return statusOf(verifyResult); + } + const verifyReport = parseJsonObject(verifyResult.stdout); + if (!getVerifyActionKey(verifyReport)) { + return runMode('prove', proof, paths, options); + } + + const lock = tryAcquireProofLock(proof, paths, options.env, verifyReport); + if (lock.acquired) { + return runProveWithLock(proof, paths, options, lock.lockPath); + } + + options.stderr.write( + `zeroshot cmdproof check ${proof.id}: proof miss; waiting for in-flight proof from another agent.\n` + ); + return waitForProofOrProve(proof, paths, options, verifyReport); +} + function runCmdproof({ mode, id, @@ -250,12 +428,7 @@ function runCmdproof({ ensureKeypair(paths, options); if (mode === 'check') { - const verifyResult = spawnCmdproof(buildCmdproofArgs('verify', proof, paths), options); - writeResult(verifyResult, stdout, stderr); - if (!shouldFallbackFromVerify(verifyResult)) { - return statusOf(verifyResult); - } - return runMode('prove', proof, paths, options); + return runCheck(proof, paths, options); } return runMode(mode, proof, paths, options); @@ -265,4 +438,5 @@ module.exports = { buildCmdproofArgs, parseCommandToArgv, runCmdproof, + proofLockName, }; diff --git a/tests/unit/cmdproof-command.test.js b/tests/unit/cmdproof-command.test.js index 741d4149..505bf1e2 100644 --- a/tests/unit/cmdproof-command.test.js +++ b/tests/unit/cmdproof-command.test.js @@ -6,6 +6,7 @@ const path = require('path'); const { buildCmdproofArgs, parseCommandToArgv, + proofLockName, runCmdproof, } = require('../../cli/commands/cmdproof'); @@ -112,6 +113,7 @@ describe('zeroshot cmdproof command', function () { ]), CMDPROOF_CACHE_DIR: path.join(tempDir, 'cache'), CMDPROOF_KEY_DIR: path.join(tempDir, 'keys'), + ZEROSHOT_CMDPROOF_LOCK_DIR: path.join(tempDir, 'locks'), }; try { @@ -140,6 +142,131 @@ describe('zeroshot cmdproof command', function () { calls.map((call) => call.args[0]), ['verify', 'prove'] ); + assert.strictEqual(fs.existsSync(env.ZEROSHOT_CMDPROOF_LOCK_DIR), false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('waits for an in-flight proof and reuses it instead of proving concurrently', function () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-cmdproof-test-')); + const proof = { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }; + const env = { + ZEROSHOT_COMMAND_PROOFS: JSON.stringify([proof]), + CMDPROOF_CACHE_DIR: path.join(tempDir, 'cache'), + CMDPROOF_KEY_DIR: path.join(tempDir, 'keys'), + ZEROSHOT_CMDPROOF_LOCK_DIR: path.join(tempDir, 'locks'), + ZEROSHOT_CMDPROOF_WAIT_MS: '50', + ZEROSHOT_CMDPROOF_POLL_MS: '1', + }; + const calls = []; + const stdout = []; + const stderr = []; + + try { + fs.mkdirSync(env.CMDPROOF_KEY_DIR, { recursive: true }); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'private-key.json'), '{}'); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'public-key.json'), '{}'); + const lockPath = path.join( + env.ZEROSHOT_CMDPROOF_LOCK_DIR, + proofLockName(proof, { actionKey: 'action-a' }) + ); + fs.mkdirSync(lockPath, { recursive: true }); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ pid: process.pid })); + + const exitCode = runCmdproof({ + mode: 'check', + id: 'opcore-ci', + env, + cwd: tempDir, + spawnSyncFn: (command, args) => { + calls.push({ command, args }); + if ( + args[0] === 'verify' && + calls.filter((call) => call.args[0] === 'verify').length === 1 + ) { + return { status: 2, stdout: '{"status":"miss","actionKey":"action-a"}\n', stderr: '' }; + } + if (args[0] === 'verify') { + return { status: 0, stdout: '{"status":"reused_proof","reused":true}\n', stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + stdout: { write: (chunk) => stdout.push(chunk) }, + stderr: { write: (chunk) => stderr.push(chunk) }, + }); + + assert.strictEqual(exitCode, 0); + assert.deepStrictEqual( + calls.map((call) => call.args[0]), + ['verify', 'verify'] + ); + assert.match(stdout.join(''), /"status":"miss"/); + assert.match(stdout.join(''), /"status":"reused_proof"/); + assert.match(stderr.join(''), /waiting for in-flight proof/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('takes over proving when an in-flight proof lock is stale', function () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-cmdproof-test-')); + const proof = { + id: 'opcore-ci', + profile: 'ci-equivalent', + command: 'bash ./scripts/ci/run-local-ci-equivalent.sh', + }; + const env = { + ZEROSHOT_COMMAND_PROOFS: JSON.stringify([proof]), + CMDPROOF_CACHE_DIR: path.join(tempDir, 'cache'), + CMDPROOF_KEY_DIR: path.join(tempDir, 'keys'), + ZEROSHOT_CMDPROOF_LOCK_DIR: path.join(tempDir, 'locks'), + ZEROSHOT_CMDPROOF_LOCK_STALE_MS: '1', + }; + const calls = []; + + try { + fs.mkdirSync(env.CMDPROOF_KEY_DIR, { recursive: true }); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'private-key.json'), '{}'); + fs.writeFileSync(path.join(env.CMDPROOF_KEY_DIR, 'public-key.json'), '{}'); + const lockPath = path.join( + env.ZEROSHOT_CMDPROOF_LOCK_DIR, + proofLockName(proof, { actionKey: 'action-stale' }) + ); + fs.mkdirSync(lockPath, { recursive: true }); + const old = new Date(Date.now() - 10_000); + fs.utimesSync(lockPath, old, old); + + const exitCode = runCmdproof({ + mode: 'check', + id: 'opcore-ci', + env, + cwd: tempDir, + spawnSyncFn: (command, args) => { + calls.push({ command, args }); + if (args[0] === 'verify') { + return { + status: 2, + stdout: '{"status":"miss","actionKey":"action-stale"}\n', + stderr: '', + }; + } + return { status: 0, stdout: '{"status":"passed"}\n', stderr: '' }; + }, + stdout: { write: () => {} }, + stderr: { write: () => {} }, + }); + + assert.strictEqual(exitCode, 0); + assert.deepStrictEqual( + calls.map((call) => call.args[0]), + ['verify', 'prove'] + ); + assert.strictEqual(fs.existsSync(lockPath), false); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }