diff --git a/.changeset/script-free-install.md b/.changeset/script-free-install.md new file mode 100644 index 0000000..00ee147 --- /dev/null +++ b/.changeset/script-free-install.md @@ -0,0 +1,6 @@ +--- +'@bloxwap/agent-cli': patch +--- + +Ship license, changelog and repository metadata with the CLI. Verify script-free +global npm installs and installed bunx execution on macOS/Linux and Node 22.12/24. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0a34f3d..d8bc5f7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,6 +5,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: dedicated-testnet-account + cancel-in-progress: false jobs: testnet: runs-on: ubuntu-latest @@ -12,6 +15,8 @@ jobs: environment: testnet env: BLOXWAP_E2E: '1' + # This wallet and Privy app belong to disposable company CI, never users. + BLOXWAP_TEST_ACCOUNT_ONLY: '1' BLOXWAP_TEST_MASTER_KEY: ${{ secrets.BLOXWAP_TEST_MASTER_KEY }} BLOXWAP_PRIVY_APP_ID: ${{ secrets.BLOXWAP_PRIVY_APP_ID }} BLOXWAP_PRIVY_APP_SECRET: ${{ secrets.BLOXWAP_PRIVY_APP_SECRET }} @@ -23,9 +28,34 @@ jobs: cache: npm - run: npm ci - run: npm run build - - run: npm run test:e2e + - name: Verify the live testnet lifecycle (Relay mocked) + run: npm run test:e2e + - name: Record testnet coverage and outcome + if: always() + run: | + node --input-type=module <<'NODE' + import { readFileSync, appendFileSync, existsSync } from 'node:fs'; + const path = 'packages/agent-cli/artifacts/lifecycle-report.json'; + if (!existsSync(path)) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, 'No lifecycle report was produced. Live coverage is unproven.\n'); + process.exit(1); + } + const report = JSON.parse(readFileSync(path)); + const stages = report.stages.filter(stage => stage.status !== 'running'); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, + `Testnet lifecycle: **${report.status}**. Cleanup: ${report.cleanup}.\n\n` + + 'Hyperliquid testnet and dedicated Privy app are live; Relay is a zero-value mock using pre-funded test collateral. This script is not an AI-agent session.\n\n' + + stages.map(stage => `- ${stage.name}: ${stage.status}\n`).join('')); + NODE - uses: actions/upload-artifact@v4 if: always() with: - name: testnet-journals - path: packages/agent-cli/artifacts/ + name: testnet-journals-${{ github.run_id }} + retention-days: 7 + if-no-files-found: error + path: | + packages/agent-cli/artifacts/lifecycle-report.json + packages/agent-cli/artifacts/events.jsonl + packages/agent-cli/artifacts/events-follow.jsonl + packages/agent-cli/artifacts/audit.jsonl + packages/agent-cli/artifacts/runner*.jsonl diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index b719a37..164f5b4 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -8,6 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] + node: ['22.12.0', '24'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -16,8 +17,14 @@ jobs: node-version: 24 cache: npm - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.10' - run: npm ci - run: npm run build - - run: npm run test:package + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - name: Install the packed artifact with lifecycle scripts disabled + run: npm run test:package env: BLOXWAP_SMOKE_BUN: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dafbd6f..497e2f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,39 +4,131 @@ on: tags: ['v*'] workflow_dispatch: permissions: - contents: write - id-token: write + contents: read +concurrency: + group: agent-cli-release-${{ github.ref }} + cancel-in-progress: false jobs: - release: - if: vars.BLOXWAP_PUBLISH == '1' - runs-on: ubuntu-latest - environment: npm-release + build: + name: Candidate (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + target: bun-linux-x64-baseline + package: true + - os: ubuntu-24.04-arm + platform: linux-arm64 + target: bun-linux-arm64 + - os: macos-15-intel + platform: darwin-x64 + target: bun-darwin-x64 + - os: macos-latest + platform: darwin-arm64 + target: bun-darwin-arm64 + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 24 - cache: npm - registry-url: https://registry.npmjs.org - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.10' + - name: Verify version, skill, release notes and tagged main ancestry + run: node packages/agent-cli/scripts/verify-release.mjs - run: npm ci - - name: Verify tag and package version - if: startsWith(github.ref, 'refs/tags/') - run: node -e 'const p=require("./packages/agent-cli/package.json");if(process.env.GITHUB_REF_NAME!==`v${p.version}`)throw Error("Tag/version mismatch")' - run: npm run build - run: npm run lint - run: npm run typecheck - run: npm test - - run: npm run test:package - - run: npm run build:binaries - - run: npm publish --workspace @bloxwap/agent-cli --provenance --access public + - name: Verify installed npm and bunx package + run: npm run test:package + env: + BLOXWAP_SMOKE_BUN: '1' + - name: Compile and run the native executable + run: | + npm run build:binaries -- ${{ matrix.target }} + node packages/agent-cli/scripts/binary-smoke.mjs packages/agent-cli/binaries/agent-cli-${{ matrix.platform }} + - name: Ensure generated tracked files match the release commit + run: git diff --exit-code HEAD + - uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.platform }} + path: packages/agent-cli/binaries/agent-cli-${{ matrix.platform }} + if-no-files-found: error + - name: Pack the tested npm release artifact + if: matrix.package + run: | + mkdir -p packages/agent-cli/artifacts + npm pack --workspace @bloxwap/agent-cli --ignore-scripts --pack-destination packages/agent-cli/artifacts + cp packages/agent-cli/SKILL.md packages/agent-cli/CHANGELOG.md packages/agent-cli/LICENSE packages/agent-cli/artifacts/ + - name: Verify the exact release tarball + if: matrix.package + run: | + for release_tarball in "$PWD"/packages/agent-cli/artifacts/*.tgz; do + BLOXWAP_SMOKE_TARBALL="$release_tarball" npm run test:package + done + env: + BLOXWAP_SMOKE_BUN: '1' + - uses: actions/upload-artifact@v4 + if: matrix.package + with: + name: npm-package + path: packages/agent-cli/artifacts/* + if-no-files-found: error + publish: + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && vars.BLOXWAP_PUBLISH == '1' + runs-on: ubuntu-latest + environment: npm-release + concurrency: + group: agent-cli-npm-publish + cancel-in-progress: false + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.10' + - name: Recheck tagged main ancestry before publishing + run: node packages/agent-cli/scripts/verify-release.mjs + - uses: actions/download-artifact@v4 + with: + pattern: native-* + merge-multiple: true + path: release + - uses: actions/download-artifact@v4 + with: + name: npm-package + path: release + - name: Prepare release checksums + run: node packages/agent-cli/scripts/release-checksums.mjs release + - name: Publish the tested tarball with provenance + run: node packages/agent-cli/scripts/publish-artifact.mjs release env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Verify published integrity and provenance + run: node packages/agent-cli/scripts/verify-publication.mjs release + - name: Verify public npm install and bunx + run: npm run test:package + env: + BLOXWAP_SMOKE_REGISTRY: '1' + BLOXWAP_SMOKE_BUN: '1' - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') with: generate_release_notes: true - files: | - packages/agent-cli/binaries/* - packages/agent-cli/SKILL.md - packages/agent-cli/CHANGELOG.md + fail_on_unmatched_files: true + files: release/* diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index ebb0109..8f3e503 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -1,11 +1,20 @@ name: Version packages on: workflow_dispatch: + inputs: + create_pr: + type: boolean + default: false + description: Open a Changesets PR (requires organization permission) permissions: contents: write pull-requests: write +concurrency: + group: agent-cli-version-main + cancel-in-progress: false jobs: version: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -14,8 +23,22 @@ jobs: node-version: 24 cache: npm - run: npm ci + - name: Prepare and validate a version patch + if: ${{ !inputs.create_pr }} + run: | + npm run version:candidate + git diff --binary HEAD > version.patch + - uses: actions/upload-artifact@v4 + if: ${{ !inputs.create_pr }} + with: + name: agent-cli-version-patch + path: version.patch + if-no-files-found: error - uses: changesets/action@v1 + if: inputs.create_pr with: - version: npm run version:packages + version: npm run version:candidate + title: 'chore: version agent-cli for release' + commit: 'chore: version agent-cli for release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 195be47..296dd4e 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,18 @@ or current Bun. No postinstall script. Publishing is release-gated. ## Testnet quickstart +Create your own [Privy account and app](https://dashboard.privy.io/) first. +Use your app ID and secret on your machine or infrastructure you control. +Bloxwap does not provide a shared Privy app, receive these credentials, or operate +a hosted signing service. Keep the master wallet separate from the Privy agent. + ```sh npm ci npm run build node packages/agent-cli/dist/cli.js --help node packages/agent-cli/dist/cli.js config set privyAppId YOUR_APP_ID node packages/agent-cli/dist/cli.js config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET -# Set BLOXWAP_PRIVY_APP_SECRET securely outside the CLI/conversation. +# Set your own app's BLOXWAP_PRIVY_APP_SECRET locally, outside the conversation. node packages/agent-cli/dist/cli.js init --network testnet --master-address 0xYOUR_MASTER_ADDRESS # Sign returned EIP-712 approval externally, then submit its 65-byte signature: node packages/agent-cli/dist/cli.js init --network testnet --signature 0xYOUR_SIGNATURE @@ -40,17 +45,21 @@ with addresses/signatures from your test environment. Never paste a private key. ```mermaid flowchart LR Agent[Agent / CLI] -->|private JSON-RPC| Runner[Persistent runner] - Runner -->|P-256 authorization| Privy[Privy managed agent] + Runner -->|P-256 authorization| Privy[Agent in user's Privy app] Privy -->|EIP-712 signature| HL[Hyperliquid exchange] Agent --> Relay[Relay funding quote] Master[External master wallet] -->|one-time approvals| HL Runner --> Journal[Events / audit / logs] ``` -Master custody stays external. Privy owns the separate agent key; the CLI holds -only an authorization key. Hyperliquid agent permissions deny withdrawal. -Privy policies restrict signing domains; local checks enforce configured risk -limits. Read [custody limits](docs/custody.md) before using a funded account. +You own the Privy account/app. Privy manages the separate agent wallet key in +that app; its P-256 authorization key stays on your machine or infrastructure. +Your master key stays in your external wallet. Bloxwap never receives or stores +these keys, your Privy app secret, or your authorization key on Bloxwap servers. +The CLI talks directly to Privy, Hyperliquid, and Relay. Hyperliquid agent +permissions prevent withdrawals from the master account. Privy policies restrict +the visible signing envelope; local checks enforce configured risk limits. +Read [custody limits](docs/custody.md) before using a funded account. State: `~/.bloxwap`; config: `~/.bloxwap/config.json`; IPC: `~/.bloxwap/run.sock`; env prefix: `BLOXWAP_`. All local state is private to the OS @@ -62,7 +71,10 @@ Override via `config set builder.address` / `config set builder.fee` or ## Development and docs `npm run build`, `npm run lint`, `npm run typecheck`, `npm test` must pass before -committing. Unit tests mock services. Credential-dependent tests are gated: `npm run test:sandbox` and `npm run test:e2e` skip without the required environment. The master-key testnet harness is CI-only; unit tests never access the network. +committing. Unit tests mock services. `npm run test:sandbox` requires an explicit +opt-in and credentials for your own test app; missing prerequisites fail before +any request. `npm run test:e2e` is separately credential-gated. The master-key +testnet harness is CI-only; unit tests never access the network. The npm workspace keeps the publishable CLI in `packages/agent-cli` and the Fumadocs app in `apps/docs`. Use Node.js **24+** for repository development. diff --git a/apps/docs/content/docs/custody.mdx b/apps/docs/content/docs/custody.mdx index fb6fef8..707004f 100644 --- a/apps/docs/content/docs/custody.mdx +++ b/apps/docs/content/docs/custody.mdx @@ -1,19 +1,57 @@ --- title: "Custody" -description: "Master-wallet and managed-agent trust boundaries." +description: "User-owned Privy apps, local credentials and wallet trust boundaries." --- -The master wallet approves a separate Hyperliquid API agent. Its key stays in Privy; -the CLI holds a P-256 authorization key, never the master private key. Hyperliquid -agent permissions prevent withdrawals and master-only user-signed actions. +Each user creates and controls their own [Privy account/app](https://dashboard.privy.io/). +Set that app's ID and a local environment reference to its secret. There is no +shared Bloxwap Privy account, credential proxy, or hosted Bloxwap signer. +Do not send Privy credentials or wallet keys to Bloxwap, an AI conversation, +repository, issue, or support message. -The versioned Privy policy permits only EIP-712 Agent messages in the Hyperliquid -L1 domain (chain 1337, zero verifying contract, correct network source). Unmatched -RPC methods are denied by default. User-signed withdrawal and approval messages -have a different domain/type. Local dispatch allows only trading operations. +The master wallet stays external and approves a separate Hyperliquid API agent. +Privy manages the agent wallet key in the user's app; the CLI generates its +P-256 owner authorization key locally. Only the public authorization key is +registered with Privy. The private authorization key remains in a private local +file or user-controlled environment reference. Bloxwap never receives or stores +the master key, agent key, app secret, or authorization key on its infrastructure. +Run the CLI/runner on your machine or infrastructure you control. The CLI calls +Privy directly with your app credentials. Privy's authorization model is described +in its [wallet controls documentation](https://docs.privy.io/security/wallet-infrastructure/policy-and-controls). + +`init` creates the versioned policy in `packages/agent-cli/policies/agent-trade-only.json`, +stores its ID and CLI policy version, and attaches it when creating or linking +the agent wallet. Re-running `init` verifies the remote policy rules against the +configured network, replaces outdated/different rules, and confirms attachment. +Updating an already owned wallet uses your existing local owner authorization +key; the app secret alone is not treated as permission to replace its owner. +An owned wallet cannot be linked without that key. Never link the master wallet +as the agent. `status` reports the configured policy ID/version; it does not fetch +and attest the remote policy on every read. + +The Privy policy allows only typed-data requests matching chain 1337, the zero +verifying contract and the Agent schema's network source (`a` mainnet, `b` testnet). +It explicitly denies agent private-key/seed export; unmatched methods are denied +by [Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). +The local signer additionally requires the exact `Exchange`/`1` domain, Agent +fields and a 32-byte connection ID. User-signed withdrawals, transfers and agent +approvals have a different type/domain and are refused locally. The CLI dispatch +allowlist refuses withdrawal/transfer/approval actions before signing. Hyperliquid +API-agent permissions supply the master-account withdrawal boundary. Privy sees the L1 action hash, not its preimage: it cannot independently distinguish an order from another L1 action or enforce notional limits inside that hash. Local limits and exchange agent permissions supply those checks. Do not describe -this policy as independent server-side per-action/notional enforcement. Confirm -policy behavior in the credential-gated sandbox before production use. +this policy as independent server-side per-action/notional enforcement. Passing +the local Agent-envelope check also does not prove that a hash contains an order. +Privy's [Hyperliquid guide](https://docs.privy.io/recipes/hyperliquid/policies-and-offline-actions) +distinguishes user-signed actions from L1 agent actions. + +`npm run test:sandbox` needs `BLOXWAP_PRIVY_SANDBOX=1`, `BLOXWAP_PRIVY_APP_ID` +and `BLOXWAP_PRIVY_APP_SECRET` from your dedicated test app. It creates an empty +wallet and policy in that app, signs a valid testnet Agent envelope, then directly +requests a withdrawal-type signature and expects `POLICY_REJECTED`. It sends no +transaction to Hyperliquid and needs no master key or funds. The wallet and policy +remain visible in your Privy app for inspection; never reuse that test wallet for +funds. Without those credentials the explicit sandbox command fails its preflight. +Unit tests alone are not evidence of a live Privy rejection. diff --git a/apps/docs/content/docs/security.mdx b/apps/docs/content/docs/security.mdx index 621a628..0371c8e 100644 --- a/apps/docs/content/docs/security.mdx +++ b/apps/docs/content/docs/security.mdx @@ -6,12 +6,20 @@ description: "Private local state, secret handling, and custody limitations." State is always `~/.bloxwap`, directory 0700, config/auth keys/logs/events/audit 0600. P-256 Privy authorization keys are PKCS8 base64 in private files or environment references. The CLI does not import master keys, seeds or mnemonic phrases. +Every user owns their Privy account/app and runs the CLI/runner locally or on +infrastructure they control. App IDs and wallet IDs identify resources in that +user's app. App secrets and P-256 private authorization keys remain in the user's +environment or private local files; Bloxwap never receives or stores them. There +is no Bloxwap credential service, shared app secret, or signing proxy. The agent +wallet key is managed by Privy in the user's app, separate from the external +master wallet. The CLI never exports either wallet's key. External master-wallet signatures are accepted only as 65-byte signatures. Every CLI and RPC input is scanned before schema parsing so unknown fields cannot smuggle key material. Hex 32-byte secrets and BIP39-like phrases are refused. -Trust boundaries: master wallet → one-time Hyperliquid agent approval → Privy -managed wallet → restricted local signing runner. Read [Custody](/docs/custody) for the hash +Trust boundaries: external master wallet → one-time Hyperliquid agent approval → +agent wallet in the user's Privy app → user-controlled local signing runner. +Read [Custody](/docs/custody) for the hash preimage limitation of server policies. Anyone controlling this OS user can use the authorization key and change limits. Socket/file modes exclude other users, not same-user malware or root. Windows named pipes require current-user ACLs; diff --git a/apps/docs/content/docs/skill.mdx b/apps/docs/content/docs/skill.mdx index 6f7daf0..e2e5f90 100644 --- a/apps/docs/content/docs/skill.mdx +++ b/apps/docs/content/docs/skill.mdx @@ -24,9 +24,13 @@ Use `--network testnet` during testing; never silently change to mainnet. ## Onboarding sequence -1. Configure Privy app ID and secret reference, e.g. `config set privyAppId APP` - and `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets - the environment secret outside the conversation. The default Bloxwap builder is +1. The user creates and owns their own Privy account and app. Configure that + app ID and a local secret reference, e.g. `config set privyAppId APP` and + `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the + environment secret outside the conversation on their own machine or runner. + Bloxwap does not host the app, receive credentials, or custody user keys. + Privy manages the trading-agent key in the user's app; master-wallet approval + always remains in the user's external wallet. The default Bloxwap builder is `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%). Override with `config set builder.address` / `config set builder.fee` or `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`. @@ -54,6 +58,25 @@ Use `--network testnet` during testing; never silently change to mainnet. plan, an error follows the catalog below, and expiry warnings prompt rotation using external master-wallet approval. +## Completion and approval boundaries + +Read this skill and query schemas before acting. Keep a progress record with the +network, current stage, deposit IDs, last event sequence and order cloid/oid; +record public IDs only. A missing user-owned Privy app, wallet signature, funding +route or settled balance is a blocked stage. Report the exact next action without +requesting any secret in chat or moving to the next stage. Repeat `init` to resume +persisted approvals rather than creating another agent. An `ok:true` transport +envelope does not imply exchange acceptance: inspect `data.result` and reconcile +`orders`/`events` after uncertain mutations. Never retry an order with a new cloid +until the original intent has been reconciled. + +For rotation, verify `swapped:true` before asking the external wallet to sign old +agent revocation. Require `oldRevoked:true` before claiming rotation complete. +`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms +that the signed exchange revocation was submitted. Inspect `status` to verify the +agent is absent from exchange approvals. Cancel outstanding orders before final +revocation, and report any cancellation or cleanup failure explicitly. + ## Commands and example envelopes Examples show representative `data`, not live balances or service promises. @@ -70,7 +93,7 @@ Full inputs/outputs are discoverable with `schema`; amounts/prices are strings. | `fund --amount 10 --no-qr` | `{"ok":true,"data":{"depositId":"...","depositAddress":"0x...","expectedReceived":"100000000","builderFeeRate":"0.01%","builderAddress":"0x71b09A08257078A4d642F7Dd7315e656c837329a","state":"awaiting_funds","qrAscii":null}}` | | `deposit --deposit-id ID` | `{"ok":true,"data":{"depositId":"ID","steps":[{"id":"step:0","requiresSignature":true,"payload":{}}]}}` | | `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{"ok":true,"data":{"depositId":"ID","steps":[]}}` | -| `status` | `{"ok":true,"data":{"balance":{"accountValue":"100"},"agent":{"approved":true,"daysRemaining":"179"},"deposits":[],"policy":{"id":"...","version":"1"},"runner":{"pid":"123"}}}` | +| `status` | `{"ok":true,"data":{"balance":{"accountValue":"100"},"agent":{"approved":true,"daysRemaining":"179"},"deposits":[],"policy":{"id":"...","version":"2"},"runner":{"pid":"123"}}}` | | `balance` | `{"ok":true,"data":{"accountValue":"100","accountValueUnits":"10000000000","withdrawable":"96","marginUsed":"4"}}` | | `market --coin BTC` | `{"ok":true,"data":{"coin":"BTC","asset":"0","szDecimals":"5","maxLeverage":"40","mid":"20000"}}` | | `positions` | `{"ok":true,"data":[{"coin":"BTC","size":"0.001","entryPrice":"20000","leverage":"5","marginMode":"cross"}]}` | @@ -129,3 +152,17 @@ trade-only; local limits include markets, size/notional, leverage, daily loss an open orders. Privy sees an L1 action hash, so it cannot independently inspect notional/action contents; Hyperliquid agent permissions and local checks enforce those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`. + + +## Verification boundaries + +Offline skill tests validate the documented command examples against CLI schemas. +Mocked lifecycle tests exercise resumable approvals, pending funding, events +and failure handling. These checks do **not** prove a live agent or +exchange run. The nightly testnet lifecycle uses only a company-owned disposable +CI master account and separate test Privy app, real Hyperliquid testnet and Privy, +and an explicitly mocked Relay route backed by already funded test collateral. +It never transfers real funds. Missing CI credentials fail the job; a skipped or +mocked test must never be reported as live validation. Record the workflow run, +commit, lifecycle report, event and audit artifacts before claiming a testnet pass. +See `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup. diff --git a/apps/docs/package.json b/apps/docs/package.json index dcf18cf..e26156e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,7 +7,7 @@ "dev": "next dev --port 3903", "build": "next build", "postbuild": "node scripts/check-export.mjs", - "check": "node scripts/check-content.mjs --check && node scripts/hig-tokens.mjs --check && next typegen && tsc --noEmit", + "check": "node --test scripts/hig-css-guard.test.mjs && node scripts/check-content.mjs --check && node scripts/hig-tokens.mjs --check && next typegen && tsc --noEmit", "preview": "node scripts/preview.mjs", "postinstall": "fumadocs-mdx", "predev": "npm --workspace @bloxwap/agent-cli run build && node scripts/generate-content.mjs && node scripts/prepare-public.mjs", diff --git a/apps/docs/scripts/hig-css-guard.mjs b/apps/docs/scripts/hig-css-guard.mjs new file mode 100644 index 0000000..286588a --- /dev/null +++ b/apps/docs/scripts/hig-css-guard.mjs @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; + +export function assertNoCopiedHigTokens(css) { + // A declaration can follow another declaration or an opening brace on the same line. + const copied = css.match(/(?:^|[;{\s])(--docs-(?:radius|duration|ease|control|type)[\w-]*\s*:)/m); + assert.ok(!copied, `app/global.css must use --hig-radius/motion/type/density roles, not copy ${copied?.[1]}`); +} diff --git a/apps/docs/scripts/hig-css-guard.test.mjs b/apps/docs/scripts/hig-css-guard.test.mjs new file mode 100644 index 0000000..96b117e --- /dev/null +++ b/apps/docs/scripts/hig-css-guard.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assertNoCopiedHigTokens } from './hig-css-guard.mjs'; + +test('rejects copied HIG declarations in formatted and compact CSS', () => { + for (const css of [ + ':root {\n --docs-radius: 14px;\n}', + ':root { --docs-icon: 16px; --docs-radius: 14px; }', + ':root{--docs-duration-fast:120ms;--docs-icon:16px}', + ':root{--docs-icon:16px;--docs-ease-standard:ease}', + ':root{--docs-control-height:32px}', + '.compact{--docs-type-body-size:14px}', + ]) { + assert.throws(() => assertNoCopiedHigTokens(css), /must use --hig-radius\/motion\/type\/density roles/); + } +}); + +test('allows host-specific declarations and HIG role references', () => { + assert.doesNotThrow(() => assertNoCopiedHigTokens(` + :root { --docs-icon: 16px; --docs-gap: 1rem; } + .card { border-radius: var(--hig-radius-xl); font-size: var(--hig-type-body-size); } + .icon { width: var(--docs-icon); } + `)); +}); diff --git a/apps/docs/scripts/hig-tokens.mjs b/apps/docs/scripts/hig-tokens.mjs index 0a6211e..0ed7d87 100644 --- a/apps/docs/scripts/hig-tokens.mjs +++ b/apps/docs/scripts/hig-tokens.mjs @@ -5,6 +5,7 @@ import { readFile, stat } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import assert from 'node:assert/strict'; import { tokens } from '@bloxwap/hig-tokens'; +import { assertNoCopiedHigTokens } from './hig-css-guard.mjs'; const root = new URL('../../../', import.meta.url); const docs = new URL('../', import.meta.url); @@ -35,8 +36,7 @@ assert.equal(tokens.version, provenance.version.split('-')[0]); const css = await readFile(new URL('app/global.css', docs), 'utf8'); // Radius, motion, type, density and font families are CSS roles in @bloxwap/hig-tokens/css; a host copy would drift. -const copied = css.match(/^\s*--docs-(?:radius|duration|ease|control|type)[\w-]*\s*:/m); -assert.ok(!copied, `app/global.css must use --hig-radius/motion/type/density roles, not copy ${copied?.[0].trim()}`); +assertNoCopiedHigTokens(css); assert.ok(!/var\(--font-bloxwap-(?:sans|mono)\)/.test(css), 'app/global.css must read fonts through --hig-font-sans/--hig-font-mono'); assert.ok(!/\.hig-(?:button|number)[^{]*\{[^}]*font-family/.test(css), 'HIG controls resolve the font roles themselves; no font-family bridge'); const roles = ['--hig-radius-xl', '--hig-motion-duration-fast', '--hig-type-body-size', '--hig-density-compact-control', '--hig-font-sans']; diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index bb68cef..1d81a1f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -2,25 +2,29 @@ This records implementation evidence, not a claim that credential-dependent services or hosted workflows ran. See PROGRESS.md for the remaining partial -items. The final local run uses the SDK-supported Node 22 runtime and Bun. +items. Local checks use Node 24 and Bun; install CI also covers the supported +Node 22.12 minimum. | Issues | Reviewed artifacts and meaningful validation | | --- | --- | -| #1 | package scripts/bin, strict ESM tsconfig, CI workflow; build/lint/typecheck/Vitest smoke pass. Hosted CI awaits a push, which was explicitly excluded. | +| #1 | package scripts/bin, strict ESM tsconfig, CI workflow; build/lint/typecheck/Vitest smoke pass. | | #2 | docs/adr/0001-runner-interface.md compares all three transports, method names, private permissions and Windows strategy. | | #3–#4 | Stable append-only error catalog and serialization/exit tests; strict flags/JSON/stdin parser, input/output schemas, typo and uncaught-error tests. Streaming exceptions are explicit. | | #5–#6 | Config validation/migration/atomic private writes/corrupt-file tests; recursive field/env/PEM redaction, request correlation and size/day rotation. | | #7–#9 | Requested SDK, network factories, shared metadata cache/refresh, exact rounding fixtures, service mapping fixtures, bounded retries and exclusive persisted nonce tests. | -| #10–#13 | P-256 managed-wallet creation/linking and typed-data adapter; versioned Privy domain/source policy and local forbidden-action tests; resumable external master approvals with persisted per-master nonces, expiry bounds and builder approval verification. Bloxwap builder defaults to f=10 (0.01%); hash-preimage policy limitations are explicit in PROGRESS/custody docs. Sandbox calls are separately gated. | +| #10–#13 | User-owned Privy app, local P-256 owner authorization, recoverable wallet creation/linking and typed-data adapter; versioned domain/source/no-export policy, authorized attachment and init revalidation; local forbidden-action tests; resumable external master approvals with persisted per-master nonces, expiry bounds and builder approval verification. Bloxwap builder defaults to f=10 (0.01%); hash-preimage policy limitations are explicit in PROGRESS/custody docs. Direct live sandbox denial still needs a dedicated Privy test app. | | #14–#19 | Relay quote/fee/address fixture tests, bigint round trips including signed values, terminal QR snapshot and file renderer, idempotent funding store, ordered/resumable signature tests, terminal deposit transitions and collateral receipt excluding marked PnL. | | #20–#25 | Numeric-string account/order/market projections, approval/policy/deposit/expiry/runner status; every risk bound tested, opposing pending exposure and realized/marked daily PnL checked; SDK order fixture tests cover limit/IOC/slippage/trigger behavior, cancellation, batch modification preserving reduce-only/TIF/trigger semantics and leverage ceilings/loss. | | #26–#28 | Private PID/socket lifecycle, stale dead-PID recovery/readiness, serialized RPC, cross-network/config refusal and direct fallback only on confirmed absence; simulated WS drop, SDK resubscribe, paginated info gap-fill, persisted fill dedupe. Windows ACL limitations are documented. | | #29–#32 | Locked sequence journal across independent writers/restarts, replay/types/follow, exact fill fee units; log filtering/redaction; runner heartbeat/staleness and deposit polling; mocked-clock expiry blocking and once-per-threshold warnings. | | #33–#36 | New approval verified before signer handoff/old revocation, resumable rotation and immediate local revocation tests; fsynced cross-process hash-chain with intent/decision/signature digest/response/fill linkage; tamper/redaction tests and linked full-lifecycle records. CLI/RPC pre-schema hex/mnemonic detector and threat model. | -| #37–#38 | Frontmatter validator, independent simulated skill forward-test (schema-validated actions, ordered signatures, pending/rejection/expiry handling), bounded polling and supported-test-route guidance; install/custody README, generated input/output command reference and static Pages site. Real agent/testnet validation is credential-gated. | -| #39 | Offline full CLI/SDK/RPC account lifecycle test; separate opt-in nightly testnet script signs only in CI, uses mocked Relay and funded test collateral, covers onboarding/orders/events/rotation/revoke and exports event/audit artifacts. Live execution skips without secrets. | -| #40–#42 | Packed global installation and Bun smoke, executable bin, engine declaration, no package postinstall; Linux/macOS binary build, changesets/tag/version/provenance/asset pipeline behind publish gate; packaged/embedded skill, Claude/Codex paths, CODEX_HOME, overwrite protection and matching versions. Publishing/registry verification awaits external credentials. | +| #37–#38 | Skill source/frontmatter/error-catalog tests and every documented command input parsed through actual CLI schemas offline; user-owned Privy, ordered external approvals, bounded polling, exchange reconciliation and supported-test-route guidance; install/custody README, generated command reference and static Pages site. A real coding-agent testnet session is still unproven. | +| #39 | Offline full CLI/SDK/RPC account lifecycle test and testnet-driver assertions; separate nightly script signs only for an acknowledged disposable CI test account, uses mocked Relay and already funded test collateral, reconciles exchange outcomes and exports event/audit/report artifacts. Explicit live execution fails when prerequisites are missing. Nightly live evidence remains pending. | +| #40–#42 | Script-free packed global installation and installed bunx smoke, executable bin, license and matching skill versions; Node 22.12/24 install CI; Linux/macOS ARM/x64 native binary smoke; changesets/tagged-main/version/provenance/asset pipeline with candidate-only manual runs. Publishing and public registry verification await npm configuration. | +| #49 | Token guard rejects declarations anywhere in formatted/minified CSS. Unit regression and a mutation of global.css prove docs:check rejects the issue's exact same-line copy. | -No push, tag creation, remote changes, package publication or live-account trading -was performed. Local acceptance deliberately leaves unsupported external claims -unproven instead of labelling skipped checks as successful live tests. +No package publication or credentialed account trading is claimed by this review. +Close the live criteria only with an actual run ID, commit, lifecycle report and +event/audit artifacts, or a published version with verified integrity/provenance +and public installation evidence. Mocks, missing-secret failures and candidate +artifacts are not substitutes for those records. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 30bfcf2..cfdf660 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -13,7 +13,7 @@ | #8 | done | Service error adapters, redacted upstream detail, fixture-driven classification and retry defaults. | | #9 | done | Bounded jittered retries and per-signer persisted monotonic nonces guarded by exclusive locks. Stale locks fail closed. | | #10 | done | Privy server SDK create/link, P-256 owner, private authorization storage and typed-data signer; sandbox test env-gated. | -| #11 | partial | Versioned domain/network Privy policy and local trade allowlist; L1 action hash prevents Privy per-action/notional inspection. Exchange agent permissions enforce withdrawal boundary. | +| #11 | partial | User-owned Privy app, v2 domain/network and no-export policy, authorized wallet attachment and init revalidation; local typed-data and withdrawal rejection tests pass. Direct live Privy rejection still needs a dedicated test app. L1 action hashes prevent Privy per-action/notional inspection; exchange agent permissions enforce the withdrawal boundary. | | #12 | done | Idempotent managed-wallet onboarding, persisted external master EIP-712 approvals/deeplink, expiry bound, agent/builder verification and master-key rejection. | | #13 | done | Default Bloxwap builder address with f=10 (0.01%); config/env overrides, init approval, approved-maximum checks and fund/order address/rate reporting. | | #14 | done | Typed validated Relay quote/status adapter, perps USDC destination, multiple origin routes and fee parsing; mocked fixtures. | @@ -39,14 +39,15 @@ | #34 | done | Optional cancel-all, resumable master revocation payload, immediate local blocking and config/session clearing after exchange submission. | | #35 | done | Cross-process locked fsynced hash-chain audit, intent/decision/signature digest/response/fill linkage, rotation/revoke records and filters; tamper/redaction tests. | | #36 | done | Pre-schema recursive CLI/RPC secret detector, BIP39-like seed rejection, private authorization refs and env/log/error scrubbing; threat model docs. | -| #37 | partial | Independently forward-tested, schema-validated self-contained skill with every command example, ordered funding/custody flow and complete error table; real agent testnet execution requires credentials (#39 gate). | +| #37 | partial | User-owned Privy guidance, resumable approval/funding stages, exchange reconciliation and complete schema-validated command examples/error table. Offline forward tests pass; an actual coding-agent testnet session still needs dedicated QA credentials and evidence. | | #38 | done | Install/testnet README, custody architecture, schema-generated command reference and static docs site with gated Pages workflow. | -| #39 | partial | Full mocked CLI/SDK/RPC lifecycle passes; opt-in real testnet lifecycle with mocked Relay, nightly secrets gate and journal artifacts. Real execution skipped without credentials. | -| #40 | partial | Postinstall-free executable package, supported Node 22.12+ engine and global/Bun install smoke pass locally; macOS/Linux CI and optional compiled binaries. Registry install awaits publication. | -| #41 | partial | Changesets/version PR workflow, gated tag publish with npm provenance, generated release notes and binary/skill assets; publishing not run without registry credentials. | +| #39 | partial | Full mocked CLI/SDK/RPC lifecycle and fail-closed testnet harness, with builder approval, exchange outcome reconciliation, runner/events, rotation/revoke and audit assertions. Nightly live execution and journals need a disposable funded QA account and QA Privy app; missing credentials cannot count as a pass. | +| #40 | partial | Script-free tarball global install and actual installed bunx help/schema smoke pass locally; executable/skill/license checks and Node 22.12/24 macOS/Linux CI. Public registry installation awaits publication. | +| #41 | partial | Validated Changesets patch workflow with optional version PR, native binary smoke/checksums, tagged-main version gates, exact tested tarball publication with provenance and registry verification. Manual candidates cannot publish. Actual release needs npm authentication and the publishing gate; organization policy blocks automatic version PRs, so the default workflow uploads a reviewable patch. | | #42 | done | Final packaged flow audited and corrected (network/config isolation, exact loss/exposure/receipt checks, trigger preservation, readiness and stream errors). Skill shipped/embedded in npm and compiled CLI; Claude/Codex installation with CODEX_HOME, version synchronization, idempotency and overwrite protection. | +| #49 | done | Docs token guard checks declarations anywhere on a line; regression coverage includes formatted and minified radius, motion, density and type copies. The issue's same-line mutation makes docs:check fail. | -Final local verification: supported Node 22, strict build/lint/typecheck and isolated offline tests. Sandbox/testnet scripts skip without credentials. npm audit reports zero vulnerabilities. Bloxwap builder defaults to f=10 (0.01%), with config/env overrides. +Original implementation verification: supported Node 22, strict build/lint/typecheck and isolated offline tests. This did not establish live Privy/testnet or registry acceptance. Bloxwap builder defaults to f=10 (0.01%), with config/env overrides. ## Workspace and Fumadocs migration @@ -55,3 +56,18 @@ Final local verification: supported Node 22, strict build/lint/typecheck and iso - Local docs URLs: done — development defaults to `http://localhost:3903/` with documentation under `/docs/`; the Pages workflow explicitly retains `/agent-cli` as its deployment base path. - Landing workflow panel: done — shared code-window toolbar and copy control, highlighted shell commands, muted comments, and HIG-backed blue syntax colors. - Verification: Node 24; clean `npm ci`; root build/lint/typecheck/tests (71 tests), docs build/check, export link/social checks, static preview 200/404 checks, CLI pack and global-install smoke passed. No push or publish. + +## Open-issue review — 2026-10-16 + +Three agents reviewed policies (#11), the skill/testnet lifecycle (#37/#39), +and installation/releases (#40/#41); the integration review fixed #49. +The integrated local checks pass build, lint, typecheck, 103 CLI tests, two docs +guard tests, docs build/check and the script-free global npm/installed bunx smoke. +The exact same-line CSS mutation from #49 is rejected by `docs:check`. + +Live acceptance remains pending: the `testnet` environment has no QA credentials, +the public npm package returns 404, and publication authentication/gating is not +configured. GitHub also rejected the repository's Actions PR setting with an +organization-policy 409; version dispatch therefore defaults to an uploaded +reviewable patch. No production user credentials, package publication or live +account trading were used for this review. diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md new file mode 100644 index 0000000..228c0eb --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,49 @@ +# Working with a coding agent + +The AI decides which authorized actions to request. `agent-cli` provides JSON +commands, a signing runner, risk checks and event/audit journals; it does not +choose a trading strategy or supply a Bloxwap-owned wallet. + +Create your own Privy account and app, and configure them on your own machine or +runner. Bloxwap never receives your app secret, authorization key or master key. +Keep the master wallet external and review each approval there. Privy manages the +separate trading-agent key inside your app. Set `BLOXWAP_PRIVY_APP_SECRET` in your +local secret manager or process environment, outside the AI conversation. Then: + +```sh +agent-cli config set privyAppId YOUR_APP_ID +agent-cli config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET +agent-cli config set network testnet +agent-cli skill install --target codex +# Or: agent-cli skill install --target claude +``` + +Tell the agent to read the installed `agent-cli` skill and inspect command schemas. +Give it a network, allowed markets, maximum notional/leverage and the operations +you authorize. Configure the corresponding local limits before trading. Never +paste a private key, seed or Privy app secret into the conversation or CLI input. + +The workflow is `init` → `fund` → poll `status` until settled → `run --detach`. +`init` returns separate agent and builder approval payloads. Sign them externally +and resume the same `init`; proceed only when `approved:true`. Funding returns a +QR/address and possibly ordered signature steps. On testnet, verify a supported +test route and token; the default origin chain/token describes Arbitrum mainnet +and must not be used as a test route. If no route is available, stop and report +that prerequisite. The [testnet CI harness](testnet.md) uses mocked Relay instead. + +For every command, parse the JSON envelope and exchange result. A resting order +is accepted, not filled. Track its cloid/oid, reconcile uncertain writes and save +the last processed `events` sequence. React to fills, normalized errors and agent +expiry warnings as described in the skill. Bound status polling to 12 attempts or +five minutes, and report pending or failed deposits without starting trading. + +Rotation needs a new external approval, verified signer handoff, then separate +old-agent revocation. Final revocation should cancel outstanding orders and +confirm both local disablement and exchange revocation. Report incomplete +cleanup. Persist only public identifiers and redacted logs in task summaries. + +An offline forward test is useful evidence that command examples and recovery +steps work with fixtures. Closing the live-test acceptance criterion also needs +an actual coding-agent session using this skill against dedicated testnet +accounts, with a successful lifecycle report and event/audit artifacts. Record +the agent, commit and workflow/session ID; never label a mock or skip as that run. diff --git a/docs/custody.md b/docs/custody.md index ae8e6fa..037a3f4 100644 --- a/docs/custody.md +++ b/docs/custody.md @@ -1,16 +1,54 @@ # Custody boundary -The master wallet approves a separate Hyperliquid API agent. Its key stays in Privy; -the CLI holds a P-256 authorization key, never the master private key. Hyperliquid -agent permissions prevent withdrawals and master-only user-signed actions. +Each user creates and controls their own [Privy account/app](https://dashboard.privy.io/). +Set that app's ID and a local environment reference to its secret. There is no +shared Bloxwap Privy account, credential proxy, or hosted Bloxwap signer. +Do not send Privy credentials or wallet keys to Bloxwap, an AI conversation, +repository, issue, or support message. -The versioned Privy policy permits only EIP-712 Agent messages in the Hyperliquid -L1 domain (chain 1337, zero verifying contract, correct network source). Unmatched -RPC methods are denied by default. User-signed withdrawal and approval messages -have a different domain/type. Local dispatch allows only trading operations. +The master wallet stays external and approves a separate Hyperliquid API agent. +Privy manages the agent wallet key in the user's app; the CLI generates its +P-256 owner authorization key locally. Only the public authorization key is +registered with Privy. The private authorization key remains in a private local +file or user-controlled environment reference. Bloxwap never receives or stores +the master key, agent key, app secret, or authorization key on its infrastructure. +Run the CLI/runner on your machine or infrastructure you control. The CLI calls +Privy directly with your app credentials. Privy's authorization model is described +in its [wallet controls documentation](https://docs.privy.io/security/wallet-infrastructure/policy-and-controls). + +`init` creates the versioned policy in `packages/agent-cli/policies/agent-trade-only.json`, +stores its ID and CLI policy version, and attaches it when creating or linking +the agent wallet. Re-running `init` verifies the remote policy rules against the +configured network, replaces outdated/different rules, and confirms attachment. +Updating an already owned wallet uses your existing local owner authorization +key; the app secret alone is not treated as permission to replace its owner. +An owned wallet cannot be linked without that key. Never link the master wallet +as the agent. `status` reports the configured policy ID/version; it does not fetch +and attest the remote policy on every read. + +The Privy policy allows only typed-data requests matching chain 1337, the zero +verifying contract and the Agent schema's network source (`a` mainnet, `b` testnet). +It explicitly denies agent private-key/seed export; unmatched methods are denied +by [Privy's default policy behavior](https://docs.privy.io/controls/policies/overview). +The local signer additionally requires the exact `Exchange`/`1` domain, Agent +fields and a 32-byte connection ID. User-signed withdrawals, transfers and agent +approvals have a different type/domain and are refused locally. The CLI dispatch +allowlist refuses withdrawal/transfer/approval actions before signing. Hyperliquid +API-agent permissions supply the master-account withdrawal boundary. Privy sees the L1 action hash, not its preimage: it cannot independently distinguish an order from another L1 action or enforce notional limits inside that hash. Local limits and exchange agent permissions supply those checks. Do not describe -this policy as independent server-side per-action/notional enforcement. Confirm -policy behavior in the credential-gated sandbox before production use. +this policy as independent server-side per-action/notional enforcement. Passing +the local Agent-envelope check also does not prove that a hash contains an order. +Privy's [Hyperliquid guide](https://docs.privy.io/recipes/hyperliquid/policies-and-offline-actions) +distinguishes user-signed actions from L1 agent actions. + +`npm run test:sandbox` needs `BLOXWAP_PRIVY_SANDBOX=1`, `BLOXWAP_PRIVY_APP_ID` +and `BLOXWAP_PRIVY_APP_SECRET` from your dedicated test app. It creates an empty +wallet and policy in that app, signs a valid testnet Agent envelope, then directly +requests a withdrawal-type signature and expects `POLICY_REJECTED`. It sends no +transaction to Hyperliquid and needs no master key or funds. The wallet and policy +remain visible in your Privy app for inspection; never reuse that test wallet for +funds. Without those credentials the explicit sandbox command fails its preflight. +Unit tests alone are not evidence of a live Privy rejection. diff --git a/docs/index.md b/docs/index.md index 1aa2f3a..d9493f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,8 +4,10 @@ Use the [command reference](commands.md) for JSON schemas, [configuration](confi for state/env settings, [funding](funding.md) for deposits/signatures, [errors](errors.md) for recovery, [events](events.md) for streaming, [custody](custody.md) and [security](security.md) for key boundaries. +Start with [working with a coding agent](agent-workflow.md) to configure your own +Privy app and understand approvals, funding and completion checks. Development progress is recorded in [PROGRESS](PROGRESS.md). Documentation is built with `npm run docs:build` into `apps/docs/out`. Use `npm run docs:dev` -for port 3903 at `/agent-cli`, or `npm run docs:preview` for the static export. +for port 3903 at `/`, or `npm run docs:preview` for the static export. The command reference and skill MDX are synchronized with `npm run docs:generate`. diff --git a/docs/releases.md b/docs/releases.md index 1d3a76a..a457c52 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,13 +1,96 @@ # Releases -Add `.changeset/*.md` for user-visible changes. `npm run version:packages` applies -versioning/changelogs and synchronizes the packaged skill version. The version -workflow creates a version PR when manually invoked. - -A matching `vVERSION` tag builds/tests/packs and compiles Linux/macOS executables, -then publishes with npm provenance and attaches binaries, SKILL.md and changelog -to a generated-notes GitHub release. Enable repository variable -`BLOXWAP_PUBLISH=1` and configure `npm-release` environment plus `NPM_TOKEN` (or npm -trusted publishing) before using it. No publishing occurs without that gate. -Registry ownership, provenance and hosted CI execution need external credentials. -The implementation process does not push tags, publish packages or alter remotes. +## Prepare a candidate + +Add `.changeset/*.md` for user-visible changes. The version workflow runs +`npm run version:packages` to consume changesets, update the package lock/changelog, +and synchronize the packaged skill version. By default it regenerates the docs, +validates build, lint, typecheck, tests and docs checks, then uploads a reviewable +`version.patch` artifact. Apply that +patch on a fresh branch at the run's revision and open the version PR yourself; +alternatively run `npm run version:packages` locally and create the PR. With the +current pending first-release changeset, the next stable version is **0.2.0**, +not 0.1.0. + +The optional `create_pr=true` input uses Changesets to create the version PR after +the same validation. This needs GitHub Actions' permission to create pull requests. +At audit time, enabling it returned HTTP409: the organization disallows Actions +from creating or approving PRs. An organization owner must permit it before the +repository can enable that setting, or maintainers must configure an appropriately +scoped GitHub App token. The default patch path works without changing that policy. + +Run `npm run build`, `npm run lint`, `npm run typecheck`, `npm test`, and +`BLOXWAP_SMOKE_BUN=1 npm run test:package`. The package smoke test installs the +packed artifact with lifecycle scripts disabled and exercises npm's global +executable and installed bunx with both Node and Bun. Install CI runs on +macOS/Linux with Node 22.12 and 24. + +The Release workflow can also be manually dispatched on a branch to build a +candidate. It validates metadata, tests packed installs, compiles and executes +native Linux/macOS x64/arm64 binaries, and uploads reviewable artifacts. A manual +run **never publishes**, even when `BLOXWAP_PUBLISH=1`. The compiled x64 Linux +binary uses Bun's baseline target; binaries do not load incidental `.env` or +`bunfig.toml` files from the caller's directory. + +## Configure publishing + +As of 2026-10-16, `@bloxwap/agent-cli` is not publicly available on npm (registry +404), no `BLOXWAP_PUBLISH` variable or `NPM_TOKEN` secret is configured, and the +`npm-release` GitHub environment does not exist. The install and publication +criteria remain pending until an actual public release passes registry smoke. +No version tag, npm publication or GitHub release was created by this audit. + +Before enabling publication: + +1. Confirm npm write access to the `@bloxwap` scope and the package name. Configure + a granular npm token as the `NPM_TOKEN` secret in the `npm-release` environment + for the initial publication, or configure an authorized npm trusted publisher. + A local `npm whoami` does not verify OIDC trusted-publisher permissions. +2. Create the `npm-release` GitHub environment and apply the desired tag/reviewer + restrictions before setting the repository variable `BLOXWAP_PUBLISH=1`. +3. Prefer npm trusted publishing after the package exists. Bind it to organization + `bloxwap`, repository `agent-cli`, workflow **`release.yml`**, and environment + **`npm-release`**, with direct `npm publish` allowed. Node 24 includes a supported + npm CLI (trusted publishing requires npm 11.5.1+ and Node 22.14+). Do not leave a + token configured after migrating fully to OIDC. New trusted publisher bindings + must complete their first successful publish within npm's activation window. + +See the current [npm trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/) +for scope ownership, activation and authentication details. The package's +repository metadata identifies the public GitHub source required for provenance. + +## Publish a reviewed version + +Merge the version PR and all required fixes to `main`, ensure CI is green, then +check the exact version and revision before pushing a tag: + +```sh +git switch main +git pull --ff-only +node -p 'JSON.parse(require("node:fs").readFileSync("packages/agent-cli/package.json", "utf8")).version' +# Substitute that version below only after reviewing the candidate artifacts. +git tag -a vVERSION -m 'Release vVERSION' +git push origin vVERSION +``` + +A matching stable `vVERSION` tag must point to a commit on `origin/main`, have a +matching packaged skill and changelog, and contain no unconsumed changesets. +The workflow rejects mismatched, prerelease or unreviewed branch tags. A tag with +publishing disabled still builds candidate artifacts; enabling the gate later +requires rerunning the existing tag's workflow, not moving the tag. + +After all four native candidates succeed, the gated publish job downloads the +exact tested tarball, computes `SHA256SUMS`, publishes it with `--provenance`, and +verifies registry integrity plus the provenance attestation. It then tests a +public global npm install and bunx registry execution before creating generated +GitHub release notes and attaching the npm tarball, four binaries, SKILL.md, +CHANGELOG.md, LICENSE and checksums. Downloaded binaries need `chmod +x` before +execution. Verify downloads with `sha256sum -c SHA256SUMS` (Linux) or +`shasum -a 256 -c SHA256SUMS` (macOS). + +A published npm version is immutable. If publication succeeds but a later step +fails, inspect the registry and failure before rerunning the job; keep the tag in +place. The publish step accepts an existing version only when its registry hash +matches the tested archive and its provenance is present, then continues the +remaining release steps. Authentication and network errors never count as an +unpublished version. A conflicting existing archive stops the workflow. diff --git a/docs/security.md b/docs/security.md index c3ec951..53ff4e9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -3,12 +3,20 @@ State is always `~/.bloxwap`, directory 0700, config/auth keys/logs/events/audit 0600. P-256 Privy authorization keys are PKCS8 base64 in private files or environment references. The CLI does not import master keys, seeds or mnemonic phrases. +Every user owns their Privy account/app and runs the CLI/runner locally or on +infrastructure they control. App IDs and wallet IDs identify resources in that +user's app. App secrets and P-256 private authorization keys remain in the user's +environment or private local files; Bloxwap never receives or stores them. There +is no Bloxwap credential service, shared app secret, or signing proxy. The agent +wallet key is managed by Privy in the user's app, separate from the external +master wallet. The CLI never exports either wallet's key. External master-wallet signatures are accepted only as 65-byte signatures. Every CLI and RPC input is scanned before schema parsing so unknown fields cannot smuggle key material. Hex 32-byte secrets and BIP39-like phrases are refused. -Trust boundaries: master wallet → one-time Hyperliquid agent approval → Privy -managed wallet → restricted local signing runner. Read `custody.md` for the hash +Trust boundaries: external master wallet → one-time Hyperliquid agent approval → +agent wallet in the user's Privy app → user-controlled local signing runner. +Read `custody.md` for the hash preimage limitation of server policies. Anyone controlling this OS user can use the authorization key and change limits. Socket/file modes exclude other users, not same-user malware or root. Windows named pipes require current-user ACLs; diff --git a/docs/testnet.md b/docs/testnet.md new file mode 100644 index 0000000..2dc1ea9 --- /dev/null +++ b/docs/testnet.md @@ -0,0 +1,55 @@ +# Testnet lifecycle verification + +The `Testnet lifecycle` GitHub workflow runs nightly and on manual dispatch in the +`testnet` environment. It fails when required credentials are absent. A successful +unit test, skipped integration test or workflow that stops before the lifecycle +is not a live testnet pass. + +Set these environment secret **names**, using a dedicated disposable company CI +account and separate company test Privy app: + +| Secret | Purpose | +| --- | --- | +| `BLOXWAP_TEST_MASTER_KEY` | CI-only master signer for a testnet-only disposable account | +| `BLOXWAP_PRIVY_APP_ID` | Dedicated test Privy app ID | +| `BLOXWAP_PRIVY_APP_SECRET` | Dedicated test Privy app secret | + +No production user key or credential belongs in this workflow. Production users +create and own their own Privy account/app and approve actions from their external +wallet. Bloxwap never receives those keys or secrets. The CI-only test signer is +not shipped as a CLI key-import mode and its key is removed from CLI child process +environments. + +Pre-fund the disposable Hyperliquid **testnet** account with at least 30 test USDC. +It must have no positions, open orders or leftover agent approvals. Use an +ephemeral clean CI home: the harness refuses existing `~/.bloxwap` state. CI sets +`BLOXWAP_E2E=1` and `BLOXWAP_TEST_ACCOUNT_ONLY=1`; signing is refused outside CI. +Do not run this workflow with a mainnet account or real funds. + +Hyperliquid testnet and Privy calls are real. Relay is explicitly mocked at the +testnet URL: the quote has zero expected output and settlement relies on existing +test collateral. No token transfer or bridge execution is claimed. Mainnet Relay +and Hyperliquid HTTP endpoints are blocked by the harness preload. This boundary +is visible in every lifecycle report and workflow summary. + +The lifecycle asserts both init approvals, funding QR and settled state, runner +signing/readiness, post-only resting order placement, observable modification and +cancellation, WebSocket order events, `events --follow`/cursor replay, verified +rotation/old-agent revocation, final local/exchange revocation and hash-verified +audit intent/signature/response records. Assertions reconcile exchange state; +`ok:true` alone never counts as completion. Orders use BTC with a 50 test USD +notional/position cap and a two-open-order limit. Test fills are not requested. + +Artifacts contain `lifecycle-report.json`, event and audit journals, streamed +events and redacted runner logs. They exclude configs, approvals and key files, +are checked against the known secret values before writing and expire after +seven days. The report records the commit/run and each stage's outcome. Failed +runs attempt order/agent cleanup; `failed-manual-review-required` means inspect +the disposable account before another run. A forced job termination can interrupt +cleanup, so account owners should review failed/timed-out runs. + +An AI-driven skill forward run remains a separate acceptance requirement: have a +coding agent read the installed skill, use the dedicated testnet setup, follow its +approval/funding boundaries, and record its agent/session ID plus lifecycle and +journal evidence. The deterministic CI script is not represented as that agent +session. Without credentials and both forms of evidence, #37/#39 remain partial. diff --git a/package.json b/package.json index b04cff9..4505424 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build:binaries": "npm --workspace @bloxwap/agent-cli run build:binaries", "test:sandbox": "npm --workspace @bloxwap/agent-cli run test:sandbox", "changeset": "changeset", - "version:packages": "changeset version && npm --workspace @bloxwap/agent-cli run skill:sync-version && npm --workspace @bloxwap/agent-cli run skill:generate", + "version:packages": "changeset version && npm --workspace @bloxwap/agent-cli run skill:sync-version && npm --workspace @bloxwap/agent-cli run skill:generate && node packages/agent-cli/scripts/sync-lock-version.mjs", + "version:candidate": "npm run version:packages && npm run docs:generate && npm run lint && npm run typecheck && npm test && npm run docs:check", "pack:cli": "npm pack --workspace @bloxwap/agent-cli --pack-destination packages/agent-cli", "docs:dev": "npm --workspace @bloxwap/agent-cli-docs run dev", "docs:build": "npm --workspace @bloxwap/agent-cli-docs run build", diff --git a/packages/agent-cli/LICENSE b/packages/agent-cli/LICENSE new file mode 100644 index 0000000..8c800a5 --- /dev/null +++ b/packages/agent-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Bloxwap contributors + +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/packages/agent-cli/README.md b/packages/agent-cli/README.md index 19e3aaa..fddf462 100644 --- a/packages/agent-cli/README.md +++ b/packages/agent-cli/README.md @@ -3,9 +3,12 @@ An agent-native Hyperliquid trading CLI with JSON input/output, a persistent signing runner, Privy managed agents, Relay funding, and a packaged agent skill. -Install: `npm i -g @bloxwap/agent-cli` or `bunx @bloxwap/agent-cli --help`. -The requested Hyperliquid SDK declares Node **22.12+**; use that supported runtime -or current Bun. No postinstall script. Publishing is release-gated. +After the first public release, install with `npm i -g @bloxwap/agent-cli` or run +`bunx @bloxwap/agent-cli --help`. Use Node **22.12+** or Bun; +`bunx --bun @bloxwap/agent-cli --help` explicitly selects Bun. +The package has no install lifecycle scripts and supports `--ignore-scripts`. +Until publication, build a local tarball with `npm run build && npm run pack:cli` +from the repository root and install that tarball with `npm i -g PATH_TO_TARBALL`. ## Testnet quickstart diff --git a/packages/agent-cli/SKILL.md b/packages/agent-cli/SKILL.md index 86bbd11..e425012 100644 --- a/packages/agent-cli/SKILL.md +++ b/packages/agent-cli/SKILL.md @@ -14,9 +14,13 @@ Use `--network testnet` during testing; never silently change to mainnet. ## Onboarding sequence -1. Configure Privy app ID and secret reference, e.g. `config set privyAppId APP` - and `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets - the environment secret outside the conversation. The default Bloxwap builder is +1. The user creates and owns their own Privy account and app. Configure that + app ID and a local secret reference, e.g. `config set privyAppId APP` and + `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the + environment secret outside the conversation on their own machine or runner. + Bloxwap does not host the app, receive credentials, or custody user keys. + Privy manages the trading-agent key in the user's app; master-wallet approval + always remains in the user's external wallet. The default Bloxwap builder is `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%). Override with `config set builder.address` / `config set builder.fee` or `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`. @@ -44,6 +48,25 @@ Use `--network testnet` during testing; never silently change to mainnet. plan, an error follows the catalog below, and expiry warnings prompt rotation using external master-wallet approval. +## Completion and approval boundaries + +Read this skill and query schemas before acting. Keep a progress record with the +network, current stage, deposit IDs, last event sequence and order cloid/oid; +record public IDs only. A missing user-owned Privy app, wallet signature, funding +route or settled balance is a blocked stage. Report the exact next action without +requesting any secret in chat or moving to the next stage. Repeat `init` to resume +persisted approvals rather than creating another agent. An `ok:true` transport +envelope does not imply exchange acceptance: inspect `data.result` and reconcile +`orders`/`events` after uncertain mutations. Never retry an order with a new cloid +until the original intent has been reconciled. + +For rotation, verify `swapped:true` before asking the external wallet to sign old +agent revocation. Require `oldRevoked:true` before claiming rotation complete. +`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms +that the signed exchange revocation was submitted. Inspect `status` to verify the +agent is absent from exchange approvals. Cancel outstanding orders before final +revocation, and report any cancellation or cleanup failure explicitly. + ## Commands and example envelopes Examples show representative `data`, not live balances or service promises. @@ -60,7 +83,7 @@ Full inputs/outputs are discoverable with `schema`; amounts/prices are strings. | `fund --amount 10 --no-qr` | `{"ok":true,"data":{"depositId":"...","depositAddress":"0x...","expectedReceived":"100000000","builderFeeRate":"0.01%","builderAddress":"0x71b09A08257078A4d642F7Dd7315e656c837329a","state":"awaiting_funds","qrAscii":null}}` | | `deposit --deposit-id ID` | `{"ok":true,"data":{"depositId":"ID","steps":[{"id":"step:0","requiresSignature":true,"payload":{}}]}}` | | `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{"ok":true,"data":{"depositId":"ID","steps":[]}}` | -| `status` | `{"ok":true,"data":{"balance":{"accountValue":"100"},"agent":{"approved":true,"daysRemaining":"179"},"deposits":[],"policy":{"id":"...","version":"1"},"runner":{"pid":"123"}}}` | +| `status` | `{"ok":true,"data":{"balance":{"accountValue":"100"},"agent":{"approved":true,"daysRemaining":"179"},"deposits":[],"policy":{"id":"...","version":"2"},"runner":{"pid":"123"}}}` | | `balance` | `{"ok":true,"data":{"accountValue":"100","accountValueUnits":"10000000000","withdrawable":"96","marginUsed":"4"}}` | | `market --coin BTC` | `{"ok":true,"data":{"coin":"BTC","asset":"0","szDecimals":"5","maxLeverage":"40","mid":"20000"}}` | | `positions` | `{"ok":true,"data":[{"coin":"BTC","size":"0.001","entryPrice":"20000","leverage":"5","marginMode":"cross"}]}` | @@ -119,3 +142,17 @@ trade-only; local limits include markets, size/notional, leverage, daily loss an open orders. Privy sees an L1 action hash, so it cannot independently inspect notional/action contents; Hyperliquid agent permissions and local checks enforce those boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`. + + +## Verification boundaries + +Offline skill tests validate the documented command examples against CLI schemas. +Mocked lifecycle tests exercise resumable approvals, pending funding, events +and failure handling. These checks do **not** prove a live agent or +exchange run. The nightly testnet lifecycle uses only a company-owned disposable +CI master account and separate test Privy app, real Hyperliquid testnet and Privy, +and an explicitly mocked Relay route backed by already funded test collateral. +It never transfers real funds. Missing CI credentials fail the job; a skipped or +mocked test must never be reported as live validation. Record the workflow run, +commit, lifecycle report, event and audit artifacts before claiming a testnet pass. +See `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup. diff --git a/packages/agent-cli/package.json b/packages/agent-cli/package.json index 2d21ffd..7701306 100644 --- a/packages/agent-cli/package.json +++ b/packages/agent-cli/package.json @@ -11,7 +11,9 @@ "files": [ "dist", "SKILL.md", - "policies" + "policies", + "LICENSE", + "CHANGELOG.md" ], "scripts": { "build": "node scripts/generate-skill.mjs && tsup src/cli.ts --format esm --clean && node scripts/finalize-build.mjs", @@ -45,8 +47,17 @@ "ws": "^8.22.0", "zod": "^4.6.7" }, - "description": "Agent-native Hyperliquid trading CLI with Privy custody and Relay funding", + "description": "Agent-native Hyperliquid trading CLI with user-owned Privy signing and Relay funding", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/bloxwap/agent-cli.git", + "directory": "packages/agent-cli" + }, + "homepage": "https://bloxwap.github.io/agent-cli/", + "bugs": { + "url": "https://github.com/bloxwap/agent-cli/issues" + }, "publishConfig": { "access": "public" } diff --git a/packages/agent-cli/policies/agent-trade-only.json b/packages/agent-cli/policies/agent-trade-only.json index 353d662..2ba11ad 100644 --- a/packages/agent-cli/policies/agent-trade-only.json +++ b/packages/agent-cli/policies/agent-trade-only.json @@ -1,8 +1,53 @@ { - "version":"1.0","name":"agent-cli trade-only v1","chain_type":"ethereum", - "rules":[{"name":"Hyperliquid L1 agent domain","method":"eth_signTypedData_v4","action":"ALLOW","conditions":[ - {"field_source":"ethereum_typed_data_domain","field":"chainId","operator":"eq","value":"1337"}, - {"field_source":"ethereum_typed_data_domain","field":"verifyingContract","operator":"eq","value":"0x0000000000000000000000000000000000000000"}, - {"field_source":"ethereum_typed_data_message","field":"source","operator":"eq","value":"b","typed_data":{"primary_type":"Agent","types":{"Agent":[{"name":"source","type":"string"},{"name":"connectionId","type":"bytes32"}]}}} - ]}] + "version": "1.0", + "name": "agent-cli HL agent domain v2", + "chain_type": "ethereum", + "rules": [ + { + "name": "Hyperliquid L1 agent domain", + "method": "eth_signTypedData_v4", + "action": "ALLOW", + "conditions": [ + { + "field_source": "ethereum_typed_data_domain", + "field": "chainId", + "operator": "eq", + "value": "1337" + }, + { + "field_source": "ethereum_typed_data_domain", + "field": "verifyingContract", + "operator": "eq", + "value": "0x0000000000000000000000000000000000000000" + }, + { + "field_source": "ethereum_typed_data_message", + "field": "source", + "operator": "eq", + "value": "b", + "typed_data": { + "primary_type": "Agent", + "types": { + "Agent": [ + { "name": "source", "type": "string" }, + { "name": "connectionId", "type": "bytes32" } + ] + } + } + } + ] + }, + { + "name": "Never export agent private key", + "method": "exportPrivateKey", + "action": "DENY", + "conditions": [] + }, + { + "name": "Never export agent seed phrase", + "method": "exportSeedPhrase", + "action": "DENY", + "conditions": [] + } + ] } diff --git a/packages/agent-cli/scripts/binary-smoke.mjs b/packages/agent-cli/scripts/binary-smoke.mjs new file mode 100644 index 0000000..06d3d16 --- /dev/null +++ b/packages/agent-cli/scripts/binary-smoke.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const binary = resolve( + process.argv[2] ?? `binaries/agent-cli-${process.platform}-${process.arch}`, +); +const run = (args) => + JSON.parse( + execFileSync(binary, args, { + input: '', + encoding: 'utf8', + timeout: 30_000, + }), + ); +assert.equal(run(['--help']).ok, true); +const order = run(['schema', 'order']); +assert.equal(order.ok, true); +assert.ok(order.data.input.properties.coin); +console.log( + `PASS: standalone ${process.platform}/${process.arch} CLI help and order schema`, +); diff --git a/packages/agent-cli/scripts/build-binaries.mjs b/packages/agent-cli/scripts/build-binaries.mjs index 1a6b8aa..aeab9e8 100644 --- a/packages/agent-cli/scripts/build-binaries.mjs +++ b/packages/agent-cli/scripts/build-binaries.mjs @@ -1,19 +1,32 @@ +import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; +import { mkdirSync, rmSync } from 'node:fs'; + +const targets = { + 'bun-linux-x64-baseline': 'linux-x64', + 'bun-linux-arm64': 'linux-arm64', + 'bun-darwin-x64': 'darwin-x64', + 'bun-darwin-arm64': 'darwin-arm64', +}; +const requested = process.argv.slice(2); +const selected = requested.length ? requested : Object.keys(targets); +for (const target of selected) + assert.ok(target in targets, `Unsupported binary target: ${target}`); +rmSync('binaries', { recursive: true, force: true }); mkdirSync('binaries', { recursive: true }); -const targets = process.argv.slice(2); -for (const target of targets.length - ? targets - : ['bun-linux-x64', 'bun-darwin-arm64']) +for (const target of selected) execFileSync( 'bun', [ 'build', 'src/cli.ts', '--compile', + '--minify', + '--no-compile-autoload-dotenv', + '--no-compile-autoload-bunfig', `--target=${target}`, '--outfile', - `binaries/agent-cli-${target.replace('bun-', '')}`, + `binaries/agent-cli-${targets[target]}`, ], { stdio: 'inherit' }, ); diff --git a/packages/agent-cli/scripts/e2e-relay-hook.mjs b/packages/agent-cli/scripts/e2e-relay-hook.mjs index da1f9f0..afab35a 100644 --- a/packages/agent-cli/scripts/e2e-relay-hook.mjs +++ b/packages/agent-cli/scripts/e2e-relay-hook.mjs @@ -1,8 +1,14 @@ // Loaded only by the explicit testnet e2e harness, never by the packaged CLI. const originalFetch = globalThis.fetch; globalThis.fetch = async (url, init) => { - if (String(url).startsWith('https://api.testnets.relay.link/')) { - if (String(url).includes('/quote/')) + const target = new URL(url instanceof Request ? url.url : String(url)); + if (['api.hyperliquid.xyz', 'api.relay.link'].includes(target.hostname)) + throw new Error('Mainnet requests are forbidden in the CI test harness'); + if (target.origin === 'https://api.testnets.relay.link') { + if (target.pathname.startsWith('/quote/')) { + const quote = JSON.parse(url instanceof Request ? await url.clone().text() : String(init?.body)); + if (quote.originChainId !== 421614 || quote.originCurrency !== '0x' + '0'.repeat(40)) + throw new Error('Expected the explicit test-only Relay fixture route'); return Response.json({ steps: [ { @@ -19,7 +25,8 @@ globalThis.fetch = async (url, init) => { expiresAt: Date.now() + 60000, }, }); - if (String(url).includes('/intents/status')) + } + if (target.pathname.startsWith('/intents/status')) return Response.json({ status: 'success' }); throw new Error('Unexpected Relay mock endpoint'); } diff --git a/packages/agent-cli/scripts/e2e-testnet.mjs b/packages/agent-cli/scripts/e2e-testnet.mjs index b332c55..e1c914a 100644 --- a/packages/agent-cli/scripts/e2e-testnet.mjs +++ b/packages/agent-cli/scripts/e2e-testnet.mjs @@ -1,135 +1,179 @@ -import { execFileSync } from 'node:child_process'; -import { mkdirSync, copyFileSync, existsSync, writeFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { homedir } from 'node:os'; +import { createConnection } from 'node:net'; import { privateKeyToAccount } from 'viem/accounts'; -if ( - process.env.BLOXWAP_E2E !== '1' || - !process.env.BLOXWAP_TEST_MASTER_KEY || - !process.env.BLOXWAP_PRIVY_APP_ID || - !process.env.BLOXWAP_PRIVY_APP_SECRET -) { - console.log( - 'SKIP: set BLOXWAP_E2E=1 and dedicated testnet master/Privy credentials', - ); - process.exit(0); -} -if (!['true', '1'].includes(process.env.CI ?? '')) - throw new Error( - 'Master-key e2e signing is restricted to a dedicated CI account', - ); -const account = privateKeyToAccount(process.env.BLOXWAP_TEST_MASTER_KEY); -const config = join(homedir(), '.bloxwap', 'e2e-config.json'); -if (existsSync(config)) - throw new Error( - 'Refusing to replace an existing e2e config; use a clean CI account', - ); -const hook = resolve('scripts/e2e-relay-hook.mjs'); -const env = { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${hook}`, -}; -const run = (name, input = {}) => { - const raw = execFileSync( - process.execPath, - [ - 'dist/cli.js', - name, - '--config', - config, - '--network', - 'testnet', - '--json', - JSON.stringify(input), - ], - { input: '', encoding: 'utf8', env }, - ); - const result = JSON.parse(raw); - if (!result.ok) throw new Error(JSON.stringify(result)); - return result.data; +import { requireTestnetEnvironment, requiredTestnetSecrets, testnetLifecycle } from './testnet-lifecycle.mjs'; + +const directory = join(homedir(), '.bloxwap'); +const config = join(directory, 'e2e-config.json'); +const artifacts = resolve('artifacts'); +mkdirSync(artifacts, { recursive: true }); +const report = { + schemaVersion: 1, status: 'failed', network: 'testnet', + accountOwnership: 'company-owned disposable CI account; never a user wallet', + coverage: { + hyperliquid: { mode: 'live testnet', verified: false }, + privy: { mode: 'live dedicated test app', verified: false }, + relay: { mode: 'mocked zero-value route; pre-funded collateral', verified: false }, + }, + agentSession: { status: 'not-run', reason: 'Deterministic CI script; an independent coding-agent session is required for issue 37' }, + commit: process.env.GITHUB_SHA ?? null, + workflowRun: process.env.GITHUB_RUN_ID ?? null, + startedAt: new Date().toISOString(), stages: [], cleanup: 'not-started', }; -const sign = (payload) => account.signTypedData(payload.typedData); -const info = async (type, params = {}) => - ( - await fetch('https://api.hyperliquid-testnet.xyz/info', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type, ...params }), - }) - ).json(); -try { - run('config', { - args: ['set', 'privyAppId', process.env.BLOXWAP_PRIVY_APP_ID], - }); - const init = run('init', { masterAddress: account.address, qr: false }); - if (!init.approved) - run('init', { signature: await sign(init.approvals.agent), qr: false }); - const state = await info('clearinghouseState', { user: account.address }); - if (Number(state.withdrawable) < 30) - throw new Error( - 'Dedicated testnet account needs at least 30 test USDC; no real funding is performed', - ); - run('fund', { amount: '10', qr: false }); - const status = run('status'); - if (status.deposits.some((d) => d.state !== 'settled')) - throw new Error('Mocked Relay funding did not settle'); - run('run', { detach: true }); - const meta = await info('meta'); - const market = meta.universe.find((m) => m.name === 'BTC'); - const mids = await info('allMids'); - const mid = Number(mids.BTC); - const size = ( - Math.ceil((20 / mid) * 10 ** market.szDecimals) / - 10 ** market.szDecimals - ).toFixed(market.szDecimals); - const cloid = '0x' + Date.now().toString(16).padStart(32, '0'); - const order = run('order', { - coin: 'BTC', - side: 'buy', - size, - price: String(Math.floor(mid * 0.7)), - cloid, - intentId: 'nightly-lifecycle', +let account; +let childEnv; +let ownedState = false; +let follower; +let followed = ''; +let followFailure = false; +let stoppingFollower = false; +function command(name, input = {}) { + const result = spawnSync(process.execPath, [ + 'dist/cli.js', name, '--config', config, '--network', 'testnet', + ], { input: JSON.stringify(input), encoding: 'utf8', env: childEnv, timeout: 60_000, maxBuffer: 4_194_304 }); + let envelope; + try { envelope = JSON.parse(result.stdout); } + catch { throw new Error(`Command ${name} did not return one JSON envelope`); } + if (result.status !== 0 || !envelope.ok) + throw new Error(`Command ${name} failed (${envelope.code ?? 'PROCESS_ERROR'})`); + return envelope.data; +} +async function sign(approval) { + if (approval.typedData.domain.name !== 'HyperliquidSignTransaction' || + approval.typedData.message.hyperliquidChain !== 'Testnet') + throw new Error('Refusing to sign approval outside Hyperliquid testnet'); + return account.signTypedData(approval.typedData); +} +async function info(type, params = {}) { + const response = await fetch('https://api.hyperliquid-testnet.xyz/info', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type, ...params }), signal: AbortSignal.timeout(15_000), }); - const oid = order.result.response.data.statuses[0].resting?.oid; - if (!oid) throw new Error('Expected resting test order'); - run('modify', { oid, price: String(Math.floor(mid * 0.69)) }); - run('cancel', { oid }); - run('events', { since: 0 }); - const rotation = run('rotate'); - const swapped = run('rotate', { signature: await sign(rotation.approval) }); - run('rotate', { revokeSignature: await sign(swapped.revokeApproval) }); - const revoke = run('revoke', { cancelOrders: true }); - run('revoke', { signature: await sign(revoke.approval) }); - run('audit', { intent: 'nightly-lifecycle' }); - console.log( - 'PASS: testnet init/fund/run/order/modify/cancel/events/rotate/revoke', - ); -} finally { - mkdirSync('artifacts', { recursive: true }); - for (const file of ['events.jsonl', 'audit.jsonl']) - if (existsSync(join(homedir(), '.bloxwap', file))) - copyFileSync(join(homedir(), '.bloxwap', file), join('artifacts', file)); - // A failed run records its stage via journals; credentials are never exported. - writeFileSync('artifacts/network.txt', 'testnet\n'); - const { createConnection } = await import('node:net'); + if (!response.ok) throw new Error('Testnet info request failed'); + return response.json(); +} +async function stopRunner() { await new Promise((resolveStop) => { - const socket = createConnection(join(homedir(), '.bloxwap', 'run.sock')); - socket.on('connect', () => - socket.end( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'shutdown', - params: {}, - }) + '\n', - ), - ); + const socket = createConnection(join(directory, 'run.sock')); + socket.on('connect', () => socket.end(JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'shutdown', params: {}, + }) + '\n')); socket.on('error', resolveStop); socket.on('close', resolveStop); - socket.setTimeout(5000, () => { - socket.destroy(); - resolveStop(); - }); + socket.setTimeout(5000, () => { socket.destroy(); resolveStop(); }); }); } +async function stopFollowing() { + if (!follower || follower.exitCode !== null) return; + const child = follower; + stoppingFollower = true; + await new Promise((resolveStop) => { + const timer = setTimeout(() => { followFailure = true; child.kill('SIGKILL'); }, 5000); + child.once('exit', () => { clearTimeout(timer); resolveStop(); }); + child.kill('SIGTERM'); + }); +} +function safeArtifact(name, content) { + // Upload only the explicit journals, never configs, approval payloads, keys or + // arbitrary process errors. Drop the entire artifact if a known secret leaks. + for (const secretName of requiredTestnetSecrets) { + const secret = process.env[secretName]; + if (secret && content.includes(secret)) throw new Error('Secret detected in test artifact'); + } + if (/-----BEGIN [^-]*PRIVATE KEY-----/.test(content)) + throw new Error('Private key detected in test artifact'); + writeFileSync(join(artifacts, name), content, { mode: 0o600 }); +} +try { + requireTestnetEnvironment(process.env); + // A different config path alone does not isolate journals or sockets. Refuse + // any existing state directory so cleanup can only touch this CI run. + if (existsSync(directory)) throw new Error('Use a clean CI home without ~/.bloxwap'); + ownedState = true; + account = privateKeyToAccount(process.env.BLOXWAP_TEST_MASTER_KEY); + const hook = resolve('scripts/e2e-relay-hook.mjs'); + childEnv = { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${hook}` }; + delete childEnv.BLOXWAP_TEST_MASTER_KEY; + await testnetLifecycle({ + command, info, sign, masterAddress: account.address, appId: process.env.BLOXWAP_PRIVY_APP_ID, + checkpoint: (stage) => { + report.stages.push({ ...stage, at: new Date().toISOString() }); + if (stage.status === 'passed') { + if (stage.name === 'test-account-preflight') report.coverage.hyperliquid.verified = true; + if (stage.name === 'init-agent-and-builder-approvals') report.coverage.privy.verified = true; + if (stage.name === 'mock-relay-funding-and-settlement') report.coverage.relay.verified = true; + } + }, + startFollow: async () => { + follower = spawn(process.execPath, [ + 'dist/cli.js', 'events', '--follow', '--since', '0', '--network', 'testnet', '--config', config, + ], { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); + follower.on('error', () => { followFailure = true; }); + follower.on('exit', (code) => { if (!stoppingFollower || code !== 0) followFailure = true; }); + follower.stdout.setEncoding('utf8'); + follower.stdout.on('data', (line) => { + followed += line; + if (Buffer.byteLength(followed) > 4_194_304) { + followFailure = true; + follower.kill('SIGTERM'); + } + }); + // Do not echo raw child errors or credentials into hosted logs. + follower.stderr.resume(); + return async () => { + await stopFollowing(); + if (followFailure) throw new Error('events --follow terminated unexpectedly'); + const lines = followed.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); + if (!lines.some((event) => event.type === 'order_update')) + throw new Error('events --follow did not deliver order updates'); + }; + }, + }); + report.status = 'passed'; +} catch { + report.failure = report.stages.findLast((stage) => stage.status === 'failed')?.name ?? 'testnet-preflight'; + report.missingSecrets = requiredTestnetSecrets.filter((name) => !process.env[name]); + // Never export arbitrary SDK errors: they can contain credentials or payloads. + console.error('FAIL: dedicated testnet lifecycle; inspect lifecycle-report.json'); + process.exitCode = 1; +} finally { + if (ownedState && existsSync(config)) { + try { + if ((await info('extraAgents', { user: account.address })).length) { + // Best effort cleanup is itself signed only by the disposable CI + // account. Remaining approvals are a failed run requiring review. + const pending = command('revoke', { cancelOrders: true }); + command('revoke', { signature: await sign(pending.approval) }); + } + const agents = await info('extraAgents', { user: account.address }); + const orders = await info('frontendOpenOrders', { user: account.address }); + if (agents.length || orders.length) throw new Error('Test approvals or orders remain'); + report.cleanup = 'verified-no-orders-or-agents'; + } catch { + report.cleanup = 'failed-manual-review-required'; + report.status = 'failed'; + process.exitCode = 1; + } + await stopFollowing(); + await stopRunner(); + try { + for (const file of ['events.jsonl', 'audit.jsonl']) + if (existsSync(join(directory, file))) safeArtifact(file, readFileSync(join(directory, file), 'utf8')); + if (followed) safeArtifact('events-follow.jsonl', followed); + const logs = join(directory, 'logs'); + if (existsSync(logs)) for (const file of readdirSync(logs).filter((file) => file.endsWith('.jsonl'))) + safeArtifact(file, readFileSync(join(logs, file), 'utf8')); + } catch { + report.status = 'failed'; + report.artifacts = 'rejected-secret-or-unreadable-journal'; + process.exitCode = 1; + } + } + report.finishedAt = new Date().toISOString(); + // A failed preflight still produces an explicit failure artifact; never skip. + safeArtifact('lifecycle-report.json', JSON.stringify(report, null, 2) + '\n'); + if (report.status === 'passed') console.log('PASS: dedicated testnet lifecycle (Relay mocked; see report)'); +} diff --git a/packages/agent-cli/scripts/package-smoke.mjs b/packages/agent-cli/scripts/package-smoke.mjs index 2abb45a..00484e4 100644 --- a/packages/agent-cli/scripts/package-smoke.mjs +++ b/packages/agent-cli/scripts/package-smoke.mjs @@ -1,56 +1,159 @@ +import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; + const directory = mkdtempSync(join(tmpdir(), 'bloxwap-package-')); -try { - const packed = JSON.parse( - execFileSync('npm', ['pack', '--json', '--pack-destination', directory], { +const jsonCommand = (command, args, cwd = directory) => + JSON.parse( + execFileSync(command, args, { + cwd, + input: '', encoding: 'utf8', + timeout: 120_000, }), - )[0]; - for (const required of ['SKILL.md', 'dist/cli.js', 'policies/agent-trade-only.json']) - if (!packed.files.some((file) => file.path === required)) - throw new Error(`Package missing ${required}`); - if (packed.files.some((file) => /^(apps|src|test|scripts)\//.test(file.path))) - throw new Error('Package includes development files'); + ); +function smoke(command, prefix = [], cwd = directory) { + const help = jsonCommand(command, [...prefix, '--help'], cwd); + assert.equal(help.ok, true, 'CLI help failed'); + assert.ok( + help.data.commands.some(({ name }) => name === 'order'), + 'Order command missing', + ); + const schema = jsonCommand(command, [...prefix, 'schema', 'order'], cwd); + assert.equal(schema.ok, true, 'Order schema failed'); + assert.ok(schema.data.input.properties.coin, 'Order input schema missing'); +} + +try { + const registry = process.env.BLOXWAP_SMOKE_REGISTRY === '1'; + const sourceManifest = JSON.parse(readFileSync('package.json', 'utf8')); + const registrySpec = `${sourceManifest.name}@${sourceManifest.version}`; + const tarball = process.env.BLOXWAP_SMOKE_TARBALL + ? resolve(process.env.BLOXWAP_SMOKE_TARBALL) + : join( + directory, + jsonCommand( + 'npm', + [ + 'pack', + ...(registry ? [registrySpec] : []), + '--ignore-scripts', + '--json', + '--pack-destination', + directory, + ], + process.cwd(), + )[0].filename, + ); + execFileSync('tar', ['-xzf', tarball, '-C', directory]); + const packedRoot = join(directory, 'package'); + const manifest = JSON.parse( + readFileSync(join(packedRoot, 'package.json'), 'utf8'), + ); + assert.equal(manifest.name, '@bloxwap/agent-cli'); + assert.equal(manifest.bin['agent-cli'], 'dist/cli.js'); + for (const lifecycle of ['preinstall', 'install', 'postinstall']) + assert.equal( + manifest.scripts?.[lifecycle], + undefined, + `Unexpected ${lifecycle} script`, + ); + for (const required of [ + 'SKILL.md', + 'dist/cli.js', + 'policies/agent-trade-only.json', + 'README.md', + 'LICENSE', + 'CHANGELOG.md', + ]) + assert.ok( + statSync(join(packedRoot, required)).isFile(), + `Package missing ${required}`, + ); + const files = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + .trim() + .split('\n'); + assert.ok( + !files.some((file) => + /^package\/(apps|src|test|scripts|node_modules)\//.test(file), + ), + 'Package includes development files', + ); + const cli = readFileSync(join(packedRoot, 'dist/cli.js'), 'utf8'); + assert.ok( + cli.startsWith('#!/usr/bin/env node\n'), + 'CLI must have a Node shebang', + ); + if (process.platform !== 'win32') + assert.ok( + statSync(join(packedRoot, 'dist/cli.js')).mode & 0o111, + 'CLI must be executable', + ); + assert.ok( + readFileSync(join(packedRoot, 'SKILL.md'), 'utf8').includes( + ` version: "${manifest.version}"`, + ), + 'Packaged skill version differs', + ); + + const globalRoot = join(directory, 'global'); execFileSync( 'npm', [ 'install', '--global', + '--ignore-scripts', + '--no-audit', + '--no-fund', '--prefix', - join(directory, 'global'), - join(directory, packed.filename), + globalRoot, + registry ? registrySpec : tarball, ], - { stdio: 'pipe' }, + { cwd: directory, stdio: 'pipe', timeout: 180_000 }, ); const bin = process.platform === 'win32' - ? join(directory, 'global', 'agent-cli.cmd') - : join(directory, 'global', 'bin', 'agent-cli'); - const help = JSON.parse( - execFileSync(bin, ['--help'], { input: '', encoding: 'utf8' }), - ); - if ( - !help.ok || - !help.data.commands.some((command) => command.name === 'order') - ) - throw new Error('Global CLI smoke failed'); - const schema = JSON.parse( - execFileSync(bin, ['schema', 'order'], { input: '', encoding: 'utf8' }), - ); - if (!schema.ok) throw new Error('Schema smoke failed'); + ? join(globalRoot, 'agent-cli.cmd') + : join(globalRoot, 'bin', 'agent-cli'); + smoke(bin); + if (process.env.BLOXWAP_SMOKE_BUN === '1') { - const bun = JSON.parse( - execFileSync('bun', [resolve('dist/cli.js'), '--help'], { - input: '', - encoding: 'utf8', - }), + if (registry) { + // Run from outside any project to exercise bunx's public registry auto-install path. + smoke('bun', ['x', registrySpec]); + smoke('bun', ['x', '--bun', registrySpec]); + } + const fixture = join(directory, 'bun-consumer'); + mkdirSync(fixture); + writeFileSync( + join(fixture, 'package.json'), + JSON.stringify({ name: 'agent-cli-install-smoke', private: true }), + ); + execFileSync( + 'bun', + ['add', '--ignore-scripts', registry ? registrySpec : tarball], + { + cwd: fixture, + stdio: 'pipe', + timeout: 180_000, + }, ); - if (!bun.ok) throw new Error('Bun smoke failed'); + // Resolve the installed tarball through bunx, without falling back to source or the registry. + smoke('bun', ['x', '--no-install', '@bloxwap/agent-cli'], fixture); + smoke('bun', ['x', '--bun', '--no-install', '@bloxwap/agent-cli'], fixture); } - console.log('PASS: packed global install, CLI/schema and optional Bun smoke'); + console.log( + `PASS: @bloxwap/agent-cli@${manifest.version} tarball, script-free global install, help/schema${process.env.BLOXWAP_SMOKE_BUN === '1' ? ', installed bunx with Node and Bun' : ''}`, + ); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/packages/agent-cli/scripts/publication.mjs b/packages/agent-cli/scripts/publication.mjs new file mode 100644 index 0000000..e8bedef --- /dev/null +++ b/packages/agent-cli/scripts/publication.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export function releaseArtifact(directory) { + const root = resolve(directory); + const tarballs = readdirSync(root).filter((file) => file.endsWith('.tgz')); + assert.equal(tarballs.length, 1, 'Expected exactly one published tarball'); + const tarball = join(root, tarballs[0]); + const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`; + const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ); + return { tarball, integrity, spec: `${manifest.name}@${manifest.version}` }; +} + +export function readRegistryDist(spec, run = execFileSync) { + let output; + try { + output = run('npm', ['view', spec, 'dist', '--json'], { + encoding: 'utf8', + stdio: 'pipe', + timeout: 30_000, + }); + } catch (error) { + // Only an explicit registry 404 means unpublished. Authentication/network errors stop release. + let response; + try { + response = JSON.parse(String(error.stdout)); + } catch { + throw error; + } + if (response?.error?.code === 'E404') return undefined; + throw error; + } + return JSON.parse(output); +} + +export function verifyPublication(dist, integrity) { + assert.equal( + dist?.integrity, + integrity, + 'Published tarball differs from tested release artifact', + ); + assert.ok(dist?.attestations?.url, 'npm provenance attestation is missing'); +} diff --git a/packages/agent-cli/scripts/publish-artifact.mjs b/packages/agent-cli/scripts/publish-artifact.mjs new file mode 100644 index 0000000..d4b05d4 --- /dev/null +++ b/packages/agent-cli/scripts/publish-artifact.mjs @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; +import { + readRegistryDist, + releaseArtifact, + verifyPublication, +} from './publication.mjs'; + +const artifact = releaseArtifact(process.argv[2] ?? 'release'); +const published = readRegistryDist(artifact.spec); +if (published) { + verifyPublication(published, artifact.integrity); + console.log( + `Already published identical provenanced artifact: ${artifact.spec}; continuing release`, + ); +} else { + execFileSync( + 'npm', + [ + 'publish', + artifact.tarball, + '--provenance', + '--access', + 'public', + '--ignore-scripts', + ], + { stdio: 'inherit' }, + ); +} diff --git a/packages/agent-cli/scripts/release-checksums.mjs b/packages/agent-cli/scripts/release-checksums.mjs new file mode 100644 index 0000000..44b1fe2 --- /dev/null +++ b/packages/agent-cli/scripts/release-checksums.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const directory = resolve(process.argv[2] ?? 'release'); +const files = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name !== 'SHA256SUMS') + .map((entry) => entry.name) + .sort(); +for (const required of [ + 'agent-cli-linux-x64', + 'agent-cli-linux-arm64', + 'agent-cli-darwin-x64', + 'agent-cli-darwin-arm64', + 'SKILL.md', + 'CHANGELOG.md', + 'LICENSE', +]) + assert.ok(files.includes(required), `Missing release artifact: ${required}`); +assert.equal( + files.filter((file) => file.endsWith('.tgz')).length, + 1, + 'Expected exactly one npm tarball', +); +writeFileSync( + join(directory, 'SHA256SUMS'), + files + .map( + (file) => + `${createHash('sha256') + .update(readFileSync(join(directory, file))) + .digest('hex')} ${file}\n`, + ) + .join(''), +); +console.log(`Prepared SHA256SUMS for ${files.length} release artifacts`); diff --git a/packages/agent-cli/scripts/release-metadata.mjs b/packages/agent-cli/scripts/release-metadata.mjs new file mode 100644 index 0000000..650deda --- /dev/null +++ b/packages/agent-cli/scripts/release-metadata.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; + +export function verifyReleaseMetadata({ + manifest, + skill, + changelog, + tag, + changesets = [], + lockVersion, +}) { + assert.equal( + manifest.name, + '@bloxwap/agent-cli', + 'Unexpected release package', + ); + assert.match( + manifest.version, + /^\d+\.\d+\.\d+$/, + 'Only stable semver releases are supported', + ); + assert.notEqual(manifest.private, true, 'Release package must be public'); + assert.equal( + lockVersion, + manifest.version, + 'Lockfile version differs from CLI', + ); + assert.equal( + manifest.repository?.url, + 'git+https://github.com/bloxwap/agent-cli.git', + 'Repository metadata must identify the provenance source', + ); + assert.ok( + skill.includes(`metadata:\n version: "${manifest.version}"`), + 'Skill version differs from CLI', + ); + assert.ok( + changelog.split('\n').some((line) => line === `## ${manifest.version}`), + 'Package version is missing from CHANGELOG.md', + ); + if (tag !== undefined) { + assert.equal(tag, `v${manifest.version}`, 'Tag/version mismatch'); + assert.deepEqual( + changesets, + [], + 'Version the pending changesets before tagging a release', + ); + } + return { name: manifest.name, version: manifest.version, tag: tag ?? null }; +} diff --git a/packages/agent-cli/scripts/sync-lock-version.mjs b/packages/agent-cli/scripts/sync-lock-version.mjs new file mode 100644 index 0000000..ab559d8 --- /dev/null +++ b/packages/agent-cli/scripts/sync-lock-version.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); +const lockfile = new URL('../../../package-lock.json', import.meta.url); +const lock = JSON.parse(readFileSync(lockfile, 'utf8')); +const workspace = lock.packages['packages/agent-cli']; +assert.equal(workspace.name, manifest.name, 'Unexpected lockfile workspace'); +// Versioning changes no resolutions: preserve all dependencies and platform entries. +workspace.version = manifest.version; +writeFileSync(lockfile, `${JSON.stringify(lock, null, 2)}\n`); diff --git a/packages/agent-cli/scripts/testnet-lifecycle.mjs b/packages/agent-cli/scripts/testnet-lifecycle.mjs new file mode 100644 index 0000000..839db4d --- /dev/null +++ b/packages/agent-cli/scripts/testnet-lifecycle.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; + +export const requiredTestnetSecrets = [ + 'BLOXWAP_TEST_MASTER_KEY', + 'BLOXWAP_PRIVY_APP_ID', + 'BLOXWAP_PRIVY_APP_SECRET', +]; + +export function requireTestnetEnvironment(env) { + assert.equal(env.BLOXWAP_E2E, '1', 'Explicit BLOXWAP_E2E=1 is required'); + assert.ok(['true', '1'].includes(env.CI), 'Test master signing is CI-only'); + assert.equal(env.BLOXWAP_TEST_ACCOUNT_ONLY, '1', + 'A disposable company-owned test account must be explicitly acknowledged'); + const missing = requiredTestnetSecrets.filter((name) => !env[name]); + assert.equal(missing.length, 0, `Missing testnet secrets: ${missing.join(', ')}`); +} + +// No wallet key enters this driver. CI supplies a narrowly scoped external test +// signer; production approvals always stay in the user's external wallet. +export async function testnetLifecycle({ + command, info, sign, masterAddress, appId, startFollow, + checkpoint = () => {}, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now = Date.now, +}) { + const intent = `testnet-lifecycle-${now()}`; + async function stage(name, action) { + checkpoint({ name, status: 'running' }); + try { + const result = await action(); + checkpoint({ name, status: 'passed' }); + return result; + } catch (error) { + checkpoint({ name, status: 'failed' }); + throw error; + } + } + async function until(read, accept, message) { + const started = now(); + for (let attempt = 0; attempt < 12 && now() - started < 300_000; attempt++) { + const result = await read(); + if (accept(result)) return result; + await wait(Math.min(1000 * 2 ** attempt, 15_000)); + } + throw new Error(message); + } + await stage('test-account-preflight', async () => { + const state = await info('clearinghouseState', { user: masterAddress }); + assert.ok(Number(state.withdrawable) >= 30, + 'Disposable account requires at least 30 test USDC; no real funding is performed'); + assert.ok(state.assetPositions.every(({ position }) => Number(position.szi) === 0), + 'Disposable test account must have no positions'); + assert.deepEqual(await info('frontendOpenOrders', { user: masterAddress }), [], + 'Disposable test account must have no open orders'); + assert.deepEqual(await info('extraAgents', { user: masterAddress }), [], + 'Disposable test account must have no leftover agent approvals'); + }); + await stage('configure', async () => { + for (const [key, value] of [ + ['privyAppId', appId], + ['privyAppSecretRef', 'env:BLOXWAP_PRIVY_APP_SECRET'], + ['allowedMarkets', '["BTC"]'], + ['limits.maxNotional', '50'], + ['limits.maxPositionNotional', '50'], + ['limits.maxOpenOrders', '2'], + ]) await command('config', { args: ['set', key, value] }); + }); + await stage('init-agent-and-builder-approvals', async () => { + const pending = await command('init', { masterAddress, qr: false }); + assert.equal(pending.approved, false); + assert.ok(pending.approvals.agent.typedData); + let approved = await command('init', { + signature: await sign(pending.approvals.agent), qr: false, + }); + if (pending.approvals.builder) approved = await command('init', { + builderSignature: await sign(pending.approvals.builder), qr: false, + }); + assert.equal(approved.approved, true, 'Both exchange approvals must be visible'); + }); + await stage('mock-relay-funding-and-settlement', async () => { + const deposit = await command('fund', { + amount: '10', originChain: 421614, + originCurrency: '0x' + '0'.repeat(40), originDecimals: 6, qr: true, + }); + assert.equal(deposit.expectedReceived, '0', 'Only the explicit zero-value Relay fixture is allowed'); + assert.ok(deposit.qrAscii, 'Funding QR must be rendered'); + await until(() => command('status'), (status) => { + const current = status.deposits.find(({ id }) => id === deposit.depositId); + assert.ok(current, 'Quoted deposit must appear in status'); + assert.ok(!['failed', 'refunded'].includes(current.state), 'Funding failed'); + return current.state === 'settled'; + }, 'Funding did not settle within the polling budget'); + }); + await stage('runner-readiness', async () => { + const runner = await command('run', { detach: true }); + assert.equal(runner.network, 'testnet'); + assert.equal(runner.ready, true); + await command('config', { args: ['set', 'runnerFallback', 'false'] }); + const status = await command('status'); + assert.equal(status.runner.signingSession, 'ready', 'Signing must route through the runner'); + assert.equal(status.runner.wsState, 'connected'); + }); + const stopFollowing = await startFollow(); + let oid; + try { + await stage('place-and-reconcile-resting-order', async () => { + const market = await command('market', { coin: 'BTC' }); + const mid = Number(market.mid); + const decimals = Number(market.szDecimals); + assert.ok(Number.isFinite(mid) && mid > 0 && Number.isInteger(decimals)); + // Size ~20 test USD at a post-only bid well below the market; never an IOC. + const price = String(Math.floor(mid * 0.7)); + const size = (Math.ceil(20 / Number(price) * 10 ** decimals) / 10 ** decimals) + .toFixed(decimals); + const cloid = '0x' + now().toString(16).padStart(32, '0'); + const placed = await command('order', { + coin: 'BTC', side: 'buy', size, price, tif: 'Alo', cloid, intentId: intent, + }); + oid = placed.result.response.data.statuses[0].resting?.oid; + assert.ok(oid, 'Expected a resting post-only test order, not a fill'); + await until(() => command('orders'), + (orders) => orders.some((order) => Number(order.oid) === oid && order.cloid === cloid), + 'Accepted order not visible on exchange'); + }); + await stage('modify-and-reconcile', async () => { + const [original] = (await command('orders')).filter((order) => Number(order.oid) === oid); + assert.ok(original, 'Order disappeared before modification'); + const price = String(Math.floor(Number(original.limitPx) * 0.98)); + await command('modify', { modifications: [{ oid, price }], intentId: intent }); + const orders = await until(() => command('orders'), + (orders) => orders.some((order) => Number(order.limitPx) === Number(price)), + 'Modified price not visible on exchange'); + oid = Number(orders.find((order) => Number(order.limitPx) === Number(price)).oid); + }); + await stage('cancel-and-reconcile', async () => { + await command('cancel', { oid, intentId: intent }); + await until(() => command('orders'), (orders) => orders.length === 0, + 'Cancellation not visible on exchange'); + }); + await stage('websocket-events-and-replay', async () => { + const events = await until(() => command('events', { since: 0 }), + (events) => events.some((event) => event.type === 'order_update') && + events.some((event) => event.type === 'runner_state'), + 'No real order-update and runner events observed'); + assert.ok(events.every((event, i) => !i || event.seq > events[i - 1].seq)); + const cursor = events[Math.floor(events.length / 2)].seq; + const replay = await command('events', { since: cursor }); + assert.deepEqual(replay.filter((event) => event.seq <= events.at(-1).seq), + events.filter((event) => event.seq > cursor)); + }); + await stage('rotate-and-revoke-old-agent', async () => { + const pending = await command('rotate', { intentId: intent }); + const swapped = await command('rotate', { + signature: await sign(pending.approval), intentId: intent, + }); + assert.equal(swapped.swapped, true); + assert.notEqual(swapped.newAgent, swapped.oldAgent); + const revoked = await command('rotate', { + revokeSignature: await sign(swapped.revokeApproval), intentId: intent, + }); + assert.equal(revoked.oldRevoked, true); + await until(() => info('extraAgents', { user: masterAddress }), + (agents) => agents.some((agent) => agent.address.toLowerCase() === swapped.newAgent.toLowerCase()) && + !agents.some((agent) => agent.address.toLowerCase() === swapped.oldAgent.toLowerCase()), + 'Rotation not confirmed on exchange'); + assert.equal((await command('status')).runner.signingSession, 'ready'); + }); + await stage('revoke-and-confirm-exchange-state', async () => { + const pending = await command('revoke', { cancelOrders: true, intentId: intent }); + assert.equal(pending.locallyRevoked, true); + const revoked = await command('revoke', { + signature: await sign(pending.approval), intentId: intent, + }); + assert.equal(revoked.exchangeRevoked, true); + await until(() => command('status'), (status) => + status.agent.revoked && !status.agent.approved && + status.runner.signingSession === 'unavailable', + 'Final revocation not visible locally and on exchange'); + assert.deepEqual(await command('orders'), []); + }); + await stage('audit-integrity-and-intent-linkage', async () => { + // The audit command verifies the entire hash chain before filtering. + const entries = await command('audit', { intent }); + for (const action of ['order', 'modify', 'cancel', 'rotate', 'revoke']) { + assert.ok(entries.some((entry) => entry.stage === 'intent' && entry.data.action === action), + `Missing ${action} audit intent`); + assert.ok(entries.some((entry) => entry.stage === 'response' && entry.data.action === action), + `Missing ${action} audit response`); + } + assert.ok(entries.some((entry) => entry.stage === 'signature'), 'Missing signing audit evidence'); + }); + } finally { + await stopFollowing(); + } +} diff --git a/packages/agent-cli/scripts/verify-publication.mjs b/packages/agent-cli/scripts/verify-publication.mjs new file mode 100644 index 0000000..a3e0598 --- /dev/null +++ b/packages/agent-cli/scripts/verify-publication.mjs @@ -0,0 +1,22 @@ +import { + readRegistryDist, + releaseArtifact, + verifyPublication, +} from './publication.mjs'; + +const artifact = releaseArtifact(process.argv[2] ?? 'release'); +let failure; +for (let attempt = 0; attempt < 6; attempt++) { + try { + verifyPublication(readRegistryDist(artifact.spec), artifact.integrity); + console.log( + `PASS: ${artifact.spec} registry integrity and provenance attestation`, + ); + failure = undefined; + break; + } catch (error) { + failure = error; + if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 5_000)); + } +} +if (failure) throw failure; diff --git a/packages/agent-cli/scripts/verify-release.mjs b/packages/agent-cli/scripts/verify-release.mjs new file mode 100644 index 0000000..d6434ea --- /dev/null +++ b/packages/agent-cli/scripts/verify-release.mjs @@ -0,0 +1,41 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { verifyReleaseMetadata } from './release-metadata.mjs'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const root = resolve(packageRoot, '../..'); +const tag = process.env.GITHUB_REF?.startsWith('refs/tags/') + ? process.env.GITHUB_REF.slice('refs/tags/'.length) + : undefined; +const release = verifyReleaseMetadata({ + manifest: JSON.parse( + readFileSync(resolve(packageRoot, 'package.json'), 'utf8'), + ), + lockVersion: JSON.parse( + readFileSync(resolve(root, 'package-lock.json'), 'utf8'), + ).packages['packages/agent-cli'].version, + skill: readFileSync(resolve(packageRoot, 'SKILL.md'), 'utf8'), + changelog: readFileSync(resolve(packageRoot, 'CHANGELOG.md'), 'utf8'), + tag, + changesets: readdirSync(resolve(root, '.changeset')) + .filter((file) => file.endsWith('.md') && file !== 'README.md') + .sort(), +}); +if (tag) { + // fetch-depth: 0 must expose origin/main. Tags outside reviewed main cannot publish. + execFileSync('git', ['merge-base', '--is-ancestor', 'HEAD', 'origin/main'], { + cwd: root, + }); + execFileSync('git', ['diff', '--exit-code', 'HEAD'], { + cwd: root, + stdio: 'inherit', + }); +} +console.log( + JSON.stringify({ + ...release, + mode: tag ? 'tagged-release' : 'candidate-only', + }), +); diff --git a/packages/agent-cli/src/commands/init.ts b/packages/agent-cli/src/commands/init.ts index eddf838..fb5c83e 100644 --- a/packages/agent-cli/src/commands/init.ts +++ b/packages/agent-cli/src/commands/init.ts @@ -34,9 +34,27 @@ register('init', { execute: async (input, context) => { rejectMasterKey(input); const config = await loadConfig(context.configPath); + if ( + context.network && + context.network !== config.network && + (config.privyWalletId || config.approved) + ) + throw new AgentCliError( + 'CONFIG_INVALID', + 'Initialized wallet network differs; use a separate config for another network', + ); + if ( + config.privyWalletId && + config.masterAddress && + input.masterAddress && + (input.masterAddress as string).toLowerCase() !== + config.masterAddress.toLowerCase() + ) + throw new AgentCliError( + 'CONFIG_INVALID', + 'Initialized master address differs; use a separate config for another account', + ); if (context.network) config.network = context.network; - if (config.approved && !config.revoked) - return { ...config, expiry: config.agentExpiry }; config.masterAddress = (input.masterAddress as string) ?? config.masterAddress; if (!config.masterAddress) @@ -54,6 +72,8 @@ register('init', { const client = await privyClient(config); const policyId = await applyPolicy(config, client, context.configPath); await createAgent(config, client, context.configPath, policyId); + if (config.approved && !config.revoked) + return { ...config, expiry: config.agentExpiry }; const path = join( stateDir(), `approval-${config.network}-${config.agentAddress}.json`, diff --git a/packages/agent-cli/src/core/skill-content.ts b/packages/agent-cli/src/core/skill-content.ts index 8fd2c33..47d8e02 100644 --- a/packages/agent-cli/src/core/skill-content.ts +++ b/packages/agent-cli/src/core/skill-content.ts @@ -1,3 +1,3 @@ // Generated from SKILL.md and package.json. export const cliVersion="0.1.0"; -export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.1.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Onboarding sequence\n\n1. Configure Privy app ID and secret reference, e.g. `config set privyAppId APP`\n and `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets\n the environment secret outside the conversation. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and pre-funded test collateral.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"1\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.1.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders. Privy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`.\n"; +export const skillContent="---\nname: agent-cli\ndescription: Operate the agent-cli Hyperliquid trading CLI, including managed-agent onboarding, funding, orders, event monitoring, rotation, and normalized error recovery.\nmetadata:\n version: \"0.1.0\"\n---\n\nUse `agent-cli` for the user's authorized Hyperliquid account operations. Obtain\ncommand schemas with `agent-cli schema COMMAND`; arguments can be flags or\n`--json '{...}'`, or a JSON object on stdin. Ordinary commands print exactly one\nJSON object: `{ok:true,data}` or `{ok:false,code,message,retryable,details}`.\nLogs/QR appear on stderr. `events --follow` and `logs` explicitly stream JSON lines.\nUse `--network testnet` during testing; never silently change to mainnet.\n\n## Onboarding sequence\n\n1. The user creates and owns their own Privy account and app. Configure that\n app ID and a local secret reference, e.g. `config set privyAppId APP` and\n `config set privyAppSecretRef env:BLOXWAP_PRIVY_APP_SECRET`. The user sets the\n environment secret outside the conversation on their own machine or runner.\n Bloxwap does not host the app, receive credentials, or custody user keys.\n Privy manages the trading-agent key in the user's app; master-wallet approval\n always remains in the user's external wallet. The default Bloxwap builder is\n `0x71b09A08257078A4d642F7Dd7315e656c837329a`, with `f=10` (1 bp = 0.01%).\n Override with `config set builder.address` / `config set builder.fee` or\n `BLOXWAP_BUILDER_ADDRESS` / `BLOXWAP_BUILDER_FEE`.\n2. Run `init --master-address 0x... --network testnet`. Show the returned approval\n payload/deeplink/QR. The user signs in their master wallet; submit only the\n resulting signature with `init --signature 0x...`. Submit builder approval\n separately with `init --builder-signature 0x...` for the configured 0.01% default. Continue only after `approved:true`.\n3. Run `fund --amount 10 --origin-chain 42161`. Show `qrAscii`/deposit address,\n origin chain/token, minimum, fees, expected received amount and expiry. On\n testnet, do not treat the default Arbitrum mainnet chain/token as a test route:\n use an explicitly supported test chain/token and `originDecimals`, or the\n credential-gated CI harness with mocked Relay and pre-funded test collateral.\n If no test route is known, stop funding and report the missing route. The user\n transfers the quoted origin token. If `signatureSteps` require signatures,\n show the exact payload and submit each via `deposit` in returned order.\n4. Poll `status` until each active deposit is `settled`, for at most 12 polls\n or 5 minutes, whichever comes first. Start with 1-second delays, double to a\n 15-second cap, and stop with an explicit pending report if the budget expires.\n Settlement requires Relay success and Hyperliquid balance receipt. Stop on\n failed/refunded deposits and explain their status. Do not place orders while\n any deposit is awaiting funds or pending.\n5. Run `run --detach`, then monitor `events --follow --since SEQ`. Persist the\n last handled sequence; replay after reconnect. An unfilled or rejected order\n remains incomplete: do not claim the requested trade succeeded. A fill updates the trading\n plan, an error follows the catalog below, and expiry warnings prompt rotation\n using external master-wallet approval.\n\n## Completion and approval boundaries\n\nRead this skill and query schemas before acting. Keep a progress record with the\nnetwork, current stage, deposit IDs, last event sequence and order cloid/oid;\nrecord public IDs only. A missing user-owned Privy app, wallet signature, funding\nroute or settled balance is a blocked stage. Report the exact next action without\nrequesting any secret in chat or moving to the next stage. Repeat `init` to resume\npersisted approvals rather than creating another agent. An `ok:true` transport\nenvelope does not imply exchange acceptance: inspect `data.result` and reconcile\n`orders`/`events` after uncertain mutations. Never retry an order with a new cloid\nuntil the original intent has been reconciled.\n\nFor rotation, verify `swapped:true` before asking the external wallet to sign old\nagent revocation. Require `oldRevoked:true` before claiming rotation complete.\n`revoke` immediately disables local signing; only `exchangeRevoked:true` confirms\nthat the signed exchange revocation was submitted. Inspect `status` to verify the\nagent is absent from exchange approvals. Cancel outstanding orders before final\nrevocation, and report any cancellation or cleanup failure explicitly.\n\n## Commands and example envelopes\n\nExamples show representative `data`, not live balances or service promises.\nFull inputs/outputs are discoverable with `schema`; amounts/prices are strings.\n\n| Input | Example output |\n| --- | --- |\n| `--help` | `{\"ok\":true,\"data\":{\"commands\":[{\"name\":\"status\",\"description\":\"Read account status\"}],\"globalFlags\":[\"--json\",\"--config\",\"--network\",\"--pretty\"]}}` |\n| `schema order` | `{\"ok\":true,\"data\":{\"input\":{\"type\":\"object\"},\"output\":{\"oneOf\":[]}}}` (actual schemas include properties) |\n| `config get network` | `{\"ok\":true,\"data\":\"testnet\"}` |\n| `config set limits.maxLeverage 5` | `{\"ok\":true,\"data\":{\"version\":1,\"limits\":{\"maxLeverage\":5}}}` |\n| `config validate` | `{\"ok\":true,\"data\":{\"valid\":true,\"version\":1}}` |\n| `init --master-address 0x...` | `{\"ok\":true,\"data\":{\"approved\":false,\"requiresSignature\":true,\"approvals\":{\"agent\":{\"typedData\":{}}},\"qrAscii\":\"...\"}}` |\n| `fund --amount 10 --no-qr` | `{\"ok\":true,\"data\":{\"depositId\":\"...\",\"depositAddress\":\"0x...\",\"expectedReceived\":\"100000000\",\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"state\":\"awaiting_funds\",\"qrAscii\":null}}` |\n| `deposit --deposit-id ID` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[{\"id\":\"step:0\",\"requiresSignature\":true,\"payload\":{}}]}}` |\n| `deposit --deposit-id ID --step-id step:0 --signature 0x...` | `{\"ok\":true,\"data\":{\"depositId\":\"ID\",\"steps\":[]}}` |\n| `status` | `{\"ok\":true,\"data\":{\"balance\":{\"accountValue\":\"100\"},\"agent\":{\"approved\":true,\"daysRemaining\":\"179\"},\"deposits\":[],\"policy\":{\"id\":\"...\",\"version\":\"2\"},\"runner\":{\"pid\":\"123\"}}}` |\n| `balance` | `{\"ok\":true,\"data\":{\"accountValue\":\"100\",\"accountValueUnits\":\"10000000000\",\"withdrawable\":\"96\",\"marginUsed\":\"4\"}}` |\n| `market --coin BTC` | `{\"ok\":true,\"data\":{\"coin\":\"BTC\",\"asset\":\"0\",\"szDecimals\":\"5\",\"maxLeverage\":\"40\",\"mid\":\"20000\"}}` |\n| `positions` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"size\":\"0.001\",\"entryPrice\":\"20000\",\"leverage\":\"5\",\"marginMode\":\"cross\"}]}` |\n| `orders` | `{\"ok\":true,\"data\":[{\"coin\":\"BTC\",\"oid\":\"123\",\"sz\":\"0.001\",\"limitPx\":\"20000\",\"side\":\"B\"}]}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"buy\",\"size\":\"0.001\",\"price\":\"20000\",\"tif\":\"Gtc\",\"cloid\":\"0x11111111111111111111111111111111\"}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"resting\":{\"oid\":123}}]}}},\"builderFeeRate\":\"0.01%\",\"builderAddress\":\"0x71b09A08257078A4d642F7Dd7315e656c837329a\",\"cloid\":\"0x...\"}}` |\n| `order --json '{\"coin\":\"BTC\",\"side\":\"sell\",\"size\":\"0.001\",\"type\":\"market\",\"slippage\":0.01,\"reduceOnly\":true}'` | `{\"ok\":true,\"data\":{\"result\":{\"status\":\"ok\",\"response\":{\"data\":{\"statuses\":[{\"filled\":{\"oid\":123}}]}}}}}` |\n| `cancel --oid 123` or `cancel --all` | `{\"ok\":true,\"data\":{\"cancelled\":[\"123\"],\"result\":{\"status\":\"ok\"}}}` |\n| `modify --json '{\"modifications\":[{\"oid\":123,\"price\":\"20100\",\"size\":\"0.001\"}]}'` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `leverage --coin BTC --leverage 5 --mode isolated` | `{\"ok\":true,\"data\":{\"status\":\"ok\"}}` |\n| `run --detach` | `{\"ok\":true,\"data\":{\"pid\":123,\"startedAt\":1700000000000,\"network\":\"testnet\"}}` |\n| `events --since 12 --types '[\"fill\"]'` | `{\"ok\":true,\"data\":[{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}]}` |\n| `events --follow` | `{\"seq\":13,\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"type\":\"fill\",\"data\":{\"tid\":1}}` (one line per event) |\n| `logs --follow --level warn` | `{\"level\":\"warn\",\"requestId\":\"...\",\"message\":\"Risk rejected\",\"time\":\"...\"}` (JSON lines) |\n| `rotate` then `rotate --signature 0x...` | `{\"ok\":true,\"data\":{\"oldAgent\":\"0x...\",\"newAgent\":\"0x...\",\"swapped\":true,\"oldRevoked\":false,\"revokeApproval\":{}}}` |\n| `rotate --revoke-signature 0x...` | `{\"ok\":true,\"data\":{\"swapped\":true,\"oldRevoked\":true}}` |\n| `revoke --cancel-orders` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":false,\"requiresSignature\":true,\"approval\":{}}}` |\n| `revoke --signature 0x...` | `{\"ok\":true,\"data\":{\"locallyRevoked\":true,\"exchangeRevoked\":true}}` |\n| `audit --intent ID` | `{\"ok\":true,\"data\":[{\"intentId\":\"ID\",\"stage\":\"decision\",\"data\":{\"allowed\":true},\"previousHash\":\"...\",\"hash\":\"...\"}]}` |\n| `skill install --target codex` | `{\"ok\":true,\"data\":{\"target\":\"codex\",\"path\":\".../agent-cli/SKILL.md\",\"version\":\"0.1.0\"}}` |\n\nBefore an order, query `market --coin BTC` for `mid`, `szDecimals`, asset and\nmarket leverage ceiling. Compute size/notional with decimal strings/bigints,\nusing the user-approved limit or the bounded IOC execution price. A valid schema\nalone does not establish that an order is tradable or within the budget.\n\nUse `--intent-id ID --intent \"reason\"` on trading actions to connect the user's\nreasoning to the audit. Keep cloid stable when retrying a known order intent.\nFor TP/SL use `type:\"tp\"|\"sl\"` and `triggerPrice` with size/price/reduceOnly;\nmarket orders use an explicit slippage-bounded IOC, not an unbounded market order.\n\n## Error recovery\n\n| Code | Action |\n| --- | --- |\n| RATE_LIMIT, NETWORK_ERROR | At most 3 read retries with exponential backoff; for uncertain mutations, reconcile order state/cloid before retrying. Stop and report unresolved results |\n| NONCE_COLLISION | Retry using a newly issued nonce; inspect a stale lock if persistent |\n| DEPOSIT_PENDING | Poll status until settled; stop on terminal funding failure |\n| RUNNER_NOT_RUNNING | Start runner or use configured direct fallback |\n| INSUFFICIENT_MARGIN | Reduce exposure or request authorized funding |\n| POLICY_REJECTED | Stop and revise intent within limits; do not bypass policy |\n| AGENT_EXPIRED | Rotate with master-wallet approval |\n| AGENT_NOT_APPROVED | Complete init/approval or explain revocation |\n| INVALID_SIZE, INVALID_PRICE | Correct input using market precision |\n| UNKNOWN_MARKET | Refresh metadata; use an allowed market |\n| ORDER_NOT_FOUND | Refresh orders before changing intent |\n| CONFIG_INVALID | Correct configuration/input; never insert secrets |\n| UPSTREAM_ERROR, INTERNAL | Stop, inspect redacted details/logs and report failure |\n\n## Gotchas\n\nHyperCore perps USDC uses **8 decimals**; EVM USDC uses **6**. Preserve strings and\nbigints. Some deposits require **two distinct signatures**; resume stored steps\nrather than restarting the route. Agent expiry is at most **180 days**. Never\nask for, paste, log or import a master private key or seed. The managed agent is\ntrade-only; local limits include markets, size/notional, leverage, daily loss and\nopen orders. Privy sees an L1 action hash, so it cannot independently inspect\nnotional/action contents; Hyperliquid agent permissions and local checks enforce\nthose boundaries. State is always `~/.bloxwap`, env prefix `BLOXWAP_`.\n\n\n## Verification boundaries\n\nOffline skill tests validate the documented command examples against CLI schemas.\nMocked lifecycle tests exercise resumable approvals, pending funding, events\nand failure handling. These checks do **not** prove a live agent or\nexchange run. The nightly testnet lifecycle uses only a company-owned disposable\nCI master account and separate test Privy app, real Hyperliquid testnet and Privy,\nand an explicitly mocked Relay route backed by already funded test collateral.\nIt never transfers real funds. Missing CI credentials fail the job; a skipped or\nmocked test must never be reported as live validation. Record the workflow run,\ncommit, lifecycle report, event and audit artifacts before claiming a testnet pass.\nSee `docs/agent-workflow.md` and `docs/testnet.md` in the repository for setup.\n"; diff --git a/packages/agent-cli/src/wallet/policy.ts b/packages/agent-cli/src/wallet/policy.ts index 7940383..aabc8ec 100644 --- a/packages/agent-cli/src/wallet/policy.ts +++ b/packages/agent-cli/src/wallet/policy.ts @@ -1,9 +1,11 @@ import type { PrivyClient } from '@privy-io/node'; +import { isDeepStrictEqual } from 'node:util'; import template from '../../policies/agent-trade-only.json' with { type: 'json' }; import { AgentCliError } from '../errors/index.js'; import { mapPrivyError } from '../errors/mappers.js'; import { saveConfig, type Config } from '../core/config.js'; -export const policyVersion = 1; +import type { Signer } from '../core/hlclient.js'; +export const policyVersion = 2; const allowed = new Set([ 'order', 'cancel', @@ -23,24 +25,78 @@ export function policyFor(network: Config['network']) { policy.rules[0].conditions[2].value = network === 'mainnet' ? 'a' : 'b'; return policy; } +// L1 action contents are opaque here: this validates the envelope, not the hash +// preimage. The command dispatch allowlist and Hyperliquid agent permissions are +// separate controls; neither this check nor Privy can decode connectionId. +export function assertAgentTypedData( + data: Parameters[0], + network: Config['network'], +) { + const fields = data.types.Agent; + if ( + data.domain?.name !== 'Exchange' || + data.domain.version !== '1' || + String(data.domain.chainId) !== '1337' || + data.domain.verifyingContract?.toLowerCase() !== + '0x0000000000000000000000000000000000000000' || + Object.keys(data.domain).some( + (key) => + !['name', 'version', 'chainId', 'verifyingContract'].includes(key), + ) || + data.primaryType !== 'Agent' || + !fields || + fields.length !== 2 || + fields[0].name !== 'source' || + fields[0].type !== 'string' || + fields[1].name !== 'connectionId' || + fields[1].type !== 'bytes32' || + data.message.source !== (network === 'mainnet' ? 'a' : 'b') || + typeof data.message.connectionId !== 'string' || + !/^0x[0-9a-fA-F]{64}$/.test(data.message.connectionId) + ) + throw new AgentCliError( + 'POLICY_REJECTED', + 'Agent signer only accepts the configured Hyperliquid L1 Agent domain', + ); +} +function policyMatches( + actual: Awaited['get']>>, + expected: ReturnType, +) { + return ( + actual.version === expected.version && + actual.chain_type === expected.chain_type && + isDeepStrictEqual( + actual.rules.map(({ name, method, action, conditions }) => ({ + name, + method, + action, + conditions, + })), + expected.rules, + ) + ); +} export async function applyPolicy( config: Config, client: PrivyClient, path?: string, ) { - if (config.policyId) return config.policyId; try { + const expected = policyFor(config.network); + if (config.policyId && config.policyVersion === policyVersion) { + const existing = await client.policies().get(config.policyId); + if (policyMatches(existing, expected)) return existing.id; + } const policy = await client .policies() .create( - policyFor(config.network) as Parameters< + expected as Parameters< ReturnType['create'] >[0], ); - if (config.privyWalletId) - await client - .wallets() - .update(config.privyWalletId, { policy_ids: [policy.id] }); + // createAgent attaches this policy with the wallet owner's authorization. + // Persist first so a failed attach can be resumed with this same policy. config.policyId = policy.id; config.policyVersion = policyVersion; await saveConfig(config, path); diff --git a/packages/agent-cli/src/wallet/privy.ts b/packages/agent-cli/src/wallet/privy.ts index c9a8535..1a54266 100644 --- a/packages/agent-cli/src/wallet/privy.ts +++ b/packages/agent-cli/src/wallet/privy.ts @@ -1,6 +1,11 @@ import { signatureAudit } from '../core/audit.js'; import { PrivyClient, generateP256KeyPair } from '@privy-io/node'; -import { randomUUID, createHash } from 'node:crypto'; +import { + randomUUID, + createHash, + createPrivateKey, + createPublicKey, +} from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { @@ -12,6 +17,7 @@ import { import type { Signer } from '../core/hlclient.js'; import { AgentCliError } from '../errors/index.js'; import { mapPrivyError } from '../errors/mappers.js'; +import { assertAgentTypedData } from './policy.js'; export async function resolveSecret( ref: string | undefined, env = process.env, @@ -36,7 +42,10 @@ export async function resolveSecret( } export async function privyClient(config: Config) { if (!config.privyAppId) - throw new AgentCliError('CONFIG_INVALID', 'Set privyAppId'); + throw new AgentCliError( + 'CONFIG_INVALID', + 'Create your own Privy account/app and set privyAppId; use only your app credentials locally', + ); return new PrivyClient({ appId: config.privyAppId, appSecret: await resolveSecret( @@ -44,6 +53,45 @@ export async function privyClient(config: Config) { ), }); } +async function prepareAuthorizationKey(config: Config, path?: string) { + if (config.authorizationKeyRef) { + const privateKey = await resolveSecret(config.authorizationKeyRef); + try { + const key = createPrivateKey({ + key: Buffer.from( + privateKey.replace(/^(wallet-auth:|wallet-api:)/, ''), + 'base64', + ), + format: 'der', + type: 'pkcs8', + }); + if ( + key.asymmetricKeyType !== 'ec' || + key.asymmetricKeyDetails?.namedCurve !== 'prime256v1' + ) + throw new Error('Expected P-256'); + return { + privateKey, + publicKey: createPublicKey(key) + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + }; + } catch { + throw new AgentCliError( + 'CONFIG_INVALID', + 'Expected a P-256 authorization key', + ); + } + } + const pair = await generateP256KeyPair(); + const keyPath = join(stateDir(), `authorization-${randomUUID()}.key`); + await atomicWrite(keyPath, pair.privateKey); + config.authorizationKeyRef = `file:${keyPath}`; + // Persist the owner reference before Privy can assign ownership. A lost API + // response or later config-write failure must not strand the wallet owner. + await saveConfig(config, path); + return pair; +} export async function createAgent( config: Config, client: PrivyClient, @@ -51,35 +99,58 @@ export async function createAgent( policyId?: string, ) { try { + if (!policyId) + throw new AgentCliError('CONFIG_INVALID', 'Apply an agent policy first'); if (config.privyWalletId) { - const wallet = await client.wallets().get(config.privyWalletId); - if (!config.authorizationKeyRef) { - const pair = await generateP256KeyPair(); - const keyPath = join(stateDir(), `authorization-${randomUUID()}.key`); - await atomicWrite(keyPath, pair.privateKey); - await client - .wallets() - .update(wallet.id, { owner: { public_key: pair.publicKey } }); - config.authorizationKeyRef = `file:${keyPath}`; + let wallet = await client.wallets().get(config.privyWalletId); + if ( + wallet.chain_type !== 'ethereum' || + wallet.address.toLowerCase() === config.masterAddress?.toLowerCase() + ) + throw new AgentCliError( + 'CONFIG_INVALID', + 'Use a separate Ethereum agent wallet in your own Privy app, never your master wallet', + ); + if (wallet.owner_id) { + if (!config.authorizationKeyRef) + throw new AgentCliError( + 'CONFIG_INVALID', + 'Set authorizationKeyRef to your existing wallet owner key; the CLI will not replace an existing owner', + ); + if (wallet.policy_ids.length !== 1 || wallet.policy_ids[0] !== policyId) + wallet = await client.wallets().update(wallet.id, { + policy_ids: [policyId], + authorization_context: { + authorization_private_keys: [ + await resolveSecret(config.authorizationKeyRef), + ], + }, + }); + } else { + const pair = await prepareAuthorizationKey(config, path); + wallet = await client.wallets().update(wallet.id, { + owner: { public_key: pair.publicKey }, + policy_ids: [policyId], + }); } + if (wallet.policy_ids.length !== 1 || wallet.policy_ids[0] !== policyId) + throw new AgentCliError( + 'POLICY_REJECTED', + 'Privy did not confirm the required agent policy attachment', + ); config.agentAddress = wallet.address; await saveConfig(config, path); return wallet; } - const pair = await generateP256KeyPair(); - const keyPath = join(stateDir(), `authorization-${randomUUID()}.key`); - await atomicWrite(keyPath, pair.privateKey); - const wallet = await client - .wallets() - .create({ - chain_type: 'ethereum', - owner: { public_key: pair.publicKey }, - policy_ids: policyId ? [policyId] : [], - idempotency_key: `agent-${createHash('sha256').update(pair.publicKey).digest('hex')}`, - }); + const pair = await prepareAuthorizationKey(config, path); + const wallet = await client.wallets().create({ + chain_type: 'ethereum', + owner: { public_key: pair.publicKey }, + policy_ids: [policyId], + idempotency_key: `agent-${createHash('sha256').update(pair.publicKey).digest('hex')}`, + }); config.privyWalletId = wallet.id; config.agentAddress = wallet.address; - config.authorizationKeyRef = `file:${keyPath}`; await saveConfig(config, path); return wallet; } catch (error) { @@ -99,6 +170,7 @@ export async function privySigner( return { address: config.agentAddress as `0x${string}`, signTypedData: async (data) => { + assertAgentTypedData(data, config.network); try { const signed = await client .wallets() diff --git a/packages/agent-cli/test/e2e-harness.test.js b/packages/agent-cli/test/e2e-harness.test.js new file mode 100644 index 0000000..308206a --- /dev/null +++ b/packages/agent-cli/test/e2e-harness.test.js @@ -0,0 +1,144 @@ +import { expect, test, vi } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { requireTestnetEnvironment, testnetLifecycle } from '../scripts/testnet-lifecycle.mjs'; + +function fixture(overrides = {}) { + let orders = []; + let rotated = false; + let revoked = false; + let started = false; + let polls = 0; + const stop = vi.fn(async () => {}); + const checkpoint = vi.fn(); + const approvals = { agent: { kind: 'agent', typedData: {} }, builder: { kind: 'builder', typedData: {} } }; + const command = vi.fn(async (name, input = {}) => { + if (name === 'config') return {}; + if (name === 'init') return { + approved: !!input.builderSignature, approvals, + }; + if (name === 'fund') return { depositId: 'fixture-deposit', expectedReceived: '0', qrAscii: 'QR' }; + if (name === 'status') return { + deposits: [{ id: 'fixture-deposit', state: ++polls > 1 ? 'settled' : 'pending' }], + runner: started ? { signingSession: revoked ? 'unavailable' : 'ready', wsState: 'connected' } : null, + agent: { approved: !revoked, revoked }, + }; + if (name === 'run') { + expect(polls).toBeGreaterThan(1); + started = true; + return { ready: true, network: 'testnet' }; + } + if (name === 'market') return { mid: '100000', szDecimals: '5' }; + if (name === 'order') { + orders = [{ oid: '1', cloid: input.cloid, limitPx: input.price }]; + return { result: { response: { data: { statuses: [{ resting: { oid: 1 } }] } } } }; + } + if (name === 'orders') return orders; + if (name === 'modify') { + expect(input.modifications).toHaveLength(1); + orders = [{ ...orders[0], oid: '2', limitPx: input.modifications[0].price }]; + return {}; + } + if (name === 'cancel') { expect(input.oid).toBe(2); orders = []; return {}; } + if (name === 'events') return [ + { seq: 1, type: 'runner_state' }, { seq: 2, type: 'order_update' }, + ].filter((event) => event.seq > (input.since ?? 0)); + if (name === 'rotate') { + if (input.signature) rotated = true; + return { + oldAgent: '0xold', newAgent: '0xnew', swapped: rotated, + oldRevoked: !!input.revokeSignature, + approval: { kind: 'rotate' }, revokeApproval: { kind: 'old-revoke' }, + }; + } + if (name === 'revoke') { + revoked = true; + return { locallyRevoked: true, exchangeRevoked: !!input.signature, approval: { kind: 'final-revoke' } }; + } + if (name === 'audit') return [ + ...['order', 'modify', 'cancel', 'rotate', 'revoke'].flatMap((action) => + ['intent', 'response'].map((stage) => ({ stage, data: { action } }))), + { stage: 'signature' }, + ]; + throw new Error(`Unexpected fixture command: ${name}`); + }); + return { + command, checkpoint, stop, masterAddress: '0xtest', appId: 'test-app', + sign: vi.fn(async (approval) => `${approval.kind}-signature`), + info: vi.fn(async (type) => { + if (type === 'clearinghouseState') return { withdrawable: '30', assetPositions: [] }; + if (type === 'frontendOpenOrders') return []; + if (type === 'extraAgents') return rotated && !revoked ? [{ address: '0xnew' }] : []; + throw new Error(`Unexpected fixture info: ${type}`); + }), + startFollow: vi.fn(async () => stop), wait: vi.fn(async () => {}), now: () => 123456789, + ...overrides, + }; +} + +test('offline lifecycle driver verifies both approvals, settlement, reconciled writes, replay and revocation', async () => { + const f = fixture(); + await testnetLifecycle(f); + expect(f.sign.mock.calls.map(([approval]) => approval.kind)).toEqual([ + 'agent', 'builder', 'rotate', 'old-revoke', 'final-revoke', + ]); + expect(f.command.mock.calls.find(([name]) => name === 'order')[1]).toMatchObject({ tif: 'Alo' }); + expect(f.wait).toHaveBeenCalledWith(1000); + expect(f.checkpoint.mock.calls.filter(([stage]) => stage.status === 'passed')).toHaveLength(12); + expect(f.stop).toHaveBeenCalledOnce(); +}); + +test('unsettled funding stops the workflow within the documented polling budget', async () => { + const f = fixture(); + const command = f.command; + f.command = vi.fn(async (name, input) => name === 'status' + ? { deposits: [{ id: 'fixture-deposit', state: 'pending' }] } + : command(name, input)); + await expect(testnetLifecycle(f)).rejects.toThrow('polling budget'); + expect(f.command.mock.calls.filter(([name]) => name === 'status')).toHaveLength(12); + expect(f.command.mock.calls.some(([name]) => ['run', 'order'].includes(name))).toBe(false); +}); + +test('a successful envelope without exchange acceptance cannot complete the lifecycle', async () => { + const f = fixture(); + const command = f.command; + f.command = vi.fn(async (name, input) => name === 'order' + ? { result: { response: { data: { statuses: [{ error: 'rejected' }] } } } } + : command(name, input)); + await expect(testnetLifecycle(f)).rejects.toThrow('resting post-only'); + expect(f.command.mock.calls.some(([name]) => name === 'rotate')).toBe(false); + expect(f.stop).toHaveBeenCalledOnce(); +}); + +test('a funded but non-disposable account is rejected before any write', async () => { + const f = fixture({ info: async (type) => type === 'clearinghouseState' + ? { withdrawable: '30', assetPositions: [{ position: { szi: '0.1' } }] } : [] }); + await expect(testnetLifecycle(f)).rejects.toThrow('no positions'); + expect(f.command).not.toHaveBeenCalled(); +}); + +test('credential absence fails the actual entrypoint with a failure artifact and no secret output', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'bloxwap-e2e-report-')); + try { + const env = { ...process.env, CI: 'true', BLOXWAP_E2E: '1', BLOXWAP_TEST_ACCOUNT_ONLY: '1' }; + for (const name of ['BLOXWAP_TEST_MASTER_KEY', 'BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET']) delete env[name]; + const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/e2e-testnet.mjs', import.meta.url))], + { cwd, env, encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stdout).not.toMatch(/SKIP|PASS/); + const report = JSON.parse(await readFile(join(cwd, 'artifacts/lifecycle-report.json'), 'utf8')); + expect(report).toMatchObject({ status: 'failed', failure: 'testnet-preflight', stages: [] }); + expect(report.missingSecrets).toEqual(['BLOXWAP_TEST_MASTER_KEY', 'BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET']); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('CI-only test credentials and explicit test account acknowledgement are mandatory', () => { + expect(() => requireTestnetEnvironment({})).toThrow('BLOXWAP_E2E'); + expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1' })).toThrow('CI-only'); + expect(() => requireTestnetEnvironment({ BLOXWAP_E2E: '1', CI: 'true' })).toThrow('disposable'); +}); diff --git a/packages/agent-cli/test/e2e-relay-hook.test.js b/packages/agent-cli/test/e2e-relay-hook.test.js new file mode 100644 index 0000000..a8b8cff --- /dev/null +++ b/packages/agent-cli/test/e2e-relay-hook.test.js @@ -0,0 +1,23 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +afterEach(() => vi.unstubAllGlobals()); + +test('test preload blocks mainnet, requires the explicit mocked route, and passes through real test services', async () => { + const upstream = vi.fn(async () => Response.json({ liveServiceFixture: true })); + vi.stubGlobal('fetch', upstream); + await import('../scripts/e2e-relay-hook.mjs'); + await expect(fetch('https://api.hyperliquid.xyz/info')).rejects.toThrow('Mainnet'); + await expect(fetch(new Request('https://api.relay.link/quote/v2'))).rejects.toThrow('Mainnet'); + await expect(fetch('https://api.testnets.relay.link/quote/v2', { + body: JSON.stringify({ originChainId: 42161 }), + })).rejects.toThrow('test-only'); + const quote = await (await fetch(new Request('https://api.testnets.relay.link/quote/v2', { + method: 'POST', body: JSON.stringify({ originChainId: 421614, originCurrency: '0x' + '0'.repeat(40) }), + }))).json(); + expect(quote.details.currencyOut.amount).toBe('0'); + expect(await (await fetch('https://api.testnets.relay.link/intents/status?id=test')).json()) + .toEqual({ status: 'success' }); + await fetch('https://api.hyperliquid-testnet.xyz/info'); + await fetch(new Request('https://api.privy.io/v1/wallets')); + expect(upstream).toHaveBeenCalledTimes(2); +}); diff --git a/packages/agent-cli/test/init-policy.test.ts b/packages/agent-cli/test/init-policy.test.ts new file mode 100644 index 0000000..e15efb5 --- /dev/null +++ b/packages/agent-cli/test/init-policy.test.ts @@ -0,0 +1,124 @@ +import { expect, test, vi } from 'vitest'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig, migrate, saveConfig } from '../src/core/config.js'; +import { policyFor, policyVersion } from '../src/wallet/policy.js'; +import { readCommand } from '../src/commands/read.js'; +import { dispatch } from '../src/core/router.js'; +import '../src/commands/init.js'; + +const api = vi.hoisted(() => ({ + getPolicy: vi.fn(), + createPolicy: vi.fn(), + getWallet: vi.fn(), + createWallet: vi.fn(), + updateWallet: vi.fn(), +})); +vi.mock('../src/wallet/privy.js', async (importOriginal) => ({ + ...(await importOriginal()), + privyClient: async () => ({ + policies: () => ({ get: api.getPolicy, create: api.createPolicy }), + wallets: () => ({ + get: api.getWallet, + create: api.createWallet, + update: api.updateWallet, + }), + }), +})); +vi.mock('../src/core/http.js', async (importOriginal) => ({ + ...(await importOriginal()), + hlInfo: async (_network: string, method: string) => { + if (method === 'extraAgents') return []; + if (method === 'maxBuilderFee') return 0; + if (method === 'clearinghouseState') + return { + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + totalNtlPos: '0', + }, + withdrawable: '0', + assetPositions: [], + }; + throw new Error(`Unexpected read: ${method}`); + }, +})); + +test('init attaches the policy and revalidates it on an approved account; status reports ID/version', async () => { + const path = join(homedir(), '.bloxwap', 'config.json'); + const wallet = { + id: 'wallet', + address: '0x' + 'a'.repeat(40), + owner_id: 'local-owner', + chain_type: 'ethereum', + policy_ids: ['policy'], + }; + api.createPolicy.mockResolvedValue({ ...policyFor('testnet'), id: 'policy' }); + api.createWallet.mockResolvedValue(wallet); + api.getWallet.mockResolvedValue(wallet); + api.getPolicy.mockResolvedValue({ + ...policyFor('testnet'), + id: 'policy', + rules: [], + }); + api.updateWallet.mockResolvedValue({ + ...wallet, + policy_ids: ['replacement'], + }); + await saveConfig(migrate({ masterAddress: '0x' + 'b'.repeat(40) }), path); + const result = await dispatch(['init', '--config', path, '--no-qr']); + expect(result.body).toMatchObject({ + ok: true, + data: { policyId: 'policy', approved: false }, + }); + expect(api.createWallet).toHaveBeenCalledWith( + expect.objectContaining({ + policy_ids: ['policy'], + owner: { public_key: expect.any(String) }, + }), + ); + const config = await loadConfig(path); + expect(config).toMatchObject({ policyId: 'policy', policyVersion }); + expect(await readCommand('status', config)).toMatchObject({ + policy: { id: 'policy', version: String(policyVersion) }, + }); + config.approved = true; + await saveConfig(config, path); + api.createPolicy.mockResolvedValue({ + ...policyFor('testnet'), + id: 'replacement', + }); + const repeated = await dispatch(['init', '--config', path, '--no-qr']); + expect(repeated.body).toMatchObject({ + ok: true, + data: { policyId: 'replacement', approved: true }, + }); + expect(api.updateWallet).toHaveBeenCalledWith( + wallet.id, + expect.objectContaining({ + policy_ids: ['replacement'], + authorization_context: { + authorization_private_keys: [expect.any(String)], + }, + }), + ); + expect(await loadConfig(path)).toMatchObject({ + policyId: 'replacement', + policyVersion, + }); + for (const override of [ + ['--network', 'mainnet'], + ['--master-address', '0x' + 'c'.repeat(40)], + ]) { + expect( + (await dispatch(['init', '--config', path, '--no-qr', ...override])).body, + ).toMatchObject({ ok: false, code: 'CONFIG_INVALID' }); + } + expect(await loadConfig(path)).toMatchObject({ + network: 'testnet', + masterAddress: '0x' + 'b'.repeat(40), + policyId: 'replacement', + approved: true, + }); + expect(api.createPolicy).toHaveBeenCalledTimes(2); +}); diff --git a/packages/agent-cli/test/lifecycle.test.ts b/packages/agent-cli/test/lifecycle.test.ts index 4fea959..4363dca 100644 --- a/packages/agent-cli/test/lifecycle.test.ts +++ b/packages/agent-cli/test/lifecycle.test.ts @@ -11,7 +11,7 @@ const environment = vi.hoisted(() => ({ sz: string; limitPx: string; }[], - agents: [] as { address: string; validUntil: number }[], + agents: [] as { address: string; validUntil: number; name: string }[], builderFee: 0, balance: '0', })); @@ -25,6 +25,13 @@ vi.mock('../src/wallet/privy.js', async (importOriginal) => { return { ...original, privyClient: async () => ({ + policies: () => ({ + create: async (input: Record) => ({ ...input, id: 'policy' }), + get: async () => { + const { policyFor } = await import('../src/wallet/policy.js'); + return { ...policyFor('testnet'), id: 'policy' }; + }, + }), wallets: () => ({ ethereum: () => ({ signTypedData: async () => ({ @@ -50,14 +57,6 @@ vi.mock('../src/wallet/privy.js', async (importOriginal) => { privySigner: original.privySigner, }; }); -vi.mock('../src/wallet/policy.js', async (importOriginal) => ({ - ...(await importOriginal()), - applyPolicy: async (config: import('../src/core/config.js').Config) => { - config.policyId = 'policy'; - config.policyVersion = 1; - return 'policy'; - }, -})); vi.mock('../src/runner/subscriptions.js', async (importOriginal) => ({ ...(await importOriginal()), startSubscriptions: async () => ({ @@ -79,13 +78,14 @@ import '../src/commands/revoke.js'; import '../src/commands/events.js'; import '../src/commands/audit.js'; import '../src/commands/market.js'; -import { dispatch } from '../src/core/router.js'; +import { dispatch, setRouting } from '../src/core/router.js'; import { saveConfig, migrate } from '../src/core/config.js'; import { activeRunner, setActiveRunner } from '../src/runner/lifecycle.js'; -import { rpcCall, socketPath } from '../src/runner/rpc.js'; +import { rpcCall, socketPath, routeToRunner } from '../src/runner/rpc.js'; import { readAudit, verifyAudit } from '../src/core/audit.js'; let path: string; beforeEach(async () => { + setRouting(routeToRunner); await mkdir(environment.home, { recursive: true, mode: 0o700 }); path = join(environment.home, '.bloxwap', 'config.json'); process.env.BLOXWAP_E2E_AUTH = 'mock-authorization'; @@ -124,10 +124,12 @@ beforeEach(async () => { return Response.json({ status: 'success' }); if (action?.type === 'approveAgent') { if (action.agentAddress === '0x' + '0'.repeat(40)) - environment.agents = []; + environment.agents = environment.agents.filter((agent) => + !agent.name.startsWith(`${action.agentName} valid_until `)); else environment.agents.push({ address: action.agentAddress, + name: action.agentName, validUntil: Number( action.agentName.match(/valid_until (\d+)/)?.[1], ), @@ -152,10 +154,15 @@ beforeEach(async () => { }); const statuses = action.type === 'order' ? [{ resting: { oid: 1 } }] : ['success']; - if (action.type === 'order') + if (action.type === 'order') { environment.orders = [ { coin: 'BTC', oid: 1, side: 'B', sz: '0.001', limitPx: '20000' }, ]; + const { events } = await import('../src/runner/events.js'); + await events.append('order_update', { order: { oid: 1 }, status: 'open' }); + } + if (action.type === 'batchModify') + environment.orders[0].limitPx = action.modifies[0].order.p; if (action.type === 'cancel') environment.orders = []; return Response.json({ status: 'ok', @@ -191,6 +198,7 @@ beforeEach(async () => { afterEach(async () => { await activeRunner?.shutdown(); setActiveRunner(undefined); + setRouting(undefined); delete process.env.BLOXWAP_E2E_AUTH; vi.unstubAllGlobals(); await rm(environment.home, { recursive: true, force: true }); @@ -228,8 +236,13 @@ test('mocked full account lifecycle through real CLI router, SDK and runner RPC' }) ).approved, ).toBe(true); + expect((await command('init', { qr: false })).approved).toBe(true); const deposit = await command('fund', { qr: false, amount: '10' }); expect(deposit.depositId).toBe('deposit-1'); + expect((await command('status')).deposits).toEqual([{ id: 'deposit-1', state: 'pending' }]); + expect((await dispatch(['order', '--config', path, '--json', + '{"coin":"BTC","side":"buy","size":"0.001","price":"20000"}'])).body) + .toMatchObject({ ok: false, code: 'DEPOSIT_PENDING' }); environment.balance = '10'; const status = await command('status'); expect(status.deposits).toEqual([{ id: 'deposit-1', state: 'settled' }]); @@ -238,7 +251,8 @@ test('mocked full account lifecycle through real CLI router, SDK and runner RPC' szDecimals: '5', asset: '0', }); - await command('run'); + expect(await command('run')).toMatchObject({ ready: true, network: 'testnet' }); + expect((await command('status')).runner).toMatchObject({ signingSession: 'ready', wsState: 'connected' }); expect(await rpcCall('balance', {}, socketPath())).toMatchObject({ accountValue: '10', }); @@ -254,14 +268,19 @@ test('mocked full account lifecycle through real CLI router, SDK and runner RPC' builderAddress: '0x71b09A08257078A4d642F7Dd7315e656c837329a', }); await command('modify', { oid: 1, price: '20100' }); + expect(environment.orders[0].limitPx).toBe('20100'); await command('cancel', { all: true }); + expect(await command('orders')).toEqual([]); const rotation = await command('rotate'); expect(rotation.requiresSignature).toBe(true); - expect( - (await command('rotate', { signature: '0x' + '11'.repeat(64) + '1b' })) - .swapped, - ).toBe(true); - await command('revoke', { cancelOrders: true }); + const swapped = await command('rotate', { signature: '0x' + '11'.repeat(64) + '1b' }); + expect(swapped.swapped).toBe(true); + expect(activeRunner?.signer?.address).toBe(swapped.newAgent); + expect((await command('rotate', { revokeSignature: '0x' + '11'.repeat(64) + '1b' })).oldRevoked).toBe(true); + expect(environment.agents.map((agent) => agent.address)).toEqual([swapped.newAgent]); + expect(await command('revoke', { cancelOrders: true })).toMatchObject({ + locallyRevoked: true, exchangeRevoked: false, + }); const rejected = await dispatch([ 'order', '--config', @@ -273,6 +292,10 @@ test('mocked full account lifecycle through real CLI router, SDK and runner RPC' ok: false, code: 'AGENT_NOT_APPROVED', }); + expect(await command('revoke', { signature: '0x' + '11'.repeat(64) + '1b' })).toMatchObject({ + locallyRevoked: true, exchangeRevoked: true, + }); + expect(environment.agents).toEqual([]); const { events } = await import('../src/runner/events.js'); await events.append('fill', { tid: 7, oid: 1, fee: '0.01' }); const entries = await readAudit( @@ -289,5 +312,8 @@ test('mocked full account lifecycle through real CLI router, SDK and runner RPC' ), ).toBe(true); expect(verifyAudit(entries)).toBe(true); - expect(await command('events')).toBeDefined(); + const replay = await command('events') as unknown as { seq: number; type: string }[]; + expect(replay.map((event) => event.type)).toContain('order_update'); + expect(replay.map((event) => event.type)).toContain('fill'); + expect(await command('events', { since: replay[0].seq })).toEqual(replay.slice(1)); }, 20000); diff --git a/packages/agent-cli/test/policy.test.ts b/packages/agent-cli/test/policy.test.ts index ff3e17a..964246e 100644 --- a/packages/agent-cli/test/policy.test.ts +++ b/packages/agent-cli/test/policy.test.ts @@ -1,5 +1,15 @@ -import { expect, test } from 'vitest'; -import { assertTradeAction, policyFor } from '../src/wallet/policy.js'; +import { expect, test, vi } from 'vitest'; +import type { PrivyClient } from '@privy-io/node'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { loadConfig, migrate } from '../src/core/config.js'; +import { + applyPolicy, + assertTradeAction, + policyFor, + policyVersion, +} from '../src/wallet/policy.js'; test('trade-only action whitelist rejects withdrawal and approval', () => { for (const action of [ 'withdraw', @@ -9,6 +19,10 @@ test('trade-only action whitelist rejects withdrawal and approval', () => { 'approveAgent', 'approveBuilderFee', 'usdClassTransfer', + 'sendAsset', + 'subAccountTransfer', + 'vaultTransfer', + 'cWithdraw', ]) expect(() => assertTradeAction(action)).toThrow( expect.objectContaining({ code: 'POLICY_REJECTED' }), @@ -16,3 +30,71 @@ test('trade-only action whitelist rejects withdrawal and approval', () => { assertTradeAction('order'); expect(policyFor('mainnet').rules[0].conditions[2].value).toBe('a'); }); + +function policyClient() { + const create = vi.fn(async (policy: ReturnType) => ({ + ...policy, + id: 'policy-new', + })); + const get = vi.fn(async () => ({ + ...policyFor('testnet'), + id: 'policy-existing', + rules: policyFor('testnet').rules.map((rule, index) => ({ + ...rule, + id: `rule-${index}`, + })), + })); + const client = { + policies: () => ({ create, get }), + } as unknown as PrivyClient; + return { client, create, get }; +} + +test('policy is persisted for wallet creation and verified before reuse', async () => { + const { client, create, get } = policyClient(); + const config = migrate({}); + const path = join(homedir(), `${randomUUID()}.json`); + expect(await applyPolicy(config, client, path)).toBe('policy-new'); + expect(create).toHaveBeenCalledWith(policyFor('testnet')); + expect(await loadConfig(path, {})).toMatchObject({ + policyId: 'policy-new', + policyVersion, + }); + config.policyId = 'policy-existing'; + expect(await applyPolicy(config, client, path)).toBe('policy-existing'); + expect(get).toHaveBeenCalledWith('policy-existing'); + expect(create).toHaveBeenCalledTimes(1); +}); + +test('changed server rules and network are replaced instead of trusted by ID', async () => { + const { client, create, get } = policyClient(); + const path = join(homedir(), `${randomUUID()}.json`); + const config = migrate({ policyId: 'policy-existing', policyVersion }); + get.mockResolvedValueOnce({ + ...policyFor('testnet'), + id: 'policy-existing', + rules: [], + }); + expect(await applyPolicy(config, client, path)).toBe('policy-new'); + config.policyId = 'policy-existing'; + config.network = 'mainnet'; + expect(await applyPolicy(config, client, path)).toBe('policy-new'); + expect(create).toHaveBeenLastCalledWith(policyFor('mainnet')); +}); + +test('policy denies private key/seed exports and leaves non-Agent methods unallowlisted', () => { + const policy = policyFor('testnet'); + expect(policy.rules.filter((rule) => rule.action === 'ALLOW')).toEqual([ + expect.objectContaining({ method: 'eth_signTypedData_v4' }), + ]); + for (const method of ['exportPrivateKey', 'exportSeedPhrase']) + expect(policy.rules).toContainEqual( + expect.objectContaining({ method, action: 'DENY', conditions: [] }), + ); + expect(policy.rules[0].conditions).toContainEqual({ + field_source: 'ethereum_typed_data_domain', + field: 'chainId', + operator: 'eq', + value: '1337', + }); +}); diff --git a/packages/agent-cli/test/privy.integration.test.ts b/packages/agent-cli/test/privy.integration.test.ts index 8567f3f..5c33de8 100644 --- a/packages/agent-cli/test/privy.integration.test.ts +++ b/packages/agent-cli/test/privy.integration.test.ts @@ -1,12 +1,95 @@ import { expect, test } from 'vitest'; +import { generateP256KeyPair } from '@privy-io/node'; +import { Withdraw3Types } from '@bloxwap/hyperliquid/api/exchange/withdraw3'; import { privyClient } from '../src/wallet/privy.js'; +import { policyFor } from '../src/wallet/policy.js'; import { migrate } from '../src/core/config.js'; -test.skipIf(!process.env.BLOXWAP_PRIVY_SANDBOX)( - 'Privy sandbox credentials', - async () => { - const client = await privyClient( - migrate({ privyAppId: process.env.BLOXWAP_PRIVY_APP_ID }), +import { mapPrivyError } from '../src/errors/mappers.js'; + +// This explicitly creates empty resources in the user's dedicated Privy app. +// Never use a master wallet or a funded wallet. No Hyperliquid request is sent. +test('user-owned Privy policy allows Agent signing and rejects a direct withdrawal-type request', async () => { + const missing = ['BLOXWAP_PRIVY_APP_ID', 'BLOXWAP_PRIVY_APP_SECRET'].filter( + (name) => !process.env[name], + ); + if (process.env.BLOXWAP_PRIVY_SANDBOX !== '1') + missing.unshift('BLOXWAP_PRIVY_SANDBOX=1'); + if (missing.length) + throw new Error( + `Privy sandbox test not run. Configure your own dedicated test app: ${missing.join(', ')}`, ); - expect(await client.wallets().list()).toBeDefined(); - }, -); + const client = await privyClient( + migrate({ privyAppId: process.env.BLOXWAP_PRIVY_APP_ID }), + ); + const pair = await generateP256KeyPair(); + const policy = await client.policies().create({ + ...policyFor('testnet'), + name: `agent-cli sandbox ${Date.now()}`, + } as Parameters['create']>[0]); + const wallet = await client.wallets().create({ + chain_type: 'ethereum', + owner: { public_key: pair.publicKey }, + policy_ids: [policy.id], + }); + expect((await client.wallets().get(wallet.id)).policy_ids).toEqual([ + policy.id, + ]); + const authorization_context = { + authorization_private_keys: [pair.privateKey], + }; + const zeroAddress = '0x' + '0'.repeat(40); + const domain = { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: zeroAddress, + }; + const signed = await client + .wallets() + .ethereum() + .signTypedData(wallet.id, { + params: { + typed_data: { + domain, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primary_type: 'Agent', + message: { source: 'b', connectionId: '0x' + '1'.repeat(64) }, + }, + }, + authorization_context, + }); + expect(signed.signature).toMatch(/^0x[0-9a-fA-F]{130}$/); + // Deliberately bypass privySigner/assertAgentTypedData so this assertion + // tests Privy's policy rejection rather than the local allowlist. + const withdrawal = client + .wallets() + .ethereum() + .signTypedData(wallet.id, { + params: { + typed_data: { + domain: { + ...domain, + name: 'HyperliquidSignTransaction', + chainId: 42161, + }, + types: Withdraw3Types, + primary_type: 'HyperliquidTransaction:Withdraw', + message: { + hyperliquidChain: 'Testnet', + destination: zeroAddress, + amount: '1', + time: Date.now(), + }, + }, + }, + authorization_context, + }); + await expect( + withdrawal.catch((error: unknown) => Promise.reject(mapPrivyError(error))), + ).rejects.toMatchObject({ code: 'POLICY_REJECTED' }); +}, 60_000); diff --git a/packages/agent-cli/test/privy.test.ts b/packages/agent-cli/test/privy.test.ts index 6e02b3d..1c306c5 100644 --- a/packages/agent-cli/test/privy.test.ts +++ b/packages/agent-cli/test/privy.test.ts @@ -1,7 +1,33 @@ import { expect, test, vi } from 'vitest'; import type { PrivyClient } from '@privy-io/node'; -import { privySigner, resolveSecret } from '../src/wallet/privy.js'; -import { migrate } from '../src/core/config.js'; +import { Withdraw3Types } from '@bloxwap/hyperliquid/api/exchange/withdraw3'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + createAgent, + privyClient, + privySigner, + resolveSecret, +} from '../src/wallet/privy.js'; +import { loadConfig, migrate } from '../src/core/config.js'; + +const agentData = () => ({ + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: ('0x' + '0'.repeat(40)) as `0x${string}`, + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'b', connectionId: '0x' + '1'.repeat(64) }, +}); test('remote typed-data signing preserves EIP-712 and maps denial', async () => { const signTypedData = vi.fn().mockResolvedValue({ signature: '0x1234' }); const client = { @@ -17,17 +43,7 @@ test('remote typed-data signing preserves EIP-712 and maps denial', async () => }), client, ); - const data = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: ('0x' + '0'.repeat(40)) as `0x${string}`, - }, - types: { Agent: [{ name: 'source', type: 'string' }] }, - primaryType: 'Agent', - message: { source: 'b' }, - }; + const data = agentData(); expect(await signer.signTypedData(data)).toBe('0x1234'); expect(signTypedData.mock.calls[0][1].params.typed_data.primary_type).toBe( 'Agent', @@ -43,3 +59,210 @@ test('remote typed-data signing preserves EIP-712 and maps denial', async () => await resolveSecret('env:BLOXWAP_SECRET', { BLOXWAP_SECRET: 'ok' }), ).toBe('ok'); }); + +test('signer refuses master actions and wrong domains before contacting Privy', async () => { + const signTypedData = vi.fn(); + const client = { + wallets: () => ({ ethereum: () => ({ signTypedData }) }), + } as unknown as PrivyClient; + vi.stubEnv('BLOXWAP_TEST_AUTH', 'test-key'); + try { + const signer = await privySigner( + migrate({ + agentAddress: '0x' + 'a'.repeat(40), + privyWalletId: 'wallet', + authorizationKeyRef: 'env:BLOXWAP_TEST_AUTH', + }), + client, + ); + const valid = agentData(); + for (const data of [ + { + domain: { + ...valid.domain, + name: 'HyperliquidSignTransaction', + chainId: 42161, + }, + types: Withdraw3Types, + primaryType: 'HyperliquidTransaction:Withdraw', + message: { + hyperliquidChain: 'Testnet', + destination: '0x' + '0'.repeat(40), + amount: '1', + time: Date.now(), + }, + }, + { ...valid, primaryType: 'HyperliquidTransaction:Withdraw' }, + { ...valid, primaryType: 'HyperliquidTransaction:ApproveAgent' }, + { ...valid, domain: { ...valid.domain, name: 'Another exchange' } }, + { ...valid, domain: { ...valid.domain, version: '2' } }, + { ...valid, domain: { ...valid.domain, chainId: 42161 } }, + { + ...valid, + domain: { ...valid.domain, verifyingContract: '0x1' as `0x${string}` }, + }, + { ...valid, message: { ...valid.message, source: 'a' } }, + { ...valid, message: { ...valid.message, connectionId: '0x1' } }, + { ...valid, types: { Agent: [{ name: 'source', type: 'string' }] } }, + ]) + await expect(signer.signTypedData(data)).rejects.toMatchObject({ + code: 'POLICY_REJECTED', + }); + expect(signTypedData).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } +}); + +test('linking an unowned agent assigns a local owner and policy together', async () => { + const wallet = { + id: 'unowned-agent', + chain_type: 'ethereum', + address: '0x' + 'a'.repeat(40), + owner_id: null, + policy_ids: [], + }; + const update = vi.fn(async () => ({ + ...wallet, + owner_id: 'new-local-owner', + policy_ids: ['policy'], + })); + const client = { + wallets: () => ({ get: async () => wallet, update }), + } as unknown as PrivyClient; + const config = migrate({ privyWalletId: wallet.id }); + await createAgent( + config, + client, + join(homedir(), `${randomUUID()}.json`), + 'policy', + ); + expect(update).toHaveBeenCalledWith(wallet.id, { + owner: { public_key: expect.any(String) }, + policy_ids: ['policy'], + }); + const authorizationKey = await resolveSecret(config.authorizationKeyRef); + expect(authorizationKey.length).toBeGreaterThan(0); + expect(JSON.stringify(update.mock.calls)).not.toContain(authorizationKey); +}); + +test('owned wallet policy updates use the local owner key and preserve ownership', async () => { + const wallet = { + id: 'wallet', + chain_type: 'ethereum', + address: '0x' + 'a'.repeat(40), + owner_id: 'user-owner', + policy_ids: ['old-policy'], + }; + const update = vi.fn( + async (_id: string, params: { policy_ids: string[] }) => ({ + ...wallet, + policy_ids: params.policy_ids, + }), + ); + const client = { + wallets: () => ({ get: async () => wallet, update }), + } as unknown as PrivyClient; + vi.stubEnv('BLOXWAP_TEST_AUTH', 'user-local-owner-key'); + try { + const config = migrate({ + privyWalletId: wallet.id, + authorizationKeyRef: 'env:BLOXWAP_TEST_AUTH', + }); + await createAgent( + config, + client, + join(homedir(), `${randomUUID()}.json`), + 'new-policy', + ); + expect(update).toHaveBeenCalledWith(wallet.id, { + policy_ids: ['new-policy'], + authorization_context: { + authorization_private_keys: ['user-local-owner-key'], + }, + }); + await expect( + createAgent( + migrate({ privyWalletId: wallet.id }), + client, + undefined, + 'policy', + ), + ).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); + await expect( + createAgent( + migrate({ + privyWalletId: wallet.id, + masterAddress: wallet.address, + authorizationKeyRef: 'env:BLOXWAP_TEST_AUTH', + }), + client, + undefined, + 'policy', + ), + ).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); + expect(update).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllEnvs(); + } +}); + +test('Privy onboarding requires credentials for the user’s own app', async () => { + await expect(privyClient(migrate({}))).rejects.toMatchObject({ + code: 'CONFIG_INVALID', + message: expect.stringContaining('your own Privy account/app'), + }); +}); + +test('a lost wallet-create response reuses the persisted owner and idempotency key', async () => { + const path = join(homedir(), `${randomUUID()}.json`); + const create = vi + .fn() + .mockRejectedValueOnce(new Error('ECONNRESET after remote create')) + .mockResolvedValueOnce({ id: 'recovered', address: '0x' + 'a'.repeat(40) }); + const client = { wallets: () => ({ create }) } as unknown as PrivyClient; + await expect( + createAgent(migrate({}), client, path, 'policy'), + ).rejects.toMatchObject({ + code: 'NETWORK_ERROR', + }); + const recovered = await loadConfig(path, {}); + expect(recovered.authorizationKeyRef).toMatch(/^file:/); + await createAgent(recovered, client, path, 'policy'); + expect(create.mock.calls[1][0]).toEqual(create.mock.calls[0][0]); + expect(await loadConfig(path, {})).toMatchObject({ + privyWalletId: 'recovered', + }); +}); + +test('a lost link response retains the local owner key for policy-attachment recovery', async () => { + const path = join(homedir(), `${randomUUID()}.json`); + const wallet = { + id: 'agent', + chain_type: 'ethereum', + address: '0x' + 'a'.repeat(40), + owner_id: null as string | null, + policy_ids: [] as string[], + }; + const update = vi.fn(async (): Promise => { + wallet.owner_id = 'assigned-owner'; + throw new Error('ECONNRESET after remote ownership change'); + }); + const client = { + wallets: () => ({ get: async () => wallet, update }), + } as unknown as PrivyClient; + await expect( + createAgent(migrate({ privyWalletId: wallet.id }), client, path, 'policy'), + ).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); + const recovered = await loadConfig(path, {}); + const authorizationKey = await resolveSecret(recovered.authorizationKeyRef); + update.mockImplementationOnce(async () => { + wallet.policy_ids = ['policy']; + return wallet; + }); + await createAgent(recovered, client, path, 'policy'); + expect(update).toHaveBeenLastCalledWith(wallet.id, { + policy_ids: ['policy'], + authorization_context: { authorization_private_keys: [authorizationKey] }, + }); +}); diff --git a/packages/agent-cli/test/publication.test.mjs b/packages/agent-cli/test/publication.test.mjs new file mode 100644 index 0000000..0f182f0 --- /dev/null +++ b/packages/agent-cli/test/publication.test.mjs @@ -0,0 +1,51 @@ +import { expect, test, vi } from 'vitest'; +import { + readRegistryDist, + verifyPublication, +} from '../scripts/publication.mjs'; + +test('only an explicit npm registry 404 is treated as an unpublished version', () => { + const missing = vi.fn(() => { + throw Object.assign(new Error('npm404'), { + stdout: JSON.stringify({ error: { code: 'E404' } }), + }); + }); + expect(readRegistryDist('@bloxwap/agent-cli@0.2.0', missing)).toBeUndefined(); + for (const code of ['E401', 'E403', 'ENOTFOUND', 'ETIMEDOUT']) { + const fail = () => { + throw Object.assign(new Error(code), { + stdout: JSON.stringify({ error: { code } }), + }); + }; + expect(() => readRegistryDist('@bloxwap/agent-cli@0.2.0', fail)).toThrow( + code, + ); + } +}); +test('invalid registry output and subprocess failures stop publication', () => { + expect(() => readRegistryDist('package', () => 'bad JSON')).toThrow(); + expect(() => + readRegistryDist('package', () => { + throw new Error('subprocess'); + }), + ).toThrow('subprocess'); +}); +test('registry distribution is parsed without changing its integrity', () => { + const dist = { + integrity: 'sha512-example', + attestations: { url: 'https://registry.npmjs.org/attestations/example' }, + }; + expect(readRegistryDist('package', () => JSON.stringify(dist))).toEqual(dist); + expect(() => verifyPublication(dist, 'sha512-example')).not.toThrow(); +}); +test('an existing version must match the tested archive and have provenance', () => { + expect(() => + verifyPublication({ integrity: 'sha512-other' }, 'sha512-example'), + ).toThrow('differs'); + expect(() => + verifyPublication({ integrity: 'sha512-example' }, 'sha512-example'), + ).toThrow('provenance'); + expect(() => verifyPublication(undefined, 'sha512-example')).toThrow( + 'differs', + ); +}); diff --git a/packages/agent-cli/test/release-metadata.test.mjs b/packages/agent-cli/test/release-metadata.test.mjs new file mode 100644 index 0000000..289ec32 --- /dev/null +++ b/packages/agent-cli/test/release-metadata.test.mjs @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; +import { verifyReleaseMetadata } from '../scripts/release-metadata.mjs'; + +const candidate = () => ({ + manifest: { + name: '@bloxwap/agent-cli', + version: '0.2.0', + repository: { url: 'git+https://github.com/bloxwap/agent-cli.git' }, + }, + skill: '---\nmetadata:\n version: "0.2.0"\n---\n', + changelog: '# @bloxwap/agent-cli\n\n## 0.2.0\n\nChanges.\n', + tag: 'v0.2.0', + lockVersion: '0.2.0', +}); +describe('release metadata gate', () => { + test('accepts a matching versioned stable release', () => { + expect(verifyReleaseMetadata(candidate())).toEqual({ + name: '@bloxwap/agent-cli', + version: '0.2.0', + tag: 'v0.2.0', + }); + }); + test('rejects a mismatched tag', () => { + expect(() => + verifyReleaseMetadata({ ...candidate(), tag: 'v0.1.0' }), + ).toThrow('Tag/version mismatch'); + }); + test('rejects a stale lockfile version', () => { + expect(() => + verifyReleaseMetadata({ ...candidate(), lockVersion: '0.1.0' }), + ).toThrow('Lockfile version differs'); + }); + test('rejects unpublished changesets on a release tag', () => { + expect(() => + verifyReleaseMetadata({ ...candidate(), changesets: ['fix.md'] }), + ).toThrow('Version the pending changesets'); + }); + test('allows pending changesets in candidate-only mode', () => { + expect( + verifyReleaseMetadata({ + ...candidate(), + tag: undefined, + changesets: ['fix.md'], + }).tag, + ).toBeNull(); + }); + test('rejects a stale packaged skill', () => { + expect(() => + verifyReleaseMetadata({ + ...candidate(), + skill: 'metadata:\n version: "0.1.0"', + }), + ).toThrow('Skill version differs'); + }); + test('requires versioned release notes', () => { + expect(() => + verifyReleaseMetadata({ + ...candidate(), + changelog: '# Changelog\n## 0.1.0\n', + }), + ).toThrow('missing from CHANGELOG'); + }); + test('refuses a prerelease rather than publishing it to latest', () => { + const fixture = candidate(); + fixture.manifest.version = '0.2.0-rc.1'; + expect(() => verifyReleaseMetadata(fixture)).toThrow('Only stable semver'); + }); + test('requires correct public provenance source metadata', () => { + const fixture = candidate(); + fixture.manifest.repository.url = 'git+https://github.com/other/repo.git'; + expect(() => verifyReleaseMetadata(fixture)).toThrow('provenance source'); + }); +}); diff --git a/packages/agent-cli/test/skill.test.ts b/packages/agent-cli/test/skill.test.ts index ba3e662..45670da 100644 --- a/packages/agent-cli/test/skill.test.ts +++ b/packages/agent-cli/test/skill.test.ts @@ -5,6 +5,73 @@ import { join } from 'node:path'; import { installSkill } from '../src/commands/skill.js'; import { skillContent, cliVersion } from '../src/core/skill-content.js'; import packageInfo from '../package.json' with { type: 'json' }; +import { commands, dispatch, setRouting } from '../src/core/router.js'; +import { errorDefaults } from '../src/errors/index.js'; +import '../src/commands/config.js'; +import '../src/commands/init.js'; +import '../src/commands/fund.js'; +import '../src/commands/deposit.js'; +import '../src/commands/read.js'; +import '../src/commands/order.js'; +import '../src/commands/cancel.js'; +import '../src/commands/modify.js'; +import '../src/commands/leverage.js'; +import '../src/commands/run.js'; +import '../src/commands/events.js'; +import '../src/commands/logs.js'; +import '../src/commands/rotate.js'; +import '../src/commands/revoke.js'; +import '../src/commands/audit.js'; +import '../src/commands/skill.js'; +import '../src/commands/market.js'; + +test('skill frontmatter and embedded source match the published skill', async () => { + const source = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8'); + expect(source).toBe(skillContent); + const frontmatter = source.match(/^---\n([\s\S]+?)\n---\n/); + expect(frontmatter).not.toBeNull(); + expect(frontmatter![1]).toMatch(/^name: agent-cli$/m); + expect(frontmatter![1]).toMatch(/^description: [^\n:]+$/m); + expect(frontmatter![1]).toContain(` version: "${packageInfo.version}"`); + for (const code of Object.keys(errorDefaults)) expect(source).toContain(code); + for (const required of [ + '8 decimals', '6', 'two distinct signatures', '180 days', + 'own Privy account and app', 'Bloxwap does not', 'external wallet', + 'never be reported as live validation', + ]) expect(source).toContain(required); +}); + +test('every documented command example parses through the real CLI schemas offline', async () => { + // Route after validation: this tests the exact documented inputs without + // creating wallets, signing, connecting to a service or mutating user state. + const covered = new Set(); + const table = skillContent.split('## Commands and example envelopes')[1] + .split('## Error recovery')[0]; + setRouting(async () => ({ routed: true, data: {} })); + try { + for (const line of table.split('\n').filter((line) => line.startsWith('| `'))) { + const separator = line.indexOf(' | '); + const inputCell = line.slice(0, separator); + const outputCell = line.slice(separator + 3); + for (const [, example] of inputCell.matchAll(/`([^`]+)`/g)) { + const argv = [...example.replaceAll('0x...', '0x' + 'a'.repeat(40)) + .matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)] + .map((match) => match[1] ?? match[2] ?? match[3]); + const result = await dispatch(argv); + expect(result.body, example).toMatchObject({ ok: true }); + covered.add(argv[0]); + } + const exampleOutput = outputCell.match(/`([^`]+)`/)![1]; + expect(() => JSON.parse(exampleOutput), line).not.toThrow(); + } + expect([...commands.keys()].filter((name) => !covered.has(name))).toEqual([]); + expect(covered).toContain('--help'); + expect(covered).toContain('schema'); + } finally { + setRouting(undefined); + } +}); + test('installs correct agent paths with version match and idempotency', async () => { const home = await mkdtemp(join(tmpdir(), 'bloxwap-skill-')); try {