From 2a0694c6bd29962538014150ab2d52b8ac79b574 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 22:33:25 -0400 Subject: [PATCH 01/29] feat: define scheduler handoff v4 artifact --- .github/workflows/ci.yml | 7 +- .github/workflows/publish.yml | 60 +- CHANGELOG.md | 20 + README.md | 4 +- docs/adoption.md | 2 +- docs/protocol.md | 4 +- docs/roadmap.md | 2 +- docs/runtime-integration-backlog.md | 12 +- docs/spec.md | 82 ++ docs/versioning.md | 2 +- fixtures/handoff-v4/conformance.json | 167 +++++ package-lock.json | 4 +- package.json | 15 +- scripts/verify-package.mjs | 105 +++ src/apply.js | 29 +- src/authorization-proof/certificate.js | 239 +++++- src/authorization-proof/detached-signature.js | 255 ++++++- src/authorization-proof/jwt.js | 143 +++- src/capabilities.js | 31 + src/compiler/openclaw-scheduler.js | 59 +- src/evidence/index.js | 11 + src/evidence/payload.js | 36 + src/handoff/index.js | 11 + src/handoff/v4.js | 699 ++++++++++++++++++ src/inspect.js | 20 + src/scheduler-fields.js | 13 + src/targets.js | 25 +- test/agentcli.test.js | 24 +- test/handoff-v4.test.js | 693 +++++++++++++++++ test/integration-scheduler.test.js | 2 +- 30 files changed, 2651 insertions(+), 125 deletions(-) create mode 100644 fixtures/handoff-v4/conformance.json create mode 100644 scripts/verify-package.mjs create mode 100644 src/handoff/index.js create mode 100644 src/handoff/v4.js create mode 100644 test/handoff-v4.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e529ae0..06b356e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,16 +20,16 @@ jobs: env: OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler - uses: actions/checkout@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" - - uses: actions/setup-node@v5 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -47,6 +47,7 @@ jobs: node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test + - run: npm run verify:package lint-test: name: lint-test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9377547..d28d366 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,10 @@ on: push: tags: ['v*'] +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read @@ -13,16 +17,27 @@ jobs: env: OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify tagged commit is contained in main + run: | + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + TAG_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + if ! git merge-base --is-ancestor "$TAG_COMMIT" refs/remotes/origin/main; then + echo "Tag $GITHUB_REF_NAME points to $TAG_COMMIT, which is not contained in origin/main" + exit 1 + fi - name: Check out pinned openclaw-scheduler - uses: actions/checkout@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" - - uses: actions/setup-node@v5 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' cache: npm @@ -40,21 +55,56 @@ jobs: node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test + - run: npm run verify:package publish: needs: test runs-on: ubuntu-latest permissions: + actions: read contents: write id-token: write steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify tagged commit is contained in main + run: | + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + TAG_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + if ! git merge-base --is-ancestor "$TAG_COMMIT" refs/remotes/origin/main; then + echo "Tag $GITHUB_REF_NAME points to $TAG_COMMIT, which is not contained in origin/main" + exit 1 + fi + - name: Require successful main CI for tagged commit + env: + GH_TOKEN: ${{ github.token }} + run: | + tag_commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + deadline=$((SECONDS + 1800)) + while [ "$SECONDS" -lt "$deadline" ]; do + run_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?event=push&head_sha=${tag_commit}&per_page=20")" + status="$(printf '%s' "$run_json" | jq -r '[.workflow_runs[] | select(.head_branch == "main")] | sort_by(.run_number) | last | .status // "missing"')" + conclusion="$(printf '%s' "$run_json" | jq -r '[.workflow_runs[] | select(.head_branch == "main")] | sort_by(.run_number) | last | .conclusion // "pending"')" + if [ "$status" = "completed" ] && [ "$conclusion" = "success" ]; then + exit 0 + fi + if [ "$status" = "completed" ] && [ "$conclusion" != "success" ]; then + echo "Main CI for $tag_commit completed with conclusion $conclusion" + exit 1 + fi + sleep 10 + done + echo "Timed out waiting for successful main CI for $tag_commit" + exit 1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' cache: npm registry-url: 'https://registry.npmjs.org' - run: npm ci + - run: npm run verify:package - name: Verify tag matches package.json version env: TAG_REF: ${{ github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d90c65..a085be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.5.0 (2026-07-18) + +- scheduler: added handoff v4 canonical artifacts with explicit artifact, + canonicalization, execution-binding, and scheduler-binding versions +- scheduler: complete non-lossy create, replace-style update, null clear, and + adopt projections now carry the immutable artifact payload and digest +- security: JWT, detached-signature, and certificate proofs bind the exact v4 + artifact, replay identifier, validity, key, and revocation result +- security: credential handoff compiles to exactly one runtime medium without + persisting values, and delegation binds the exact source run and scope +- evidence: the complete AgentCLI evidence payload and verification envelope + are available to the scheduler runtime for signed or provider-verified + terminal evidence +- inspect: added immutable artifacts, runtime events, provider sessions, and + credential presentations to the scheduler inspection surface +- conformance: added shared positive and negative v4 fixtures and exact digest + parity tests with OpenClaw Scheduler +- compatibility: handoff versions 1 through 3 and manifest versions 0.1 and 0.2 + retain their existing behavior + ## 0.4.1 (2026-07-13) - scheduler: post-success verification for shell tasks now runs from the task working directory while retaining the runtime-provided execution environment, and expanded verifier commands are checked against the scheduler's storage limit before apply diff --git a/README.md b/README.md index b038abc..7848483 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ Approvals are single-use and consumed before `spawnSync` (fail-closed: a crashed | Command | Description | |---|---| -| `inspect [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson]` | Inspect scheduler runtime state with field masks and sanitization. | +| `inspect [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson]` | Inspect scheduler runtime state with field masks and sanitization. | | `audit [--limit n]` | Display recent audit records from the append-only log. | | `verify [--allowed-signers path]` | Verify execution evidence for a completed run. | | `signing providers` | List registered signing providers and their attestation methods. | @@ -481,7 +481,7 @@ See [docs/protocol.md](docs/protocol.md) for the full protocol specification. | Target | Description | |---|---| | `standalone` | Portable plan for authoring, validation, explanation, and protocol use. No durable runtime required. | -| `openclaw-scheduler` | Compiler target for the durable scheduler runtime. Apply uses live runtime capabilities when reported and conservative static fallback values otherwise. Governed root approvals, approver scopes, structured output, and v3 handoff fields require explicit runtime support. | +| `openclaw-scheduler` | Compiler target for the durable scheduler runtime. Apply uses live runtime capabilities when reported and conservative static fallback values otherwise. Handoff v4 adds immutable canonical execution artifacts, artifact-bound proofs and evidence, exact source-run delegation, provider sessions, credential presentation, and runtime events. Handoff versions 1 through 3 remain compatible. | ```bash # Compile for standalone use diff --git a/docs/adoption.md b/docs/adoption.md index b184503..174571a 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -191,7 +191,7 @@ The main current risks are: - the standard is still draft - only one production-grade runtime adapter exists today -- runtime compatibility depends on explicit capability negotiation, especially for root approvals, approver scope, structured output, and handoff v3 +- runtime compatibility depends on explicit capability negotiation, especially for root approvals, approver scope, structured output, and handoff v4 ### Avoiding heavy single-prompt jobs diff --git a/docs/protocol.md b/docs/protocol.md index f42fb45..a588482 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -63,7 +63,7 @@ Purpose: Result: -- `{ "ok": true, "package_version": "0.4.0", "manifest_version": "0.2" }` +- `{ "ok": true, "package_version": "0.5.0", "manifest_version": "0.2" }` ### `agentcli.schema` @@ -146,7 +146,7 @@ Params: Result: -- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "..." }, "handoff": { "field_version": "1|2|3", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` +- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "..." }, "handoff": { "field_version": "1|2|3|4", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` - `adopted_from_job_id` is present only when `action` is `"adopted"` - `capabilities` summarizes runtime capability negotiation for the selected scheduler - `handoff` summarizes which scheduler field version was projected during apply diff --git a/docs/roadmap.md b/docs/roadmap.md index b20f713..119a17a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,7 +40,7 @@ The `v0.1` and `v0.2` headings below are manifest specification milestones, not - Cryptographic, manifest-bound authorization proofs and versioned evidence envelopes with transplantation detection - Fail-closed sandbox, network, provider capability, delegation, and credential cleanup behavior - Draft 2020-12 JSON Schema output, strict nested validation, strict CLI flags, and read-only JSON-RPC discovery methods -- Scheduler handoff v3, authoritative live capabilities, governed feature gates, auto-reject disabling, and refusal to persist inline shell credentials +- Scheduler handoff v4 with immutable canonical artifacts, artifact-bound cryptographic proofs and evidence, exact source-run delegation, provider session recovery, credential presentation tracking, append-only runtime events, shared conformance fixtures, and a public restart-backed E2E ## Future identity and runtime expansion diff --git a/docs/runtime-integration-backlog.md b/docs/runtime-integration-backlog.md index 5b09def..2dd2a13 100644 --- a/docs/runtime-integration-backlog.md +++ b/docs/runtime-integration-backlog.md @@ -124,13 +124,13 @@ Acceptance criteria: Owner: `agentcli` + `openclaw-scheduler` -Status: versioned field projection through handoff v3 and negative capability tests are implemented in `agentcli`; runtime releases must opt into each version and feature. +Status: shipped in AgentCLI 0.5.0 and OpenClaw Scheduler 0.5.0 as handoff v4. The canonical artifact, complete scheduler binding, shared fixtures, non-lossy apply/update/adopt path, immutable runtime bindings, and negative capability gates are executable release requirements. Problem: - The control plane and runtime need a cleaner contract than “flatten some fields and hope the semantics line up.” -Backlog: +Delivered: 1. Define a versioned compiled handoff artifact for `openclaw-scheduler`. 2. Preserve resolved per-task semantics needed by the runtime instead of requiring re-derivation from lossy fields. @@ -227,9 +227,9 @@ Acceptance criteria: Owner: `agentcli` + `openclaw-scheduler` + OpenClaw (where available) -Status: CI checks out an exact scheduler commit, verifies its capability command, and runs the agentcli scheduler integration tests. Broader gateway-backed end-to-end scenarios remain future cross-repository work. +Status: shipped for the AgentCLI to scheduler boundary. CI uses exact cross-repository revisions and published-package black-box gates. The scheduler package includes a public fresh-database, restart-backed v4 E2E for identity, credentials, proof, authorization, approvals, structured output, postconditions, signed evidence, delivery, and all five durable dispatch kinds. Broader tests against upstream OpenClaw Gateway releases remain future cross-project work. -Backlog: +Delivered for the scheduler boundary: 1. Add an integration fixture that exercises `agentcli apply` against a real scheduler instance. 2. Add at least one end-to-end `v0.2` manifest for: @@ -277,10 +277,10 @@ Acceptance criteria: ### `agentcli` - Add runtime capability discovery client and fallback logic. -- Define a versioned scheduler handoff artifact. +- Maintain the versioned scheduler handoff artifact and conformance fixtures. - Improve `apply` error reporting around capability mismatches. - Add stateless prompt-task runtime delegation path. -- Add cross-repo integration tests. +- Maintain exact-revision and published-package cross-repo integration tests. ### `openclaw-scheduler` diff --git a/docs/spec.md b/docs/spec.md index 082e320..502aaa1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -803,6 +803,88 @@ The two attestation layers work together: - Manifest-time (`identity.attestation`): proves the manifest was authorized - Execution-time (audit record): proves each run was performed by a specific identity +## Scheduler Handoff Version 4 + +A scheduler target MAY advertise handoff version 4 only when it implements the +complete protocol below. Partial support MUST fall back to an earlier handoff +version or reject apply. It MUST NOT emit a partial v4 artifact. + +### Artifact and canonicalization + +The artifact schema is `openclaw.scheduler.handoff-artifact`, artifact schema +version 1, handoff version 4, and minimum scheduler schema 29. Canonicalization +is `json-sort-v1`, canonicalization version 1, using SHA-256. Object keys are +sorted recursively, array order is preserved, undefined values normalize to +JSON null, numbers must be finite, and the artifact digest is the SHA-256 digest +of the UTF-8 canonical JSON. + +The artifact uses execution binding version 2 and scheduler job binding version +1. It MUST bind the canonical manifest digest, workflow and task IDs, stable job +ID, effective task hash, command and input hashes, complete persisted scheduler +execution projection, lifecycle, runtime, approval, output, identity, proof, +authorization, evidence, contract, verification, intent, delegation, and +credential-presentation policy. + +The artifact MUST NOT contain a raw credential, proof value, stdin value, +environment value, private key, token, password, or provider session secret. +Credential and proof sources are represented by declarative locations and +hashes. A consumer MUST recompute and compare the artifact, scheduler binding, +and effective task digests before execution. + +### Persistence and replacement + +Artifacts are immutable and content-addressed. Create, update, adoption, +explicit null clearing, migration, inspection, restart, stale-claim recovery, +and crash recovery MUST preserve the exact payload and digest. A replacement +creates a new artifact and retains the old artifact for referenced runtime +history. Pending work bound to a superseded artifact MUST be cancelled or +executed from a complete immutable snapshot; it MUST NOT silently execute the +replacement configuration. + +Every v4 dispatch, approval, run, runtime event, provider session, credential +presentation, and evidence record MUST carry the exact artifact digest. Chain +and retry work MUST also carry the exact source run ID and source artifact +digest. + +### Runtime gates + +The runtime MUST advertise all of these boolean features before AgentCLI emits +v4: `handoff_v4_artifact`, `artifact_bound_proofs`, +`signed_or_provider_verified_evidence`, `provider_session_cache`, +`credential_presentation`, `source_run_bound_delegation`, and +`immutable_runtime_events`. AgentCLI uses the live capability response as +authoritative for every key it reports. + +JWT, detached-signature, and certificate proofs MUST cryptographically bind the +artifact digest, proof identity, validity interval, verification key, and a +single-use replay identifier. Required revocation checks MUST run before user +code. Missing, tampered, replayed, transplanted, expired, prematurely valid, +revoked, or unbound proofs fail closed. + +Credential release MUST use exactly one negotiated medium: environment, +temporary file, stdin, or a Gateway capability-bound environment header. +Materialization state is recorded before release, values are never persisted, +cleanup is durable and restart-recoverable, and cleanup failure is visible to an +operator. + +Delegation MUST bind the concrete source run, validate every grant and actor, +reject cycles, enforce maximum depth and scope, and prevent credential scope +escalation. Selecting a newer run from the same parent job is not equivalent. + +Evidence MUST bind the artifact, runtime instance, lineage, identity, proof, +authorization, command result, structured output, postcondition, and terminal +status. A required evidence provider MUST sign or externally verify the +canonical payload. Verification failure MUST remain terminal and MUST NOT be +downgraded to checksum-only evidence. + +### Compatibility and conformance + +Handoff v4 is additive. Manifest versions 0.1 and 0.2 and scheduler handoff +versions 1 through 3 retain their existing behavior. Producers and consumers +SHOULD publish identical positive and negative conformance vectors. The +reference vectors are packaged under `fixtures/handoff-v4/` in AgentCLI and +OpenClaw Scheduler. + ## Compiler Targets This spec does not require a single runtime. diff --git a/docs/versioning.md b/docs/versioning.md index b1c9cb6..b76fa1a 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -2,7 +2,7 @@ ## Current Versions -- package version: `0.4.0` +- package version: `0.5.0` - manifest spec version: `0.2` - protocol status: draft, aligned to manifest spec `0.2` diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json new file mode 100644 index 0000000..710b82b --- /dev/null +++ b/fixtures/handoff-v4/conformance.json @@ -0,0 +1,167 @@ +{ + "fixture_version": 1, + "compile": { + "cwd": "/workspace", + "env": { + "HOME": "/home/fixture", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "LC_CTYPE": "C.UTF-8", + "LOGNAME": "fixture", + "PATH": "/usr/bin:/bin", + "SHELL": "/bin/sh", + "TERM": "dumb", + "TMPDIR": "/tmp", + "USER": "fixture" + } + }, + "manifest": { + "version": "0.1", + "workflows": [ + { + "id": "fixture", + "name": "Handoff v4 fixture", + "tasks": [ + { + "id": "positive", + "name": "Positive artifact fixture", + "target": { + "session_target": "shell" + }, + "shell": { + "program": "/usr/bin/printf", + "args": [ + "%s\\n", + "handoff-v4" + ] + }, + "schedule": { + "cron": "17 3 * * *" + }, + "runtime": { + "timeout_ms": 30000 + }, + "approval": { + "required": true, + "policy": "manual", + "risk_level": "medium", + "timeout_s": 300 + }, + "delivery": { + "mode": "none" + }, + "output": { + "format": "text" + }, + "contract": { + "audit": "always" + } + } + ] + } + ] + }, + "expected": { + "artifact_digest": "sha256:ff67b39864301a09e9b22d59ea5cda12164e23f61ed19f8a236d12baa23d58f6", + "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", + "effective_task_hash": "sha256:ffc128392f8cd4bbbdea2d68799fe72cfeef0e72f7daa6b02ab507c2371bb352", + "scheduler_job_binding_digest": "sha256:b01c09ed6fee8b142c8b40e63db40c6580118be303de6146977b978efeeb70af" + }, + "negative_artifact_cases": [ + { + "name": "payload tamper", + "changes": [ + { + "op": "set", + "path": ["runtime", "timeout_ms"], + "value": 30001 + } + ], + "expected_error": "artifact digest does not match payload", + "use_expected_digest": true + }, + { + "name": "future artifact schema", + "changes": [ + { + "op": "set", + "path": ["artifact_schema_version"], + "value": 2 + } + ], + "expected_error": "artifact_schema_version must be 1" + }, + { + "name": "future canonicalization", + "changes": [ + { + "op": "set", + "path": ["canonicalization", "version"], + "value": 2 + } + ], + "expected_error": "canonicalization contract is unsupported" + }, + { + "name": "invalid scheduler execution binding", + "changes": [ + { + "op": "set", + "path": ["scheduler_job_binding", "digest"], + "value": "sha256:invalid" + } + ], + "expected_error": "scheduler_job_binding.digest must be a lowercase sha256 digest" + }, + { + "name": "missing approval binding", + "changes": [ + { + "op": "delete", + "path": ["approval", "required"] + } + ], + "expected_error": "approval.required must be a boolean" + }, + { + "name": "cryptographic proof downgrade", + "changes": [ + { + "op": "set", + "path": ["authorization_proof", "method"], + "value": "jwt" + }, + { + "op": "set", + "path": ["authorization_proof", "artifact_binding_required"], + "value": false + } + ], + "expected_error": "artifact binding, replay protection, and revocation" + }, + { + "name": "raw credential material", + "changes": [ + { + "op": "set", + "path": ["identity", "presentation", "handoff"], + "value": "env" + }, + { + "op": "set", + "path": ["identity", "presentation", "bindings"], + "value": [ + { + "name": "token", + "medium": "env", + "required": true, + "redact": true, + "value": "must-not-persist" + } + ] + } + ], + "expected_error": "raw credential material" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 16c42d6..c451c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amittell/agentcli", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amittell/agentcli", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "bin": { "agentcli": "bin/agentcli.js" diff --git a/package.json b/package.json index a6e191e..7b5ad0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amittell/agentcli", - "version": "0.4.1", + "version": "0.5.0", "description": "Control plane for governed agent and CLI workflows with portable manifests, identity, approvals, and evidence.", "type": "module", "main": "./src/index.js", @@ -18,6 +18,14 @@ "./capabilities": "./src/capabilities.js", "./shorthand": "./src/shorthand.js", "./inspect": "./src/inspect.js", + "./handoff": "./src/handoff/index.js", + "./authorization-proof": "./src/authorization-proof/index.js", + "./authorization-proof/jwt": "./src/authorization-proof/jwt.js", + "./authorization-proof/detached-signature": "./src/authorization-proof/detached-signature.js", + "./authorization-proof/certificate": "./src/authorization-proof/certificate.js", + "./evidence": "./src/evidence/index.js", + "./evidence/payload": "./src/evidence/payload.js", + "./evidence/ssh": "./src/evidence/ssh.js", "./home": "./src/home.js", "./describe": "./src/describe.js", "./sanitize": "./src/sanitize.js", @@ -32,14 +40,17 @@ }, "scripts": { "test": "node --test", - "lint": "eslint . --max-warnings=0" + "lint": "eslint . --max-warnings=0", + "verify:package": "node scripts/verify-package.mjs" }, "files": [ "bin", "docs", "!docs/superpowers", "examples", + "fixtures", "skills", + "scripts/verify-package.mjs", "src", "AGENTS.md", "CONTEXT.md", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..8a49b5b --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = mkdtempSync(join(tmpdir(), 'agentcli-package-')); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + encoding: 'utf8', + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + }); +} + +try { + const packResult = JSON.parse(run(npmCommand, [ + 'pack', + '--json', + '--pack-destination', fixtureRoot, + ])); + if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') { + throw new Error('npm pack did not return an artifact filename'); + } + + const tarball = join(fixtureRoot, packResult[0].filename); + run('tar', ['-xzf', tarball], { cwd: fixtureRoot }); + const packageRoot = join(fixtureRoot, 'package'); + const requiredFiles = [ + 'bin/agentcli.js', + 'fixtures/handoff-v4/conformance.json', + 'scripts/verify-package.mjs', + 'src/handoff/index.js', + 'src/handoff/v4.js', + 'src/authorization-proof/jwt.js', + 'src/authorization-proof/detached-signature.js', + 'src/authorization-proof/certificate.js', + ]; + for (const relativePath of requiredFiles) { + if (!existsSync(join(packageRoot, relativePath))) { + throw new Error(`packed artifact is missing ${relativePath}`); + } + } + + const forbiddenPaths = [ + '.env', + '.git', + '.github', + '.coordination', + 'test', + ]; + for (const relativePath of forbiddenPaths) { + if (existsSync(join(packageRoot, relativePath))) { + throw new Error(`packed artifact contains forbidden path ${relativePath}`); + } + } + + const packedPackage = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + const sourcePackage = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); + if (packedPackage.version !== sourcePackage.version) { + throw new Error('packed package version does not match source package version'); + } + + const fixture = JSON.parse(readFileSync( + join(packageRoot, 'fixtures/handoff-v4/conformance.json'), + 'utf8', + )); + if (fixture.fixture_version !== 1 + || !fixture.manifest?.workflows?.[0]?.tasks?.length + || typeof fixture.expected?.artifact_digest !== 'string' + || !Array.isArray(fixture.negative_artifact_cases) + || fixture.negative_artifact_cases.length === 0) { + throw new Error('packed handoff v4 conformance fixture is incomplete'); + } + + const handoff = await import(pathToFileURL(join(packageRoot, 'src/handoff/index.js')).href); + for (const exportName of [ + 'buildSchedulerHandoffV4Artifact', + 'validateSchedulerHandoffV4Artifact', + ]) { + if (typeof handoff[exportName] !== 'function') { + throw new Error(`packed handoff module is missing ${exportName}`); + } + } + + const help = run(process.execPath, ['bin/agentcli.js', 'help'], { cwd: packageRoot }); + if (!help.includes('agentcli')) { + throw new Error('packed CLI help output is invalid'); + } + + process.stdout.write(`Verified @amittell/agentcli ${packedPackage.version} package contract\n`); +} finally { + rmSync(fixtureRoot, { recursive: true, force: true }); +} diff --git a/src/apply.js b/src/apply.js index 6c6c94a..62ddedb 100644 --- a/src/apply.js +++ b/src/apply.js @@ -12,16 +12,24 @@ import { expandManifestShorthands } from './shorthand.js'; import { querySchedulerCapabilities, resolveEffectiveFeatures, + supportsSchedulerHandoffV4, validateManifestCapabilities, } from './capabilities.js'; import { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELDS_V03, + SCHEDULER_FIELDS_V04, SCHEDULER_FIELD_VERSIONS, } from './scheduler-fields.js'; export { shellCommandInvocation } from './command.js'; -export { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELDS_V03, SCHEDULER_FIELD_VERSIONS }; +export { + SCHEDULER_FIELDS_V1, + SCHEDULER_FIELDS_V02, + SCHEDULER_FIELDS_V03, + SCHEDULER_FIELDS_V04, + SCHEDULER_FIELD_VERSIONS, +}; function npmCommandForPlatform(platform = process.platform) { return platform === 'win32' ? 'npm.cmd' : 'npm'; @@ -98,6 +106,7 @@ function spawnSchedulerJson(invocation, args, { cwd, env, runner = spawnSync } = // Fields that the scheduler stores as JSON text blobs rather than scalar columns. const JSON_BLOB_FIELDS = new Set([ 'identity', 'authorization_proof', 'authorization', 'evidence', + 'handoff_artifact_payload', ]); function projectSchedulerSpec(job, fields, { includeNulls = false } = {}) { @@ -127,6 +136,7 @@ export function schedulerCreateSpec(job, { originOverride, fieldVersion = '1' } } export function requiredSchedulerFieldVersion(jobs = []) { + if (jobs.some(job => SCHEDULER_FIELDS_V04.some(field => job[field] != null))) return 4; if (jobs.some(job => SCHEDULER_FIELDS_V03.some(field => job[field] != null))) return 3; if (jobs.some(job => SCHEDULER_FIELDS_V02.some(field => job[field] != null))) return 2; return 1; @@ -147,7 +157,7 @@ export function negotiateSchedulerFieldVersion(jobs, advertisedVersion = '1') { } ); } - return String(Math.min(parsedVersion, 3)); + return String(Math.min(parsedVersion, requiredVersion >= 4 ? 4 : 3)); } function schedulerUpdateSpec(job, { fieldVersion = '1' } = {}) { @@ -252,12 +262,14 @@ export async function applyManifestToScheduler( allowValueFromCommand = false } = {} ) { - const compiled = compileManifestToScheduler(manifest, { includeExplain }); + let compiled = compileManifestToScheduler(manifest, { includeExplain }); const verificationByTask = new Map(); const resolvedProofsByTask = buildResolvedAuthorizationProofsByTask(manifest); const requiredHandoffVersion = requiredSchedulerFieldVersion(compiled.jobs); const requiresCapabilityNegotiation = - requiredHandoffVersion > 1 || compiled.jobs.some(jobRequiresCapabilityNegotiation); + manifest.version === '0.2' + || requiredHandoffVersion > 1 + || compiled.jobs.some(jobRequiresCapabilityNegotiation); // Construct the scheduler runner once; runtime capability negotiation is only // needed when the compiled manifest actually uses v0.2 runtime-gated fields. @@ -276,6 +288,15 @@ export async function applyManifestToScheduler( const runtimeCaps = querySchedulerCapabilities(schedulerRunner); effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); + if (supportsSchedulerHandoffV4(effectiveResult)) { + compiled = compileManifestToScheduler(manifest, { + includeExplain, + schedulerHandoffVersion: '4', + cwd, + env, + }); + } + const { errors: capabilityErrors, warnings } = validateManifestCapabilities(compiled, effectiveResult); capabilityWarnings = warnings; if (capabilityErrors.length > 0) { diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 153099c..7f95267 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -17,32 +17,67 @@ import { canonicalStringify, hashString } from '../canonical.js'; import { resolveValueFrom } from '../command.js'; import { registerVerifier } from './index.js'; +const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; + +export function buildCertificateV4SigningContent({ + artifactDigest, + nonce, + issuedAt, + expiresAt, + keyId, +} = {}) { + return canonicalStringify({ + schema: V4_PROOF_SCHEMA, + version: 4, + method: 'certificate', + artifact_digest: artifactDigest, + nonce, + issued_at: issuedAt, + expires_at: expiresAt, + key_id: keyId, + }); +} + function normalizeDigest(value) { return typeof value === 'string' ? value.replace(/^sha256:/, '') : null; } function resolveCanonicalManifest(ctx = {}) { - const source = ctx.manifest ?? ctx.manifestContent; + const artifactMode = ctx.artifactDigest != null || ctx.handoffArtifactDigest != null; + const source = artifactMode + ? ctx.artifactPayload + : (ctx.manifest ?? ctx.manifestContent); if (source === undefined || source === null) { - return { error: 'canonical manifest content is required for certificate proof of possession' }; + return { + error: artifactMode + ? 'canonical handoff artifact payload is required for certificate proof of possession' + : 'canonical manifest content is required for certificate proof of possession', + }; } - let manifest; + let signedObject; try { - if (Buffer.isBuffer(source)) manifest = JSON.parse(source.toString('utf8')); - else if (typeof source === 'string') manifest = JSON.parse(source); - else if (typeof source === 'object' && !Array.isArray(source)) manifest = source; - else return { error: 'manifest content must be a JSON object' }; + if (Buffer.isBuffer(source)) signedObject = JSON.parse(source.toString('utf8')); + else if (typeof source === 'string') signedObject = JSON.parse(source); + else if (typeof source === 'object' && !Array.isArray(source)) signedObject = source; + else return { error: 'signed content must be a JSON object' }; } catch (error) { - return { error: `manifest content must be valid JSON: ${error.message}` }; + return { error: `signed content must be valid JSON: ${error.message}` }; } - const content = canonicalStringify(manifest); + const content = canonicalStringify(signedObject); const digest = hashString(content); - if (ctx.manifestDigest && normalizeDigest(ctx.manifestDigest) !== normalizeDigest(digest)) { - return { error: 'provided manifest digest does not match canonical manifest content' }; + const expectedDigest = artifactMode + ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) + : ctx.manifestDigest; + if (expectedDigest && normalizeDigest(expectedDigest) !== normalizeDigest(digest)) { + return { + error: artifactMode + ? 'provided artifact digest does not match canonical artifact payload' + : 'provided manifest digest does not match canonical manifest content', + }; } - return { content, digest }; + return { content, digest, artifactMode }; } function parseCertificateProof(proof) { @@ -50,6 +85,11 @@ function parseCertificateProof(proof) { return { certificate: proof.certificate, signature: proof.signature, + artifact_digest: proof.artifact_digest, + nonce: proof.nonce, + issued_at: proof.issued_at, + expires_at: proof.expires_at, + key_id: proof.key_id, }; } if (typeof proof !== 'string' && !Buffer.isBuffer(proof)) { @@ -61,17 +101,111 @@ function parseCertificateProof(proof) { return { certificate: parsed.certificate, signature: parsed.signature, + artifact_digest: parsed.artifact_digest, + nonce: parsed.nonce, + issued_at: parsed.issued_at, + expires_at: parsed.expires_at, + key_id: parsed.key_id, }; } return { certificate: text, signature: null }; } -function verifyProofOfPossession(cert, signature, manifestContent) { +function validateV4CertificateEnvelope(parsed, context) { + const v4 = context.handoffVersion === 4 || context.artifactDigest != null; + if (!v4) return { ok: true, v4: false }; + for (const field of [ + 'certificate', + 'signature', + 'artifact_digest', + 'nonce', + 'issued_at', + 'expires_at', + 'key_id', + ]) { + if (typeof parsed[field] !== 'string' || parsed[field].length === 0) { + return { ok: false, v4: true, reason: `handoff v4 certificate proof is missing ${field}` }; + } + } + if (normalizeDigest(parsed.artifact_digest) !== normalizeDigest(context.artifactDigest)) { + return { ok: false, v4: true, reason: 'certificate proof artifact digest does not match' }; + } + const now = typeof context.now === 'number' ? context.now : Date.now(); + const issuedAt = Date.parse(parsed.issued_at); + const expiresAt = Date.parse(parsed.expires_at); + const skewMs = (context.clockSkewSeconds ?? 60) * 1000; + if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) { + return { ok: false, v4: true, reason: 'certificate proof has invalid issued_at or expires_at' }; + } + if (issuedAt > now + skewMs) { + return { ok: false, v4: true, reason: 'certificate proof issued_at is in the future' }; + } + if (expiresAt + skewMs <= now) { + return { ok: false, v4: true, reason: 'certificate proof has expired' }; + } + return { ok: true, v4: true }; +} + +function enforceV4CertificateGuards(parsed, context, profile, cert) { + const envelope = validateV4CertificateEnvelope(parsed, context); + if (!envelope.ok || !envelope.v4) { + return envelope.v4 + ? { ok: false, reason: envelope.reason } + : { ok: true, replayProtected: true, revocationChecked: true }; + } + + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (typeof claimReplay !== 'function') { + return { ok: false, reason: 'handoff v4 proof replay store is required' }; + } + const replay = claimReplay({ + method: 'certificate', + issuer: profile.issuer ?? cert.issuer ?? null, + subject: cert.subject ?? null, + proofId: parsed.nonce, + artifactDigest: context.artifactDigest, + expiresAt: parsed.expires_at, + runId: context.runId ?? null, + }); + if (replay && typeof replay.then === 'function') { + return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; + } + const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + if (!replayProtected) { + return { ok: false, reason: replay?.reason || 'certificate proof nonce was already used' }; + } + + const checkRevocation = context.checkProofRevocation + ?? context.revocationChecker?.check?.bind(context.revocationChecker); + if (typeof checkRevocation !== 'function') { + return { ok: false, reason: 'handoff v4 proof revocation checker is required' }; + } + const revocation = checkRevocation({ + method: 'certificate', + issuer: cert.issuer ?? profile.issuer ?? null, + subject: cert.subject ?? null, + proofId: parsed.nonce, + artifactDigest: context.artifactDigest, + keyId: parsed.key_id ?? cert.fingerprint256, + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint256, + }); + if (revocation && typeof revocation.then === 'function') { + return { ok: false, reason: 'handoff v4 revocation checker must complete synchronously' }; + } + if (revocation === true || revocation?.revoked === true) { + return { ok: false, reason: revocation?.reason || 'certificate proof is revoked' }; + } + return { ok: true, replayProtected: true, revocationChecked: true }; +} + +function verifyProofOfPossession(cert, signature, signedContent) { if (typeof signature !== 'string' || signature.trim() === '') { return { verified: false, reason: 'certificate proof is missing a manifest signature' }; } - if (!manifestContent) { - return { verified: false, reason: 'canonical manifest content is required' }; + if (!signedContent) { + return { verified: false, reason: 'canonical proof signing content is required' }; } let signatureBytes; @@ -91,13 +225,13 @@ function verifyProofOfPossession(cert, signature, manifestContent) { if (keyType === 'ed25519' || keyType === 'ed448') { verified = verifySignature( null, - Buffer.from(manifestContent, 'utf8'), + Buffer.from(signedContent, 'utf8'), cert.publicKey, signatureBytes ); } else if (keyType === 'rsa' || keyType === 'rsa-pss' || keyType === 'ec') { const verifier = createVerify('SHA256'); - verifier.update(manifestContent); + verifier.update(signedContent); verified = verifier.verify(cert.publicKey, signatureBytes); } else { return { verified: false, reason: `unsupported certificate key type: ${keyType}` }; @@ -132,7 +266,13 @@ export function resolveCertificateVerificationContext(profile = {}, ctx = {}) { caCert, caCertError, manifestContent: manifest.content || null, - manifestDigest: manifest.digest || null, + manifestDigest: manifest.artifactMode + ? (ctx.manifestDigest ?? null) + : (manifest.digest || null), + artifactDigest: manifest.artifactMode + ? manifest.digest + : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), + handoffVersion: manifest.artifactMode ? 4 : (ctx.handoffVersion ?? null), manifestContextError: manifest.error || null, requireProofOfPossession: ctx.requireProofOfPossession ?? true, }; @@ -322,6 +462,12 @@ const certificateVerifier = { */ verifyProof(proof, profile, ctx) { const context = resolveCertificateVerificationContext(profile, ctx || {}); + const verificationNowMs = typeof context.now === 'number' + ? context.now + : context.now instanceof Date + ? context.now.getTime() + : Date.now(); + const verifiedAt = new Date(verificationNowMs).toISOString(); const claims = (profile && profile.claims) || {}; let parsedProof; @@ -336,7 +482,25 @@ const certificateVerifier = { signature_verified: false, proof_of_possession_verified: false, manifest_digest: context.manifestDigest || null, - verified_at: new Date().toISOString(), + verified_at: verifiedAt, + }; + } + + const envelope = validateV4CertificateEnvelope(parsedProof, context); + if (!envelope.ok) { + return { + verified: false, + method: 'certificate', + reason: envelope.reason, + claims_validated: false, + signature_verified: false, + proof_of_possession_verified: false, + artifact_digest: context.artifactDigest ?? null, + artifact_bound: false, + replay_protected: false, + revocation_checked: false, + manifest_digest: context.manifestDigest || null, + verified_at: verifiedAt, }; } @@ -360,12 +524,12 @@ const certificateVerifier = { serial_number: null, fingerprint: null, manifest_digest: context.manifestDigest || null, - verified_at: new Date().toISOString(), + verified_at: verifiedAt, }; } // Check certificate validity period - const now = new Date(); + const now = new Date(verificationNowMs); const validFrom = new Date(cert.validFrom); const validTo = new Date(cert.validTo); let expired = false; @@ -451,16 +615,33 @@ const certificateVerifier = { signatureReason = `CA certificate resolution failed: ${context.caCertError}`; } + const possessionContent = envelope.v4 + ? buildCertificateV4SigningContent({ + artifactDigest: parsedProof.artifact_digest, + nonce: parsedProof.nonce, + issuedAt: parsedProof.issued_at, + expiresAt: parsedProof.expires_at, + keyId: parsedProof.key_id, + }) + : context.manifestContent; const possession = context.manifestContextError ? { verified: false, reason: context.manifestContextError } : context.requireProofOfPossession - ? verifyProofOfPossession(cert, parsedProof.signature, context.manifestContent) + ? verifyProofOfPossession(cert, parsedProof.signature, possessionContent) : { verified: true }; // Overall verification requires a trusted chain and proof that the holder // of the certificate private key signed the canonical manifest. const timeValid = !expired && !notYetValid; - const verified = claimsValid && timeValid && certificateUsageValid && signatureValid && possession.verified; + const cryptographicallyVerified = claimsValid + && timeValid + && certificateUsageValid + && signatureValid + && possession.verified; + const guards = cryptographicallyVerified + ? enforceV4CertificateGuards(parsedProof, context, profile || {}, cert) + : { ok: false }; + const verified = cryptographicallyVerified && guards.ok; // Build composite reason if not verified let reason = null; @@ -470,6 +651,7 @@ const certificateVerifier = { if (!timeValid || !certificateUsageValid) reasons.push(...validityErrors); if (!signatureValid && signatureReason) reasons.push(signatureReason); if (!possession.verified && possession.reason) reasons.push(possession.reason); + if (cryptographicallyVerified && !guards.ok && guards.reason) reasons.push(guards.reason); reason = reasons.join('; '); } @@ -488,7 +670,12 @@ const certificateVerifier = { serial_number: cert.serialNumber, fingerprint: cert.fingerprint256, manifest_digest: context.manifestDigest || null, - verified_at: new Date().toISOString(), + artifact_digest: context.artifactDigest ?? null, + artifact_bound: !envelope.v4 + || normalizeDigest(parsedProof.artifact_digest) === normalizeDigest(context.artifactDigest), + replay_protected: guards.replayProtected === true, + revocation_checked: guards.revocationChecked === true, + verified_at: verifiedAt, }; return result; @@ -516,6 +703,10 @@ const certificateVerifier = { proof_of_possession_verified: result.proof_of_possession_verified, fingerprint: result.fingerprint, serial_number: result.serial_number, + artifact_digest: result.artifact_digest || null, + artifact_bound: result.artifact_bound === true, + replay_protected: result.replay_protected === true, + revocation_checked: result.revocation_checked === true, reason: result.signature_verification_reason || result.reason || null, }; }, diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index 064b40e..ea8ce57 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -17,6 +17,26 @@ import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; +const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; + +export function buildDetachedSignatureV4SigningContent({ + artifactDigest, + nonce, + issuedAt, + expiresAt, + keyId, +} = {}) { + return canonicalStringify({ + schema: V4_PROOF_SCHEMA, + version: 4, + method: 'detached-signature', + artifact_digest: artifactDigest, + nonce, + issued_at: issuedAt, + expires_at: expiresAt, + key_id: keyId, + }); +} /** * Attempt to auto-detect a suitable verification algorithm from a PEM public key. @@ -52,39 +72,50 @@ function normalizeDigest(value) { } function canonicalManifestContext(ctx = {}) { - const manifestValue = ctx.manifest ?? ctx.manifestContent; - if (manifestValue === undefined || manifestValue === null) { - return { error: 'canonical manifest content is required' }; + const artifactMode = ctx.artifactDigest != null || ctx.handoffArtifactDigest != null; + const sourceValue = artifactMode + ? ctx.artifactPayload + : (ctx.manifest ?? ctx.manifestContent); + if (sourceValue === undefined || sourceValue === null) { + return { + error: artifactMode + ? 'canonical handoff artifact payload is required' + : 'canonical manifest content is required', + }; } - let manifest; - if (Buffer.isBuffer(manifestValue)) { + let source; + if (Buffer.isBuffer(sourceValue)) { try { - manifest = JSON.parse(manifestValue.toString('utf8')); + source = JSON.parse(sourceValue.toString('utf8')); } catch (error) { - return { error: `manifest content must be valid JSON: ${error.message}` }; + return { error: `signed content must be valid JSON: ${error.message}` }; } - } else if (typeof manifestValue === 'string') { + } else if (typeof sourceValue === 'string') { try { - manifest = JSON.parse(manifestValue); + source = JSON.parse(sourceValue); } catch (error) { - return { error: `manifest content must be valid JSON: ${error.message}` }; + return { error: `signed content must be valid JSON: ${error.message}` }; } - } else if (typeof manifestValue === 'object' && !Array.isArray(manifestValue)) { - manifest = manifestValue; + } else if (typeof sourceValue === 'object' && !Array.isArray(sourceValue)) { + source = sourceValue; } else { - return { error: 'manifest content must be a JSON object' }; + return { error: 'signed content must be a JSON object' }; } - const content = canonicalStringify(manifest); + const content = canonicalStringify(source); const digest = hashString(content); - if ( - ctx.manifestDigest && - normalizeDigest(ctx.manifestDigest) !== normalizeDigest(digest) - ) { - return { error: 'provided manifest digest does not match canonical manifest content' }; + const expectedDigest = artifactMode + ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) + : ctx.manifestDigest; + if (expectedDigest && normalizeDigest(expectedDigest) !== normalizeDigest(digest)) { + return { + error: artifactMode + ? 'provided artifact digest does not match canonical artifact payload' + : 'provided manifest digest does not match canonical manifest content', + }; } - return { content, digest }; + return { content, digest, artifactMode }; } export function resolveDetachedSignatureVerificationContext(profile = {}, ctx = {}) { @@ -96,7 +127,13 @@ export function resolveDetachedSignatureVerificationContext(profile = {}, ctx = return { ...ctx, manifestContent: canonical.content || null, - manifestDigest: canonical.digest || null, + manifestDigest: canonical.artifactMode + ? (ctx.manifestDigest ?? null) + : (canonical.digest || null), + artifactDigest: canonical.artifactMode + ? canonical.digest + : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), + handoffVersion: canonical.artifactMode ? 4 : (ctx.handoffVersion ?? null), manifestContextError: canonical.error || null, trustedKey: ctx.trustedKey || profile.public_key || null, allowedSignersPath, @@ -117,6 +154,115 @@ function decodeBase64Signature(proof) { return Buffer.from(normalized, 'base64'); } +function parseV4Envelope(proof, context) { + const v4 = context.handoffVersion === 4 || context.artifactDigest != null; + if (!v4) return { signature: proof, v4: false }; + + let envelope = proof; + if (Buffer.isBuffer(envelope)) envelope = envelope.toString('utf8'); + if (typeof envelope === 'string') { + try { + envelope = JSON.parse(envelope); + } catch { + return { error: 'handoff v4 detached proof must be a JSON envelope', v4: true }; + } + } + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + return { error: 'handoff v4 detached proof must be an object', v4: true }; + } + + for (const field of ['signature', 'artifact_digest', 'nonce', 'issued_at', 'expires_at', 'key_id']) { + if (typeof envelope[field] !== 'string' || envelope[field].length === 0) { + return { error: `handoff v4 detached proof is missing ${field}`, v4: true }; + } + } + if (normalizeDigest(envelope.artifact_digest) !== normalizeDigest(context.artifactDigest)) { + return { error: 'detached proof artifact digest does not match', v4: true }; + } + + const now = typeof context.now === 'number' ? context.now : Date.now(); + const issuedAt = Date.parse(envelope.issued_at); + const expiresAt = Date.parse(envelope.expires_at); + const skewMs = (context.clockSkewSeconds ?? 60) * 1000; + if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) { + return { error: 'detached proof has invalid issued_at or expires_at', v4: true }; + } + if (issuedAt > now + skewMs) { + return { error: 'detached proof issued_at is in the future', v4: true }; + } + if (expiresAt + skewMs <= now) { + return { error: 'detached proof has expired', v4: true }; + } + return { signature: envelope.signature, envelope, v4: true }; +} + +function enforceV4RuntimeGuards(parsed, context, profile) { + if (!parsed.v4) { + return { ok: true, replayProtected: true, revocationChecked: true }; + } + + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (typeof claimReplay !== 'function') { + return { ok: false, reason: 'handoff v4 proof replay store is required' }; + } + const replay = claimReplay({ + method: 'detached-signature', + issuer: profile.issuer ?? null, + proofId: parsed.envelope.nonce, + artifactDigest: context.artifactDigest, + expiresAt: parsed.envelope.expires_at, + runId: context.runId ?? null, + }); + if (replay && typeof replay.then === 'function') { + return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; + } + const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + if (!replayProtected) { + return { ok: false, reason: replay?.reason || 'detached proof nonce was already used' }; + } + + const checkRevocation = context.checkProofRevocation + ?? context.revocationChecker?.check?.bind(context.revocationChecker); + if (typeof checkRevocation !== 'function') { + return { ok: false, reason: 'handoff v4 proof revocation checker is required' }; + } + const revocation = checkRevocation({ + method: 'detached-signature', + issuer: profile.issuer ?? null, + proofId: parsed.envelope.nonce, + artifactDigest: context.artifactDigest, + keyId: parsed.envelope.key_id, + }); + if (revocation && typeof revocation.then === 'function') { + return { ok: false, reason: 'handoff v4 revocation checker must complete synchronously' }; + } + if (revocation === true || revocation?.revoked === true) { + return { ok: false, reason: revocation?.reason || 'detached proof key is revoked' }; + } + return { ok: true, replayProtected: true, revocationChecked: true }; +} + +function signedContent(parsed, context) { + if (!parsed.v4) return context.manifestContent; + return buildDetachedSignatureV4SigningContent({ + artifactDigest: parsed.envelope.artifact_digest, + nonce: parsed.envelope.nonce, + issuedAt: parsed.envelope.issued_at, + expiresAt: parsed.envelope.expires_at, + keyId: parsed.envelope.key_id, + }); +} + +function verifiedAt(context) { + const now = typeof context.now === 'number' + ? context.now + : context.now instanceof Date + ? context.now.getTime() + : Date.now(); + return new Date(now).toISOString(); +} + /** * Verify a detached signature using ssh-keygen -Y verify. * @@ -260,6 +406,23 @@ const detachedSignatureVerifier = { verifyProof(proof, profile, ctx) { const context = resolveDetachedSignatureVerificationContext(profile, ctx || {}); const digest = context.manifestDigest; + const parsed = parseV4Envelope(proof, context); + + if (parsed.error) { + return { + verified: false, + method: 'detached-signature', + issuer: profile?.issuer ?? null, + signature_verified: false, + signature_verification_reason: parsed.error, + manifest_digest: digest, + artifact_digest: context.artifactDigest ?? null, + artifact_bound: false, + replay_protected: false, + revocation_checked: false, + verified_at: verifiedAt(context), + }; + } if (context.manifestContextError || !context.manifestContent) { return { @@ -269,15 +432,15 @@ const detachedSignatureVerifier = { signature_verified: false, signature_verification_reason: context.manifestContextError || 'canonical manifest content is required', manifest_digest: digest, - verified_at: new Date().toISOString(), + verified_at: verifiedAt(context), }; } // SSH-style verification path if (context.allowedSignersPath) { const sshResult = verifySshSignature( - proof, - context.manifestContent, + parsed.signature, + signedContent(parsed, context), { allowedSignersPath: context.allowedSignersPath, principal: context.principal || 'agentcli', @@ -285,14 +448,24 @@ const detachedSignatureVerifier = { } ); + const guards = sshResult.verified + ? enforceV4RuntimeGuards(parsed, context, profile || {}) + : { ok: false, reason: sshResult.reason }; return { - verified: sshResult.verified, + verified: sshResult.verified && guards.ok, method: 'detached-signature', issuer: (profile && profile.issuer) || null, signature_verified: sshResult.verified, - signature_verification_reason: sshResult.verified ? null : sshResult.reason, + signature_verification_reason: sshResult.verified && guards.ok + ? null + : (guards.reason || sshResult.reason), manifest_digest: digest, - verified_at: new Date().toISOString(), + artifact_digest: context.artifactDigest ?? null, + artifact_bound: !parsed.v4 + || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), + replay_protected: guards.replayProtected === true, + revocation_checked: guards.revocationChecked === true, + verified_at: verifiedAt(context), }; } @@ -304,17 +477,17 @@ const detachedSignatureVerifier = { let signatureValid; try { - const signature = decodeBase64Signature(proof); + const signature = decodeBase64Signature(parsed.signature); if (algorithm === null) { signatureValid = verifySignature( null, - Buffer.from(context.manifestContent, 'utf8'), + Buffer.from(signedContent(parsed, context), 'utf8'), context.trustedKey, signature ); } else if (algorithm) { const verifier = createVerify(algorithm); - verifier.update(context.manifestContent); + verifier.update(signedContent(parsed, context)); signatureValid = verifier.verify(context.trustedKey, signature); } else { signatureValid = false; @@ -329,14 +502,24 @@ const detachedSignatureVerifier = { verificationReason = `signature verification error: ${err.message}`; } + const guards = signatureValid + ? enforceV4RuntimeGuards(parsed, context, profile || {}) + : { ok: false, reason: verificationReason }; return { - verified: signatureValid, + verified: signatureValid && guards.ok, method: 'detached-signature', issuer: (profile && profile.issuer) || null, signature_verified: signatureValid, - signature_verification_reason: signatureValid ? null : verificationReason, + signature_verification_reason: signatureValid && guards.ok + ? null + : (guards.reason || verificationReason), manifest_digest: digest, - verified_at: new Date().toISOString(), + artifact_digest: context.artifactDigest ?? null, + artifact_bound: !parsed.v4 + || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), + replay_protected: guards.replayProtected === true, + revocation_checked: guards.revocationChecked === true, + verified_at: verifiedAt(context), }; } @@ -348,7 +531,7 @@ const detachedSignatureVerifier = { signature_verified: false, signature_verification_reason: 'no trusted key available for detached signature verification', manifest_digest: digest, - verified_at: new Date().toISOString(), + verified_at: verifiedAt(context), }; }, @@ -370,6 +553,10 @@ const detachedSignatureVerifier = { manifest_digest: result.manifest_digest || null, verifier: 'detached-signature', signature_verified: result.signature_verified, + artifact_digest: result.artifact_digest || null, + artifact_bound: result.artifact_bound === true, + replay_protected: result.replay_protected === true, + revocation_checked: result.revocation_checked === true, reason: result.signature_verification_reason || result.reason || null, }; }, diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index cfa6b3d..3b19640 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -84,6 +84,10 @@ function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function normalizeDigest(value) { + return typeof value === 'string' ? value.replace(/^sha256:/, '') : null; +} + function normalizeAudience(value) { if (Array.isArray(value)) return value; if (value === undefined || value === null) return []; @@ -555,6 +559,12 @@ const jwtVerifier = { const context = ctx || {}; const signatureRequired = Boolean(context.requireSignature); const manifestBindingRequired = context.requireManifestBinding !== false; + const artifactDigest = context.artifactDigest ?? context.handoffArtifactDigest ?? null; + const v4Required = context.handoffVersion === 4 || context.handoff_version === 4 + || artifactDigest != null; + const clockSkewSeconds = v4Required + ? (context.clockSkewSeconds ?? 60) + : (context.clockSkewSeconds ?? 0); // Validate proof is a non-empty string if (!proof || typeof proof !== 'string') { @@ -616,8 +626,20 @@ const jwtVerifier = { } } - // Check expiry (exp claim) - const now = Math.floor(Date.now() / 1000); + // Check expiry and issuance claims. Handoff v4 requires an explicit + // bounded lifetime and replay identifier. + const now = Math.floor( + (typeof context.now === 'number' ? context.now : Date.now()) / 1000, + ); + if (v4Required && payload.exp === undefined) { + return { + verified: false, + method: 'jwt', + reason: 'handoff v4 JWT is missing required exp claim', + claims_validated: false, + signature_verified: false, + }; + } if (payload.exp !== undefined) { if (typeof payload.exp !== 'number') { return { @@ -628,7 +650,7 @@ const jwtVerifier = { signature_verified: false, }; } - if (now >= payload.exp) { + if (now >= payload.exp + clockSkewSeconds) { return { verified: false, method: 'jwt', @@ -650,7 +672,7 @@ const jwtVerifier = { signature_verified: false, }; } - if (now < payload.nbf) { + if (now + clockSkewSeconds < payload.nbf) { return { verified: false, method: 'jwt', @@ -661,6 +683,36 @@ const jwtVerifier = { } } + if (v4Required) { + if (typeof payload.iat !== 'number') { + return { + verified: false, + method: 'jwt', + reason: 'handoff v4 JWT is missing a numeric iat claim', + claims_validated: false, + signature_verified: false, + }; + } + if (payload.iat > now + clockSkewSeconds) { + return { + verified: false, + method: 'jwt', + reason: 'handoff v4 JWT iat claim is in the future', + claims_validated: false, + signature_verified: false, + }; + } + if (typeof payload.jti !== 'string' || payload.jti.length === 0) { + return { + verified: false, + method: 'jwt', + reason: 'handoff v4 JWT is missing required jti claim', + claims_validated: false, + signature_verified: false, + }; + } + } + // Validate declared claims from profile if (profile.claims && typeof profile.claims === 'object' && !Array.isArray(profile.claims)) { const claimsResult = validateDeclaredClaims(profile.claims, payload); @@ -693,6 +745,21 @@ const jwtVerifier = { } } + let artifactBound = !v4Required; + let artifactBindingReason = null; + if (v4Required) { + const claimedArtifact = payload.handoff_artifact_digest ?? payload.artifact_digest; + if (typeof artifactDigest !== 'string' || artifactDigest.length === 0) { + artifactBindingReason = 'trusted handoff artifact digest is required'; + } else if (typeof claimedArtifact !== 'string') { + artifactBindingReason = 'JWT is missing required handoff artifact digest claim'; + } else if (normalizeDigest(claimedArtifact) !== normalizeDigest(artifactDigest)) { + artifactBindingReason = 'JWT handoff artifact digest does not match the compiled artifact'; + } else { + artifactBound = true; + } + } + // Attempt signature verification let signatureVerified = false; let signatureReason = context.trustedKeyError || 'no trusted key available for signature verification'; @@ -705,6 +772,56 @@ const jwtVerifier = { signatureReason = signatureReason || 'signature required but no trusted key available'; } + let replayProtected = !v4Required; + let revocationChecked = !v4Required; + let runtimeGuardReason = null; + if (v4Required && signatureVerified && manifestBound && artifactBound) { + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (typeof claimReplay !== 'function') { + runtimeGuardReason = 'handoff v4 proof replay store is required'; + } else { + const replayResult = claimReplay({ + method: 'jwt', + issuer: payload.iss ?? profile.issuer ?? null, + subject: payload.sub ?? null, + proofId: payload.jti, + artifactDigest, + expiresAt: new Date(payload.exp * 1000).toISOString(), + runId: context.runId ?? null, + }); + if (replayResult && typeof replayResult.then === 'function') { + runtimeGuardReason = 'handoff v4 replay store must complete synchronously'; + } else { + replayProtected = replayResult === true || replayResult?.claimed === true + || replayResult?.ok === true; + if (!replayProtected) runtimeGuardReason = replayResult?.reason || 'JWT jti was already used'; + } + } + + const checkRevocation = context.checkProofRevocation + ?? context.revocationChecker?.check?.bind(context.revocationChecker); + if (!runtimeGuardReason && typeof checkRevocation !== 'function') { + runtimeGuardReason = 'handoff v4 proof revocation checker is required'; + } else if (!runtimeGuardReason) { + const revocationResult = checkRevocation({ + method: 'jwt', + issuer: payload.iss ?? profile.issuer ?? null, + subject: payload.sub ?? null, + proofId: payload.jti, + artifactDigest, + keyId: context.trustedKeyId || header.kid || null, + }); + if (revocationResult && typeof revocationResult.then === 'function') { + runtimeGuardReason = 'handoff v4 revocation checker must complete synchronously'; + } else if (revocationResult?.revoked === true || revocationResult === true) { + runtimeGuardReason = revocationResult?.reason || 'JWT proof is revoked'; + } else { + revocationChecked = true; + } + } + } + // Build audit-safe subset of decoded claims const decodedClaims = {}; if (payload.sub !== undefined) decodedClaims.sub = payload.sub; @@ -723,7 +840,11 @@ const jwtVerifier = { // Claims-only parsing is useful diagnostics, not authorization. A JWT is // verified only after cryptographic verification by a trusted key. - const verified = signatureVerified && manifestBound; + const verified = signatureVerified + && manifestBound + && artifactBound + && replayProtected + && revocationChecked; const result = { verified, @@ -735,6 +856,11 @@ const jwtVerifier = { signature_required: signatureRequired, manifest_binding_required: manifestBindingRequired, manifest_bound: manifestBound, + artifact_binding_required: v4Required, + artifact_bound: artifactBound, + artifact_digest: artifactDigest, + replay_protected: replayProtected, + revocation_checked: revocationChecked, decoded_claims: decodedClaims, key_id: context.trustedKeyId || header.kid || null, key_source: context.trustedKeySource || null, @@ -749,6 +875,8 @@ const jwtVerifier = { result.reason = [ !signatureVerified ? signatureReason : null, !manifestBound ? manifestBindingReason : null, + !artifactBound ? artifactBindingReason : null, + runtimeGuardReason, ].filter(Boolean).join('; ') || 'JWT verification failed'; } @@ -778,6 +906,11 @@ const jwtVerifier = { signature_required: result.signature_required, manifest_binding_required: result.manifest_binding_required, manifest_bound: result.manifest_bound, + artifact_binding_required: result.artifact_binding_required === true, + artifact_bound: result.artifact_bound === true, + artifact_digest: result.artifact_digest || null, + replay_protected: result.replay_protected === true, + revocation_checked: result.revocation_checked === true, decoded_claims: result.decoded_claims || null, key_id: result.key_id || null, key_source: result.key_source || null, diff --git a/src/capabilities.js b/src/capabilities.js index 2ab6f04..a969db0 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -1,5 +1,23 @@ import { TARGETS } from './targets.js'; +export const HANDOFF_V4_REQUIRED_FEATURES = Object.freeze([ + 'handoff_v4_artifact', + 'artifact_bound_proofs', + 'signed_or_provider_verified_evidence', + 'provider_session_cache', + 'credential_presentation', + 'source_run_bound_delegation', + 'immutable_runtime_events', +]); + +export function supportsSchedulerHandoffV4(effectiveCapabilities = {}) { + const version = Number.parseInt(String(effectiveCapabilities.handoff_version ?? '0'), 10); + const features = effectiveCapabilities.features ?? {}; + return Number.isInteger(version) + && version >= 4 + && HANDOFF_V4_REQUIRED_FEATURES.every(feature => features[feature] === true); +} + /** * Query the scheduler for its runtime capabilities. * @param {object} runner - scheduler CLI runner with queryCapabilities() method @@ -84,6 +102,19 @@ export function validateManifestCapabilities(compiledOutput, effectiveFeatures) // validation remains execution-time only (chains are only known after a // concrete session is resolved). for (const job of compiledOutput.jobs) { + if (Number(job.handoff_version) === 4) { + for (const feature of HANDOFF_V4_REQUIRED_FEATURES) { + if (features[feature] !== true) { + errors.push({ + code: 'capability_mismatch', + feature, + required_by: `handoff v4 job "${job.name || job.id}"`, + message: `Job "${job.name || job.id}" uses handoff v4 but the runtime does not advertise ${feature}`, + }); + } + } + } + if (job.approval_required && !job.parent_id && !features.root_approval_gate) { errors.push({ code: 'capability_mismatch', diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index ee5a650..7e30ee0 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -11,7 +11,13 @@ import { import { expandManifestShorthands } from '../shorthand.js'; import { canonicalDigest } from '../canonical.js'; import { renderShellExecution } from '../shell.js'; -import { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELDS_V03 } from '../scheduler-fields.js'; +import { buildSchedulerHandoffV4Artifact } from '../handoff/v4.js'; +import { + SCHEDULER_FIELDS_V1, + SCHEDULER_FIELDS_V02, + SCHEDULER_FIELDS_V03, + SCHEDULER_FIELDS_V04, +} from '../scheduler-fields.js'; const TRIGGERED_SENTINEL_CRON = '0 0 31 2 *'; const TRIGGERED_SENTINEL_TZ = 'UTC'; @@ -153,16 +159,10 @@ function sanitizeAuthorizationDeclaration(authorization) { function sanitizeAuthorizationProofValueFrom(valueFrom) { if (!valueFrom) return null; - const sanitized = { - env: valueFrom.env ?? null, - file: valueFrom.file ?? null, - }; - - if (!sanitized.env && !sanitized.file) { - return null; - } - - return sanitized; + const sanitized = {}; + if (valueFrom.env != null) sanitized.env = valueFrom.env; + if (valueFrom.file != null) sanitized.file = valueFrom.file; + return Object.keys(sanitized).length > 0 ? sanitized : null; } function sanitizeAuthorizationProofDeclaration(authorizationProof) { @@ -261,7 +261,18 @@ function validateSchedulerShellInputs(errors, taskPath, plan) { } } -export function compileManifestToScheduler(manifest, { includeExplain = false } = {}) { +export function compileManifestToScheduler( + manifest, + { + includeExplain = false, + schedulerHandoffVersion = '3', + cwd = process.cwd(), + env = process.env, + } = {}, +) { + if (!['1', '2', '3', '4'].includes(String(schedulerHandoffVersion))) { + throw new TypeError('schedulerHandoffVersion must be one of 1, 2, 3, or 4'); + } const validation = validateManifest(manifest); if (!validation.ok) { const err = new Error('Manifest validation failed'); @@ -404,6 +415,24 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } delete_after_run: plan.delete_after_run ? 1 : 0 }; + + if (String(schedulerHandoffVersion) === '4') { + const artifact = buildSchedulerHandoffV4Artifact({ + manifest, + expanded, + workflow, + task, + plan, + job, + cwd, + env, + }); + job.handoff_version = 4; + job.handoff_artifact_digest = artifact.digest; + job.handoff_artifact_payload = artifact.payload; + job.effective_task_hash = artifact.effectiveTaskHash; + } + validateSchedulerStringLimits(targetErrors, taskPath, job); validateSchedulerReservedValues(targetErrors, taskPath, job); validateSchedulerShellInputs(targetErrors, taskPath, plan); @@ -482,10 +511,14 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } target: 'openclaw-scheduler', version: '0.2', handoff: { - field_version: '3', + field_version: String(schedulerHandoffVersion), v1_field_count: SCHEDULER_FIELDS_V1.length, v2_field_count: SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length, v3_field_count: SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length + SCHEDULER_FIELDS_V03.length, + v4_field_count: SCHEDULER_FIELDS_V1.length + + SCHEDULER_FIELDS_V02.length + + SCHEDULER_FIELDS_V03.length + + SCHEDULER_FIELDS_V04.length, }, jobs, ...profiles, diff --git a/src/evidence/index.js b/src/evidence/index.js index 5c4c11c..dac2e70 100644 --- a/src/evidence/index.js +++ b/src/evidence/index.js @@ -10,6 +10,8 @@ * describe - (envelope, ctx) => produce audit-safe metadata about the evidence */ +import { validateEvidenceRecordBinding } from './payload.js'; + const providers = new Map(); export function registerEvidenceProvider(provider) { @@ -96,6 +98,15 @@ export async function verifyEvidenceEnvelope(envelope, options = {}, ctx = {}) { try { const result = await provider.verify(envelope, options, ctx); + if (result?.verified === true && options.record) { + const binding = validateEvidenceRecordBinding(result.payload, options.record); + if (!binding.valid) { + return { + verified: false, + reason: `evidence binding failed: ${binding.errors.join('; ')}`, + }; + } + } return result?.verified === true ? result : { diff --git a/src/evidence/payload.js b/src/evidence/payload.js index ec21c67..41a6721 100644 --- a/src/evidence/payload.js +++ b/src/evidence/payload.js @@ -101,6 +101,9 @@ export function buildCompleteEvidencePayload({ manifestDigest, effectiveTask, effectiveTaskHash, + handoffArtifactDigest = null, + sourceRunId = null, + sourceRunHandoffArtifactDigest = null, declaredIdentity = null, resolvedIdentity = null, authorizationProof = null, @@ -150,6 +153,13 @@ export function buildCompleteEvidencePayload({ bindings: { manifest_digest: resolvedManifestDigest, effective_task_hash: resolvedTaskHash, + ...(handoffArtifactDigest != null + ? { + handoff_artifact_digest: handoffArtifactDigest, + source_run_id: sourceRunId, + source_run_handoff_artifact_digest: sourceRunHandoffArtifactDigest, + } + : {}), }, declared_identity: redactSensitiveEvidence(declaredIdentity), resolved_identity: redactSensitiveEvidence(resolvedIdentity), @@ -203,6 +213,21 @@ export function validateCompleteEvidencePayload(payload) { errors.push(`bindings.${field} must be a SHA-256 digest`); } } + for (const field of ['handoff_artifact_digest', 'source_run_handoff_artifact_digest']) { + const value = payload.bindings[field]; + if (value != null && !/^sha256:[a-f0-9]{64}$/.test(value)) { + errors.push(`bindings.${field} must be null or a SHA-256 digest`); + } + } + if (payload.bindings.source_run_id != null + && (typeof payload.bindings.source_run_id !== 'string' + || payload.bindings.source_run_id.length === 0)) { + errors.push('bindings.source_run_id must be null or a non-empty string'); + } + if ((payload.bindings.source_run_id == null) + !== (payload.bindings.source_run_handoff_artifact_digest == null)) { + errors.push('source run id and source run artifact digest must be declared together'); + } } if (!payload.command || typeof payload.command !== 'object' || Array.isArray(payload.command)) { errors.push('command must be an object'); @@ -293,6 +318,17 @@ export function validateEvidenceRecordBinding(payload, record) { if (payload.bindings?.effective_task_hash !== record.effective_task_hash) { errors.push('effective task hash does not match the audit record'); } + if ((payload.bindings?.handoff_artifact_digest ?? null) + !== (record.handoff_artifact_digest ?? null)) { + errors.push('handoff artifact digest does not match the audit record'); + } + if ((payload.bindings?.source_run_id ?? null) !== (record.source_run_id ?? null)) { + errors.push('source run id does not match the audit record'); + } + if ((payload.bindings?.source_run_handoff_artifact_digest ?? null) + !== (record.source_run_handoff_artifact_digest ?? null)) { + errors.push('source run artifact digest does not match the audit record'); + } const recordOutputHash = record.result?.output_hash ?? record.hashes?.result ?? null; if (payload.result?.output_hash !== recordOutputHash) { errors.push('result output hash does not match the audit record'); diff --git a/src/handoff/index.js b/src/handoff/index.js new file mode 100644 index 0000000..05e9be5 --- /dev/null +++ b/src/handoff/index.js @@ -0,0 +1,11 @@ +export { + HANDOFF_V4_SCHEMA, + HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + HANDOFF_V4_VERSION, + HANDOFF_V4_SCHEDULER_SCHEMA_MIN, + HANDOFF_V4_CANONICALIZATION, + HANDOFF_V4_CANONICALIZATION_VERSION, + HANDOFF_V4_EXECUTION_BINDING_VERSION, + buildSchedulerHandoffV4Artifact, + validateSchedulerHandoffV4Artifact, +} from './v4.js'; diff --git a/src/handoff/v4.js b/src/handoff/v4.js new file mode 100644 index 0000000..a810d62 --- /dev/null +++ b/src/handoff/v4.js @@ -0,0 +1,699 @@ +import { + canonicalDigest, + canonicalStringify, + hashNullableString, + hashString, +} from '../canonical.js'; +import { + buildEffectiveExecutionBinding, + computeEffectiveTaskHash, +} from '../compiler/shared.js'; + +export const HANDOFF_V4_SCHEMA = 'openclaw.scheduler.handoff-artifact'; +export const HANDOFF_V4_ARTIFACT_SCHEMA_VERSION = 1; +export const HANDOFF_V4_VERSION = 4; +export const HANDOFF_V4_SCHEDULER_SCHEMA_MIN = 29; +export const HANDOFF_V4_CANONICALIZATION = 'json-sort-v1'; +export const HANDOFF_V4_CANONICALIZATION_VERSION = 1; +export const HANDOFF_V4_EXECUTION_BINDING_VERSION = 2; + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; +const CRYPTOGRAPHIC_PROOF_METHODS = new Set(['jwt', 'detached-signature', 'certificate']); +const PRESENTATION_MEDIA = new Set(['none', 'env', 'temp-file', 'stdin', 'gateway-env-header']); + +function hashObject(value) { + return value == null ? null : canonicalDigest(value); +} + +function normalizeJsonValue(value) { + if (value == null || typeof value !== 'string') return value ?? null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function schedulerJobExecutionProjection(job) { + return { + invocation: { + name: job.name ?? null, + schedule_kind: job.schedule_kind ?? 'cron', + schedule_at: job.schedule_at ?? null, + schedule_cron: job.schedule_cron ?? null, + schedule_tz: job.schedule_tz ?? 'UTC', + parent_id: job.parent_id ?? null, + trigger_on: job.trigger_on ?? null, + trigger_delay_s: job.trigger_delay_s ?? 0, + trigger_condition: job.trigger_condition ?? null, + origin: job.origin ?? null, + }, + reliability: { + overlap_policy: job.overlap_policy ?? 'skip', + max_retries: job.max_retries ?? 0, + max_queued_dispatches: job.max_queued_dispatches ?? 25, + max_pending_approvals: job.max_pending_approvals ?? 10, + max_trigger_fanout: job.max_trigger_fanout ?? 100, + delivery_guarantee: job.delivery_guarantee ?? 'at-most-once', + }, + delivery: { + mode: job.delivery_mode ?? 'none', + channel: job.delivery_channel ?? null, + to: job.delivery_to ?? null, + opt_out_reason: job.delivery_opt_out_reason ?? null, + }, + context: { + retrieval: job.context_retrieval ?? 'none', + retrieval_limit: job.context_retrieval_limit ?? 5, + }, + lifecycle: { + enabled: Boolean(job.enabled), + delete_after_run: Boolean(job.delete_after_run), + }, + target: { + session_target: job.session_target ?? null, + agent_id: job.agent_id ?? 'main', + payload_kind: job.payload_kind ?? null, + }, + command: { + payload_message_sha256: hashString(job.payload_message ?? ''), + }, + runtime: { + run_timeout_ms: job.run_timeout_ms ?? 300000, + payload_timeout_seconds: job.payload_timeout_seconds ?? 120, + payload_model: job.payload_model ?? null, + payload_model_fallback: job.payload_model_fallback ?? null, + payload_thinking: job.payload_thinking ?? null, + preferred_session_key: job.preferred_session_key ?? null, + auth_profile: job.auth_profile ?? null, + auth_profile_fallback: job.auth_profile_fallback ?? null, + shell_env_policy: job.shell_env_policy ?? 'minimal', + }, + approval: { + required: Boolean(job.approval_required), + timeout_s: job.approval_timeout_s ?? null, + auto: job.approval_auto ?? null, + risk_level: job.approval_risk_level ?? null, + approver_scope: job.approval_approver_scope ?? null, + }, + output: { + format: job.output_format ?? null, + store_limit_bytes: job.output_store_limit_bytes ?? null, + excerpt_limit_bytes: job.output_excerpt_limit_bytes ?? null, + summary_limit_bytes: job.output_summary_limit_bytes ?? null, + offload_threshold_bytes: job.output_offload_threshold_bytes ?? null, + }, + identity: { + principal: job.identity_principal ?? null, + run_as: job.identity_run_as ?? null, + attestation: job.identity_attestation ?? null, + ref: job.identity_ref ?? null, + subject_kind: job.identity_subject_kind ?? null, + subject_principal: job.identity_subject_principal ?? null, + trust_level: job.identity_trust_level ?? null, + delegation_mode: job.identity_delegation_mode ?? null, + declaration: normalizeJsonValue(job.identity), + }, + authorization_proof: { + ref: job.authorization_proof_ref ?? null, + declaration: normalizeJsonValue(job.authorization_proof), + }, + authorization: { + ref: job.authorization_ref ?? null, + declaration: normalizeJsonValue(job.authorization), + }, + evidence: { + ref: job.evidence_ref ?? null, + declaration: normalizeJsonValue(job.evidence), + }, + contract: { + required_trust_level: job.contract_required_trust_level ?? null, + trust_enforcement: job.contract_trust_enforcement ?? null, + sandbox: job.contract_sandbox ?? null, + allowed_paths: normalizeJsonValue(job.contract_allowed_paths), + network: job.contract_network ?? null, + max_cost_usd: job.contract_max_cost_usd ?? null, + audit: job.contract_audit ?? null, + }, + verification: { + shell_sha256: hashNullableString(job.verify_shell), + timeout_s: job.verify_timeout_s ?? null, + on_failure: job.verify_on_failure ?? null, + }, + child_credential_policy: job.child_credential_policy ?? null, + intent: { + mode: job.execution_intent ?? 'execute', + read_only: Boolean(job.execution_read_only), + }, + }; +} + +function requiredBoolean(value, path, errors) { + if (typeof value !== 'boolean') errors.push(`${path} must be a boolean`); +} + +function validateHash(value, path, errors, { required = false } = {}) { + if (value == null) { + if (required) errors.push(`${path} is required`); + return; + } + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + errors.push(`${path} must be a lowercase sha256 digest`); + } +} + +function validateHashCollection(value, path, errors) { + if (Array.isArray(value)) { + value.forEach((item, index) => validateHash(item, `${path}[${index}]`, errors, { required: true })); + return; + } + if (!value || typeof value !== 'object') { + errors.push(`${path} must be an object or array of sha256 digests`); + return; + } + for (const [key, digest] of Object.entries(value)) { + validateHash(digest, `${path}.${key}`, errors, { required: true }); + } +} + +function normalizePayload(payload) { + if (typeof payload === 'string') { + try { + return JSON.parse(payload); + } catch (error) { + throw Object.assign(new TypeError(`Invalid handoff v4 JSON: ${error.message}`), { + code: 'HANDOFF_ARTIFACT_INVALID', + }); + } + } + return payload; +} + +function presentationMediumForTarget(target, sessionTarget) { + const kind = target?.kind ?? 'none'; + if (kind === 'env') { + return sessionTarget === 'isolated' ? 'gateway-env-header' : 'env'; + } + if (kind === 'file') return 'temp-file'; + if (kind === 'stdin') return 'stdin'; + if (kind === 'none') return 'none'; + throw Object.assign(new Error(`Unsupported credential presentation target kind: ${kind}`), { + code: 'HANDOFF_PRESENTATION_UNSUPPORTED', + }); +} + +function presentationBindings(identity, sessionTarget) { + const presentation = identity?.presentation; + if (!presentation || typeof presentation !== 'object') return []; + const declared = presentation.bindings; + if (!Array.isArray(declared)) return []; + return declared.map(binding => { + const target = binding.target ?? {}; + const medium = presentationMediumForTarget(target, sessionTarget); + return { + name: binding.name ?? binding.source ?? null, + medium, + env_key: target.kind === 'env' + ? target.name ?? null + : target.kind === 'file' + ? target.expose_as ?? null + : null, + file_name: target.kind === 'file' + ? target.name ?? target.prefix ?? null + : null, + source_hash: hashNullableString(binding.source), + required: binding.required ?? true, + redact: binding.redact ?? presentation.default_redaction ?? true, + format: binding.format ?? 'raw', + }; + }); +} + +function runtimePresentation(identity, sessionTarget) { + const bindings = presentationBindings(identity, sessionTarget); + const media = [...new Set(bindings.map(binding => binding.medium).filter(medium => medium !== 'none'))]; + if (media.length > 1) { + throw Object.assign( + new Error(`Handoff v4 requires one credential presentation medium per task, received: ${media.join(', ')}`), + { code: 'HANDOFF_PRESENTATION_MIXED_MEDIA' }, + ); + } + return { + mode: identity?.presentation?.handoff ?? 'none', + handoff: media[0] ?? 'none', + bindings, + default_redaction: identity?.presentation?.default_redaction ?? null, + cleanup: identity?.presentation?.cleanup ?? 'always', + }; +} + +function proofBinding(binding) { + const proof = binding.authorization_proof; + if (!proof) { + return { + ref: null, + method: null, + issuer: null, + audience: null, + claims_hash: null, + proof_source_hash: null, + artifact_binding_required: false, + replay_protection_required: false, + revocation_check_required: false, + }; + } + const cryptographic = CRYPTOGRAPHIC_PROOF_METHODS.has(proof.method); + return { + ref: proof.ref ?? null, + method: proof.method ?? null, + issuer: proof.issuer ?? null, + audience: proof.audience ?? null, + claims_hash: proof.claims_hash ?? null, + proof_source_hash: proof.proof_hash ?? null, + verification_context_hash: hashObject({ + jwks_uri: proof.jwks_uri ?? null, + public_key_hash: proof.public_key_hash ?? null, + allowed_signers: proof.allowed_signers ?? null, + principal: proof.principal ?? null, + namespace: proof.namespace ?? null, + ca_certificate_hash: proof.ca_certificate_hash ?? null, + ca_certificate_from_hash: proof.ca_certificate_from_hash ?? null, + verify: proof.verify ?? null, + }), + artifact_binding_required: cryptographic, + replay_protection_required: cryptographic, + revocation_check_required: cryptographic, + }; +} + +function identityBinding(binding) { + const identity = binding.identity; + if (!identity) { + return { + ref: null, + provider: null, + scope: null, + subject_kind: null, + subject_principal: null, + subject_hash: null, + auth_hash: null, + trust_level: null, + delegation_mode: null, + presentation: { + mode: 'none', + handoff: 'none', + bindings: [], + default_redaction: null, + cleanup: 'always', + }, + }; + } + const presentation = runtimePresentation(identity, binding.target?.session_target); + return { + ref: identity.ref ?? null, + provider: identity.provider ?? null, + scope: identity.scope ?? null, + subject_kind: identity.subject?.kind ?? null, + subject_principal: identity.subject?.principal ?? null, + subject_hash: hashObject(identity.subject), + auth_hash: hashObject(identity.auth), + trust_level: identity.trust?.level ?? null, + delegation_mode: identity.subject?.delegation_mode ?? null, + presentation, + }; +} + +function authorizationBinding(binding) { + const authorization = binding.authorization; + if (!authorization) { + return { + ref: null, + provider: null, + policy_digest: null, + on_error: null, + request_hash: null, + decision_hash: null, + }; + } + return { + ref: authorization.ref ?? null, + provider: authorization.provider ?? null, + policy_digest: authorization.provider_config_hash ?? null, + on_error: authorization.on_error ?? null, + request_hash: hashObject(authorization.request), + decision_hash: hashObject(authorization.decision), + }; +} + +function evidenceBinding(binding) { + const evidence = binding.evidence; + if (!evidence) { + return { + ref: null, + provider: null, + methods: [], + payload_bind: [], + verify_required: false, + retention: null, + signed_or_provider_verified_required: false, + }; + } + return { + ref: evidence.ref ?? null, + provider: evidence.provider ?? null, + methods: Array.isArray(evidence.methods) ? evidence.methods : [], + payload_bind: Array.isArray(evidence.payload?.bind) + ? evidence.payload.bind + : Array.isArray(evidence.payload?.bind_targets) + ? evidence.payload.bind_targets + : [], + payload_hash: hashObject(evidence.payload), + provider_config_hash: evidence.provider_config_hash ?? null, + verify_required: evidence.verify?.required === true, + retention: evidence.payload?.retention ?? null, + signed_or_provider_verified_required: true, + }; +} + +function commandBinding(binding, task, job) { + const command = binding.command; + const payloadKind = job.payload_kind ?? null; + const kind = payloadKind === 'shellCommand' + ? 'shell' + : payloadKind === 'agentTurn' + ? 'prompt' + : 'system'; + const argsHashes = command?.args_hashes ?? []; + return { + kind, + program: command?.program ?? null, + args_count: command?.args_count ?? 0, + args_sha256: argsHashes, + argv_sha256: command ? canonicalDigest([command.program, ...argsHashes]) : null, + cwd: command?.cwd ?? null, + stdin_sha256: command?.stdin_hash ?? null, + prompt_sha256: hashNullableString(task.prompt), + input_sha256: hashObject(task.input), + payload_message_sha256: hashString(job.payload_message ?? ''), + env: { + declared_env_sha256: hashObject(task.shell?.env), + effective_env_keys: command?.env_keys ?? [], + effective_env_value_sha256: command?.env_hashes ?? {}, + }, + }; +} + +export function buildSchedulerHandoffV4Artifact({ + manifest, + expanded = manifest, + workflow, + task, + plan, + job, + cwd = process.cwd(), + env = process.env, +} = {}) { + if (!manifest || !workflow || !task || !plan || !job) { + throw new TypeError('manifest, workflow, task, plan, and job are required'); + } + + const executionBinding = buildEffectiveExecutionBinding({ + manifest, + expanded, + workflow, + task, + cwd, + env, + timeoutMs: job.run_timeout_ms, + instanceId: null, + }); + const effectiveTaskHash = computeEffectiveTaskHash(executionBinding); + const identity = identityBinding(executionBinding); + const proof = proofBinding(executionBinding); + const authorization = authorizationBinding(executionBinding); + const evidence = evidenceBinding(executionBinding); + const contract = executionBinding.contract ?? {}; + const allowedDelegators = Array.isArray( + executionBinding.identity?.auth?.delegation_policy?.allowed_delegators, + ) + ? [...new Set(executionBinding.identity.auth.delegation_policy.allowed_delegators)].sort() + : []; + const delegationPolicy = executionBinding.identity?.auth?.delegation_policy ?? {}; + + const payload = { + schema: HANDOFF_V4_SCHEMA, + artifact_schema_version: HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + handoff_version: HANDOFF_V4_VERSION, + scheduler_schema_min: HANDOFF_V4_SCHEDULER_SCHEMA_MIN, + canonicalization: { + name: HANDOFF_V4_CANONICALIZATION, + version: HANDOFF_V4_CANONICALIZATION_VERSION, + digest: 'sha256', + undefined: 'null', + }, + execution_binding_version: HANDOFF_V4_EXECUTION_BINDING_VERSION, + scheduler_job_binding: { + version: 1, + digest: canonicalDigest(schedulerJobExecutionProjection(job)), + }, + manifest: { + version: expanded?.version ?? manifest.version ?? null, + digest: canonicalDigest(manifest), + workflow_id: workflow.id, + task_id: task.id, + }, + compiled: { + target: 'openclaw-scheduler', + job_id: job.id, + effective_task_hash: effectiveTaskHash, + source: { + workflow_id: workflow.id, + task_id: task.id, + }, + }, + lifecycle: { + enabled: Boolean(job.enabled), + delete_after_run: Boolean(job.delete_after_run), + target: { + session_target: job.session_target, + agent_id: job.agent_id ?? null, + payload_kind: job.payload_kind, + }, + }, + command: commandBinding(executionBinding, task, job), + runtime: { + timeout_ms: job.run_timeout_ms, + instance_id: { + kind: 'deferred', + source: 'run.id', + }, + }, + approval: { + required: Boolean(job.approval_required), + timeout_s: job.approval_timeout_s, + auto: job.approval_auto, + risk_level: job.approval_risk_level ?? null, + approver_scope: job.approval_approver_scope ?? null, + }, + identity, + contract: { + required_trust_level: contract.required_trust_level ?? null, + trust_enforcement: contract.trust_enforcement ?? null, + sandbox: contract.sandbox ?? null, + allowed_paths_sha256: hashObject(contract.allowed_paths), + network: contract.network ?? null, + max_cost_usd: contract.max_cost_usd ?? null, + audit: contract.audit ?? null, + postcondition: { + output_format: job.output_format ?? null, + verify_shell_sha256: hashNullableString(job.verify_shell), + verify_timeout_s: job.verify_timeout_s ?? null, + verify_on_failure: job.verify_on_failure ?? null, + }, + }, + authorization_proof: proof, + authorization, + evidence, + verification: { + shell_sha256: hashNullableString(job.verify_shell), + timeout_s: job.verify_timeout_s ?? null, + on_failure: job.verify_on_failure ?? null, + }, + output: { + format: job.output_format ?? null, + store_limit_bytes: job.output_store_limit_bytes, + excerpt_limit_bytes: job.output_excerpt_limit_bytes, + summary_limit_bytes: job.output_summary_limit_bytes, + offload_threshold_bytes: job.output_offload_threshold_bytes, + }, + child_credential_policy: executionBinding.child_credential_policy ?? null, + intent: { + mode: executionBinding.intent?.mode ?? 'execute', + read_only: Boolean(executionBinding.intent?.read_only), + }, + delegation: { + mode: identity.delegation_mode ?? null, + source_binding: 'source_run_id', + max_depth: delegationPolicy.max_depth ?? plan.budgets?.max_iterations ?? 16, + target_scope: identity.scope ?? null, + allowed_delegators: allowedDelegators, + allowed_delegators_hash: hashObject(allowedDelegators), + require_grant_per_hop: delegationPolicy.require_grant_per_hop + ?? (identity.delegation_mode != null && identity.delegation_mode !== 'none'), + }, + }; + + const canonical = canonicalStringify(payload); + const digest = hashString(canonical); + const validation = validateSchedulerHandoffV4Artifact(payload, { expectedDigest: digest }); + if (!validation.ok) { + throw Object.assign( + new Error(`Generated handoff v4 artifact is invalid: ${validation.errors.join('; ')}`), + { code: 'HANDOFF_ARTIFACT_INVALID', errors: validation.errors }, + ); + } + return { payload, digest, effectiveTaskHash, canonical }; +} + +export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = {}) { + let payload; + try { + payload = normalizePayload(input); + } catch (error) { + return { ok: false, digest: null, errors: [error.message] }; + } + + const errors = []; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return { ok: false, digest: null, errors: ['artifact payload must be an object'] }; + } + if (payload.schema !== HANDOFF_V4_SCHEMA) errors.push('schema is unsupported'); + if (payload.artifact_schema_version !== HANDOFF_V4_ARTIFACT_SCHEMA_VERSION) { + errors.push('artifact_schema_version must be 1'); + } + if (payload.handoff_version !== HANDOFF_V4_VERSION) errors.push('handoff_version must be 4'); + if (!Number.isInteger(payload.scheduler_schema_min) + || payload.scheduler_schema_min !== HANDOFF_V4_SCHEDULER_SCHEMA_MIN) { + errors.push('scheduler_schema_min must be exactly 29'); + } + if (payload.canonicalization?.name !== HANDOFF_V4_CANONICALIZATION + || payload.canonicalization?.version !== HANDOFF_V4_CANONICALIZATION_VERSION + || payload.canonicalization?.digest !== 'sha256' + || payload.canonicalization?.undefined !== 'null') { + errors.push('canonicalization contract is unsupported'); + } + if (payload.execution_binding_version !== HANDOFF_V4_EXECUTION_BINDING_VERSION) { + errors.push('execution_binding_version must be 2'); + } + + if (payload.scheduler_job_binding?.version !== 1) { + errors.push('scheduler_job_binding.version must be 1'); + } + validateHash( + payload.scheduler_job_binding?.digest, + 'scheduler_job_binding.digest', + errors, + { required: true }, + ); + validateHash(payload.manifest?.digest, 'manifest.digest', errors, { required: true }); + validateHash(payload.compiled?.effective_task_hash, 'compiled.effective_task_hash', errors, { required: true }); + validateHash(payload.command?.payload_message_sha256, 'command.payload_message_sha256', errors, { required: true }); + validateHashCollection(payload.command?.args_sha256, 'command.args_sha256', errors); + validateHashCollection( + payload.command?.env?.effective_env_value_sha256, + 'command.env.effective_env_value_sha256', + errors, + ); + for (const [path, value] of [ + ['command.argv_sha256', payload.command?.argv_sha256], + ['command.stdin_sha256', payload.command?.stdin_sha256], + ['command.prompt_sha256', payload.command?.prompt_sha256], + ['command.input_sha256', payload.command?.input_sha256], + ['command.env.declared_env_sha256', payload.command?.env?.declared_env_sha256], + ['identity.subject_hash', payload.identity?.subject_hash], + ['identity.auth_hash', payload.identity?.auth_hash], + ['authorization_proof.claims_hash', payload.authorization_proof?.claims_hash], + ['authorization_proof.proof_source_hash', payload.authorization_proof?.proof_source_hash], + ['authorization_proof.verification_context_hash', payload.authorization_proof?.verification_context_hash], + ['authorization.policy_digest', payload.authorization?.policy_digest], + ['authorization.request_hash', payload.authorization?.request_hash], + ['authorization.decision_hash', payload.authorization?.decision_hash], + ['evidence.payload_hash', payload.evidence?.payload_hash], + ['evidence.provider_config_hash', payload.evidence?.provider_config_hash], + ['verification.shell_sha256', payload.verification?.shell_sha256], + ['contract.allowed_paths_sha256', payload.contract?.allowed_paths_sha256], + ['contract.postcondition.verify_shell_sha256', payload.contract?.postcondition?.verify_shell_sha256], + ['delegation.allowed_delegators_hash', payload.delegation?.allowed_delegators_hash], + ]) { + validateHash(value, path, errors); + } + + requiredBoolean(payload.lifecycle?.enabled, 'lifecycle.enabled', errors); + requiredBoolean(payload.lifecycle?.delete_after_run, 'lifecycle.delete_after_run', errors); + requiredBoolean(payload.approval?.required, 'approval.required', errors); + requiredBoolean(payload.authorization_proof?.artifact_binding_required, 'authorization_proof.artifact_binding_required', errors); + requiredBoolean(payload.authorization_proof?.replay_protection_required, 'authorization_proof.replay_protection_required', errors); + requiredBoolean(payload.authorization_proof?.revocation_check_required, 'authorization_proof.revocation_check_required', errors); + requiredBoolean(payload.evidence?.signed_or_provider_verified_required, 'evidence.signed_or_provider_verified_required', errors); + requiredBoolean(payload.intent?.read_only, 'intent.read_only', errors); + requiredBoolean(payload.delegation?.require_grant_per_hop, 'delegation.require_grant_per_hop', errors); + const allowedDelegators = payload.delegation?.allowed_delegators; + if (!Array.isArray(allowedDelegators) + || allowedDelegators.some(value => typeof value !== 'string' || value.length === 0) + || new Set(allowedDelegators).size !== allowedDelegators.length + || allowedDelegators.some((value, index) => index > 0 && value < allowedDelegators[index - 1])) { + errors.push('delegation.allowed_delegators must be a sorted unique array of non-empty strings'); + } else if (payload.delegation.allowed_delegators_hash !== hashObject(allowedDelegators)) { + errors.push('delegation.allowed_delegators_hash does not match allowed_delegators'); + } + + const medium = payload.identity?.presentation?.handoff; + if (!PRESENTATION_MEDIA.has(medium)) { + errors.push('identity.presentation.handoff is unsupported'); + } + for (const [index, binding] of (payload.identity?.presentation?.bindings ?? []).entries()) { + if (!binding || typeof binding !== 'object') { + errors.push(`identity.presentation.bindings[${index}] must be an object`); + continue; + } + if ('value' in binding || 'credential' in binding || 'secret' in binding || 'token' in binding) { + errors.push(`identity.presentation.bindings[${index}] contains raw credential material`); + } + if (!PRESENTATION_MEDIA.has(binding.medium)) { + errors.push(`identity.presentation.bindings[${index}].medium is unsupported`); + } else if (binding.medium !== 'none' && binding.medium !== medium) { + errors.push(`identity.presentation.bindings[${index}].medium does not match presentation handoff`); + } + validateHash(binding.source_hash, `identity.presentation.bindings[${index}].source_hash`, errors); + requiredBoolean(binding.required, `identity.presentation.bindings[${index}].required`, errors); + requiredBoolean(binding.redact, `identity.presentation.bindings[${index}].redact`, errors); + } + + const proof = payload.authorization_proof ?? {}; + if (CRYPTOGRAPHIC_PROOF_METHODS.has(proof.method) + && (!proof.artifact_binding_required + || !proof.replay_protection_required + || !proof.revocation_check_required)) { + errors.push('cryptographic proof methods require artifact binding, replay protection, and revocation'); + } + if (payload.evidence?.provider && !payload.evidence.signed_or_provider_verified_required) { + errors.push('declared evidence providers require signed or provider-verified evidence'); + } + if (payload.delegation?.mode && payload.delegation.mode !== 'none' + && payload.delegation.source_binding !== 'source_run_id') { + errors.push('delegation must bind to source_run_id'); + } + + let digest = null; + try { + digest = canonicalDigest(payload); + } catch (error) { + errors.push(error.message); + } + if (expectedDigest != null) { + validateHash(expectedDigest, 'expectedDigest', errors, { required: true }); + if (digest && digest !== expectedDigest) errors.push('artifact digest does not match payload'); + } + + return { ok: errors.length === 0, digest, errors, payload }; +} diff --git a/src/inspect.js b/src/inspect.js index 37904d9..c5efdb7 100644 --- a/src/inspect.js +++ b/src/inspect.js @@ -18,6 +18,26 @@ const INSPECT_ENTITIES = { approvals: { table: 'approvals', orderBy: 'requested_at DESC' + }, + evidence: { + table: 'evidence_records', + orderBy: 'created_at DESC' + }, + artifacts: { + table: 'handoff_artifacts', + orderBy: 'created_at DESC' + }, + events: { + table: 'runtime_events', + orderBy: 'id DESC' + }, + provider_sessions: { + table: 'provider_sessions', + orderBy: 'updated_at DESC' + }, + credential_presentations: { + table: 'credential_presentations', + orderBy: 'created_at DESC' } }; diff --git a/src/scheduler-fields.js b/src/scheduler-fields.js index 0aba7f2..6b241a9 100644 --- a/src/scheduler-fields.js +++ b/src/scheduler-fields.js @@ -46,8 +46,21 @@ export const SCHEDULER_FIELDS_V03 = [ 'output_format', ]; +export const SCHEDULER_FIELDS_V04 = [ + 'handoff_version', + 'handoff_artifact_digest', + 'handoff_artifact_payload', + 'effective_task_hash', +]; + export const SCHEDULER_FIELD_VERSIONS = { '1': SCHEDULER_FIELDS_V1, '2': [...SCHEDULER_FIELDS_V1, ...SCHEDULER_FIELDS_V02], '3': [...SCHEDULER_FIELDS_V1, ...SCHEDULER_FIELDS_V02, ...SCHEDULER_FIELDS_V03], + '4': [ + ...SCHEDULER_FIELDS_V1, + ...SCHEDULER_FIELDS_V02, + ...SCHEDULER_FIELDS_V03, + ...SCHEDULER_FIELDS_V04, + ], }; diff --git a/src/targets.js b/src/targets.js index ef61ce5..47cb795 100644 --- a/src/targets.js +++ b/src/targets.js @@ -25,17 +25,24 @@ export const TARGETS = { context_retrieval: 'runtime', runtime_execution: true, identity_declaration: true, - runtime_identity_resolution: false, - evidence_generation: false, + runtime_identity_resolution: true, + evidence_generation: true, audit_export: true, - trust_evaluation: false, - delegation_validation: false, - credential_handoff: false, - authorization_proof_verification: false, - authorization_hook: false, - root_approval_gate: false, + trust_evaluation: true, + delegation_validation: true, + credential_handoff: true, + authorization_proof_verification: true, + authorization_hook: true, + root_approval_gate: true, approval_scope_enforcement: false, - structured_output_format: false, + structured_output_format: true, + handoff_v4_artifact: true, + artifact_bound_proofs: true, + signed_or_provider_verified_evidence: true, + provider_session_cache: true, + credential_presentation: true, + source_run_bound_delegation: true, + immutable_runtime_events: true, }, compile: compileManifestToScheduler, }, diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 9711330..dd7dd2f 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -985,13 +985,16 @@ test('applyManifestToScheduler plans and executes scheduler upserts', async () = assert.equal(calls[0].spec.enabled, true); }); -test('openclaw-scheduler target does not advertise unsupported v0.2 runtime features', () => { +test('openclaw-scheduler target advertises the current verified runtime feature baseline', () => { const target = listTargets().find(candidate => candidate.name === 'openclaw-scheduler'); assert.ok(target); - assert.equal(target.features.runtime_identity_resolution, false); - assert.equal(target.features.evidence_generation, false); - assert.equal(target.features.trust_evaluation, false); - assert.equal(target.features.delegation_validation, false); + assert.equal(target.features.runtime_identity_resolution, true); + assert.equal(target.features.evidence_generation, true); + assert.equal(target.features.trust_evaluation, true); + assert.equal(target.features.delegation_validation, true); + assert.equal(target.features.handoff_v4_artifact, true); + assert.equal(target.features.artifact_bound_proofs, true); + assert.equal(target.features.signed_or_provider_verified_evidence, true); }); test('applyManifestToScheduler projects only versioned runtime fields to backend specs', async () => { @@ -7516,13 +7519,11 @@ test('v0.2 scheduler compilation redacts provider inputs from durable specs', () assert.strictEqual(job.identity.auth.inputs, null); assert.deepStrictEqual(job.authorization_proof.proof.value_from, { env: 'SECRET_PROOF', - file: null, }); assert.strictEqual(job.authorization.provider_config, null); assert.strictEqual(job.evidence.provider_config, null); assert.deepStrictEqual(compiled.authorization_proof_profiles[0].proof.value_from, { env: 'SECRET_PROOF', - file: null, }); assert.strictEqual(compiled.identity_profiles[0].provider_config, null); assert.strictEqual(compiled.identity_profiles[0].auth.provider_config, null); @@ -7657,7 +7658,6 @@ test('applyManifestToScheduler returns authorization proof verification summarie assert.strictEqual(persistedProof.ref, 'jwt-proof'); assert.deepStrictEqual(persistedProof.proof.value_from, { env: 'TEST_AGENTCLI_JWT', - file: null, }); }); @@ -8114,7 +8114,8 @@ test('resolveEffectiveFeatures returns static features when no runtime capabilit const result = resolveEffectiveFeatures('openclaw-scheduler', null); assert.strictEqual(result.negotiated, false); assert.strictEqual(result.source, 'static'); - assert.strictEqual(result.features.authorization_hook, false); + assert.strictEqual(result.features.authorization_hook, true); + assert.strictEqual(result.features.handoff_v4_artifact, true); assert.strictEqual(result.features.runtime_execution, true); }); @@ -8371,7 +8372,10 @@ test('applyManifestToScheduler rejects unsupported trust and evidence capabiliti return { scheduler_version: '0.2.0', handoff_version: '3', - features: {}, + features: { + trust_evaluation: false, + evidence_generation: false, + }, }; }, listJobs() { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js new file mode 100644 index 0000000..e0f1aa6 --- /dev/null +++ b/test/handoff-v4.test.js @@ -0,0 +1,693 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, createSign } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + applyManifestToScheduler, + requiredSchedulerFieldVersion, + schedulerCreateSpec, +} from '../src/apply.js'; +import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; +import { + validateSchedulerHandoffV4Artifact, +} from '../src/handoff/v4.js'; +import { canonicalStringify } from '../src/canonical.js'; +import { jwtVerifier } from '../src/authorization-proof/jwt.js'; +import { + buildDetachedSignatureV4SigningContent, + detachedSignatureVerifier, +} from '../src/authorization-proof/detached-signature.js'; +import { + buildCertificateV4SigningContent, + certificateVerifier, +} from '../src/authorization-proof/certificate.js'; +import { listInspectableEntities } from '../src/inspect.js'; +import { registerProvider as registerIdentityProvider } from '../src/identity/index.js'; + +const V4_FEATURES = { + root_approval_gate: true, + approval_scope_enforcement: true, + structured_output_format: true, + runtime_execution: true, + identity_declaration: true, + runtime_identity_resolution: true, + evidence_generation: true, + audit_export: true, + trust_evaluation: true, + delegation_validation: true, + credential_handoff: true, + authorization_proof_verification: true, + authorization_hook: true, + handoff_v4_artifact: true, + artifact_bound_proofs: true, + signed_or_provider_verified_evidence: true, + provider_session_cache: true, + credential_presentation: true, + source_run_bound_delegation: true, + immutable_runtime_events: true, +}; + +const sharedConformanceFixture = JSON.parse(readFileSync( + join(import.meta.dirname, '..', 'fixtures', 'handoff-v4', 'conformance.json'), + 'utf8', +)); + +function applyFixtureChanges(payload, changes) { + const changed = structuredClone(payload); + for (const change of changes) { + let target = changed; + for (const key of change.path.slice(0, -1)) target = target[key]; + const key = change.path.at(-1); + if (change.op === 'delete') delete target[key]; + else target[key] = structuredClone(change.value); + } + return changed; +} + +function manifest() { + return { + version: '0.2', + workflows: [{ + id: 'v4-workflow', + name: 'V4 workflow', + tasks: [{ + id: 'root', + name: 'V4 root', + target: { session_target: 'shell' }, + shell: { program: 'printf', args: ['ok'] }, + schedule: { cron: '0 * * * *' }, + runtime: { timeout_ms: 120000 }, + output: { format: 'text' }, + }], + }], + }; +} + +function runner({ handoffVersion = '4', features = V4_FEATURES } = {}) { + const added = []; + return { + invocation: { label: 'mock-scheduler' }, + added, + queryCapabilities() { + return { + scheduler_version: 'test', + schema_version: 29, + handoff_version: handoffVersion, + features, + }; + }, + listJobs() { return []; }, + addJob(job) { + added.push(job); + return { ok: true, job }; + }, + updateJob() { throw new Error('unexpected update'); }, + deleteJob() { throw new Error('unexpected delete'); }, + }; +} + +function statefulRunner(initialJobs = []) { + const jobs = new Map(initialJobs.map(job => [job.id, structuredClone(job)])); + const history = []; + return { + invocation: { label: 'stateful-mock-scheduler' }, + history, + queryCapabilities() { + return { + scheduler_version: 'test', + schema_version: 29, + handoff_version: '4', + features: V4_FEATURES, + }; + }, + listJobs() { + return [...jobs.values()].map(job => structuredClone(job)); + }, + addJob(spec) { + assert.equal(typeof spec.id, 'string'); + assert.equal(jobs.has(spec.id), false); + const stored = structuredClone(spec); + jobs.set(spec.id, stored); + history.push({ action: 'add', id: spec.id, spec: structuredClone(spec) }); + return { ok: true, job: structuredClone(stored) }; + }, + updateJob(id, spec) { + assert.equal(jobs.has(id), true); + const stored = { ...jobs.get(id), ...structuredClone(spec), id }; + jobs.set(id, stored); + history.push({ action: 'update', id, spec: structuredClone(spec) }); + return { ok: true, job: structuredClone(stored) }; + }, + deleteJob(id) { + assert.equal(jobs.delete(id), true); + history.push({ action: 'delete', id }); + return { ok: true }; + }, + }; +} + +function base64url(value) { + return Buffer.from(value).toString('base64url'); +} + +function signJwt(payload, privateKey) { + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: 'test-key' })); + const body = base64url(JSON.stringify(payload)); + const signingInput = `${header}.${body}`; + const signer = createSign('RSA-SHA256'); + signer.update(signingInput); + return `${signingInput}.${signer.sign(privateKey).toString('base64url')}`; +} + +test('handoff v4 artifact is canonical, deterministic, and tamper evident', () => { + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }); + const job = compiled.jobs[0]; + assert.equal(job.handoff_version, 4); + assert.equal(requiredSchedulerFieldVersion(compiled.jobs), 4); + + const validation = validateSchedulerHandoffV4Artifact(job.handoff_artifact_payload, { + expectedDigest: job.handoff_artifact_digest, + }); + assert.equal(validation.ok, true, validation.errors.join('; ')); + assert.equal(job.effective_task_hash, job.handoff_artifact_payload.compiled.effective_task_hash); + + const reordered = JSON.parse(canonicalStringify(job.handoff_artifact_payload)); + assert.equal( + validateSchedulerHandoffV4Artifact(reordered, { + expectedDigest: job.handoff_artifact_digest, + }).ok, + true, + ); + + const tampered = structuredClone(job.handoff_artifact_payload); + tampered.runtime.timeout_ms += 1; + const tamperedValidation = validateSchedulerHandoffV4Artifact(tampered, { + expectedDigest: job.handoff_artifact_digest, + }); + assert.equal(tamperedValidation.ok, false); + assert.match(tamperedValidation.errors.join('; '), /digest does not match/); + + const future = structuredClone(job.handoff_artifact_payload); + future.scheduler_schema_min = 30; + const futureValidation = validateSchedulerHandoffV4Artifact(future); + assert.equal(futureValidation.ok, false); + assert.match(futureValidation.errors.join('; '), /exactly 29/); +}); + +test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { + const fixture = sharedConformanceFixture; + const [job] = compileManifestToScheduler(fixture.manifest, { + schedulerHandoffVersion: '4', + cwd: fixture.compile.cwd, + env: fixture.compile.env, + }).jobs; + assert.equal(job.handoff_artifact_digest, fixture.expected.artifact_digest); + assert.equal(job.handoff_artifact_payload.manifest.digest, fixture.expected.manifest_digest); + assert.equal(job.effective_task_hash, fixture.expected.effective_task_hash); + assert.equal( + job.handoff_artifact_payload.scheduler_job_binding.digest, + fixture.expected.scheduler_job_binding_digest, + ); + assert.equal(validateSchedulerHandoffV4Artifact(job.handoff_artifact_payload, { + expectedDigest: fixture.expected.artifact_digest, + }).ok, true); + + for (const negative of fixture.negative_artifact_cases) { + const validation = validateSchedulerHandoffV4Artifact( + applyFixtureChanges(job.handoff_artifact_payload, negative.changes), + negative.use_expected_digest + ? { expectedDigest: fixture.expected.artifact_digest } + : {}, + ); + assert.equal(validation.ok, false, negative.name); + assert.match(validation.errors.join('; '), new RegExp(negative.expected_error), negative.name); + } +}); + +test('handoff v4 maps declarative credential bindings to an exact runtime medium', () => { + registerIdentityProvider({ + name: 'handoff-v4-test-identity', + capabilities: { + auth_modes: ['service'], + credential_types: ['bearer'], + presentation_kinds: ['env', 'file', 'stdin'], + handoff_modes: ['none', 'transaction-token'], + trust_levels: ['supervised'], + approval_mechanisms: [], + refreshable: false, + delegation: false, + }, + validateProfile() { return { valid: true }; }, + resolveSession() { return { ok: true, session: { credentials: { token: { value: 'test' } } } }; }, + describeSession() { return { provider: 'handoff-v4-test-identity' }; }, + materialize() { return { materialized: true, env_vars: {} }; }, + cleanup() { return { cleaned: true }; }, + prepareHandoff(session) { return { prepared: true, session }; }, + }); + + const credentialManifest = target => ({ + version: '0.2', + identity_profiles: [{ + id: 'v4-credential', + provider: 'handoff-v4-test-identity', + subject: { kind: 'service', principal: 'agent://v4-credential-test' }, + auth: { mode: 'service', required: true }, + trust: { level: 'supervised' }, + presentation: { + handoff: 'transaction-token', + cleanup: 'always', + default_redaction: true, + bindings: [{ + source: 'credentials.token.value', + target, + required: true, + redact: true, + format: 'raw', + }], + }, + }], + workflows: [{ + id: 'credential-workflow', + name: 'Credential workflow', + tasks: [{ + id: 'credential-task', + name: 'Credential task', + target: { session_target: 'shell' }, + shell: { program: 'printf', args: ['ok'] }, + identity: { ref: 'v4-credential' }, + schedule: { cron: '0 * * * *' }, + runtime: { timeout_ms: 120000 }, + }], + }], + }); + + const envJob = compileManifestToScheduler( + credentialManifest({ kind: 'env', name: 'V4_RUNTIME_TOKEN' }), + { schedulerHandoffVersion: '4', cwd: '/tmp', env: { PATH: '/usr/bin' } }, + ).jobs[0]; + assert.equal(envJob.handoff_artifact_payload.identity.presentation.mode, 'transaction-token'); + assert.equal(envJob.handoff_artifact_payload.identity.presentation.handoff, 'env'); + assert.equal(envJob.handoff_artifact_payload.identity.presentation.bindings[0].env_key, 'V4_RUNTIME_TOKEN'); + assert.match( + envJob.handoff_artifact_payload.identity.presentation.bindings[0].source_hash, + /^sha256:[0-9a-f]{64}$/, + ); + + const fileJob = compileManifestToScheduler( + credentialManifest({ kind: 'file', name: 'token.txt', expose_as: 'V4_TOKEN_FILE' }), + { schedulerHandoffVersion: '4', cwd: '/tmp', env: { PATH: '/usr/bin' } }, + ).jobs[0]; + assert.equal(fileJob.handoff_artifact_payload.identity.presentation.handoff, 'temp-file'); + assert.equal(fileJob.handoff_artifact_payload.identity.presentation.bindings[0].file_name, 'token.txt'); + assert.equal(fileJob.handoff_artifact_payload.identity.presentation.bindings[0].env_key, 'V4_TOKEN_FILE'); + + const isolated = credentialManifest({ kind: 'env', name: 'V4_RUNTIME_TOKEN' }); + isolated.workflows[0].tasks[0] = { + id: 'credential-task', + name: 'Credential task', + target: { session_target: 'isolated', agent_id: 'main' }, + prompt: 'Use the credential-bound runtime.', + identity: { ref: 'v4-credential' }, + schedule: { cron: '0 * * * *' }, + runtime: { timeout_ms: 120000 }, + }; + const isolatedJob = compileManifestToScheduler(isolated, { + schedulerHandoffVersion: '4', cwd: '/tmp', env: { PATH: '/usr/bin' }, + }).jobs[0]; + assert.equal(isolatedJob.handoff_artifact_payload.identity.presentation.handoff, 'gateway-env-header'); +}); + +test('scheduler execution binding changes when execution controls change', () => { + const first = manifest(); + const scheduledDifferently = manifest(); + scheduledDifferently.workflows[0].tasks[0].schedule.cron = '15 * * * *'; + const formattedDifferently = manifest(); + formattedDifferently.workflows[0].tasks[0].output.format = 'json'; + + const compile = input => compileManifestToScheduler(input, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload.scheduler_job_binding.digest; + + assert.notEqual(compile(first), compile(scheduledDifferently)); + assert.notEqual(compile(first), compile(formattedDifferently)); +}); + +test('v4 builder rejects raw credential bindings', () => { + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + env: { PATH: '/usr/bin' }, + }); + const payload = structuredClone(compiled.jobs[0].handoff_artifact_payload); + payload.identity.presentation.bindings = [{ + name: 'token', + medium: 'env', + value: 'must-not-persist', + }]; + const result = validateSchedulerHandoffV4Artifact(payload); + assert.equal(result.ok, false); + assert.match(result.errors.join('; '), /raw credential material/); +}); + +test('scheduler projection emits artifact fields only for v4', () => { + const v3Job = compileManifestToScheduler(manifest()).jobs[0]; + const v4Job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const v3 = schedulerCreateSpec(v3Job, { fieldVersion: '3' }); + const v4 = schedulerCreateSpec(v4Job, { fieldVersion: '4' }); + assert.equal('handoff_artifact_digest' in v3, false); + assert.equal(typeof v4.handoff_artifact_digest, 'string'); + assert.equal(typeof v4.handoff_artifact_payload, 'string'); + assert.equal(JSON.parse(v4.handoff_artifact_payload).handoff_version, 4); +}); + +test('apply uses v4 only after every runtime gate is advertised', async () => { + const v4Runner = runner(); + const v4 = await applyManifestToScheduler(manifest(), { + runner: v4Runner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(v4.handoff.field_version, '4'); + assert.equal(v4Runner.added[0].handoff_version, 4); + + const missingGateRunner = runner({ + features: { ...V4_FEATURES, immutable_runtime_events: false }, + }); + const fallback = await applyManifestToScheduler(manifest(), { + runner: missingGateRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(fallback.handoff.field_version, '3'); + assert.equal('handoff_version' in missingGateRunner.added[0], false); + + const oldRunner = runner({ handoffVersion: '3' }); + const oldRuntime = await applyManifestToScheduler(manifest(), { + runner: oldRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(oldRuntime.handoff.field_version, '3'); +}); + +test('v4 apply add, update, clear-null, and adopt preserve complete immutable artifacts', async () => { + const scheduler = statefulRunner(); + const created = await applyManifestToScheduler(manifest(), { + runner: scheduler, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }); + assert.equal(created.actions[0].action, 'created'); + const add = scheduler.history.at(-1); + assert.equal(add.action, 'add'); + const originalPayload = JSON.parse(add.spec.handoff_artifact_payload); + const originalDigest = add.spec.handoff_artifact_digest; + assert.equal(validateSchedulerHandoffV4Artifact(originalPayload, { + expectedDigest: originalDigest, + }).ok, true); + + const clearedManifest = manifest(); + delete clearedManifest.workflows[0].tasks[0].output; + const updated = await applyManifestToScheduler(clearedManifest, { + runner: scheduler, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }); + assert.equal(updated.actions[0].action, 'updated'); + const update = scheduler.history.at(-1); + assert.equal(update.action, 'update'); + assert.equal(update.spec.output_format, null); + assert.equal(update.spec.evidence_ref, null); + assert.notEqual(update.spec.handoff_artifact_digest, originalDigest); + const replacementPayload = JSON.parse(update.spec.handoff_artifact_payload); + assert.equal(replacementPayload.output.format, null); + assert.equal(validateSchedulerHandoffV4Artifact(replacementPayload, { + expectedDigest: update.spec.handoff_artifact_digest, + }).ok, true); + assert.equal(validateSchedulerHandoffV4Artifact(originalPayload, { + expectedDigest: originalDigest, + }).ok, true); + + const legacyId = 'legacy-v3-row'; + const adopter = statefulRunner([{ + id: legacyId, + name: manifest().workflows[0].tasks[0].name, + origin: 'legacy-origin', + }]); + const adopted = await applyManifestToScheduler(manifest(), { + runner: adopter, + adoptBy: 'name', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }); + assert.equal(adopted.actions[0].action, 'adopted'); + assert.equal(adopted.actions[0].adopted_from_job_id, legacyId); + assert.deepEqual(adopter.history.map(entry => entry.action), ['add', 'delete']); + assert.equal(adopter.history[0].spec.origin, 'legacy-origin'); + assert.equal(JSON.parse(adopter.history[0].spec.handoff_artifact_payload).handoff_version, 4); +}); + +test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const artifactDigest = `sha256:${'a'.repeat(64)}`; + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: 'issuer', + sub: 'subject', + aud: 'scheduler', + iat: now, + exp: now + 300, + jti: 'proof-1', + manifest_digest: `sha256:${'b'.repeat(64)}`, + handoff_artifact_digest: artifactDigest, + }; + const token = signJwt(payload, privateKey); + const profile = { + issuer: 'issuer', + audience: 'scheduler', + public_key: publicKey.export({ type: 'spki', format: 'pem' }), + proof: { value_from: { env: 'PROOF' } }, + }; + const claimed = new Set(); + const context = { + requireSignature: true, + requireManifestBinding: true, + trustedKey: profile.public_key, + manifestDigest: payload.manifest_digest, + artifactDigest, + handoffVersion: 4, + runId: 'run-1', + claimProofReplay({ proofId }) { + if (claimed.has(proofId)) return { claimed: false, reason: 'replay' }; + claimed.add(proofId); + return { claimed: true }; + }, + checkProofRevocation() { + return { revoked: false }; + }, + }; + + const verified = jwtVerifier.verifyProof(token, profile, context); + assert.equal(verified.verified, true, verified.reason); + assert.equal(verified.artifact_bound, true); + assert.equal(verified.replay_protected, true); + assert.equal(verified.revocation_checked, true); + + const replay = jwtVerifier.verifyProof(token, profile, context); + assert.equal(replay.verified, false); + assert.match(replay.reason, /replay/); + + const missingArtifact = signJwt({ ...payload, jti: 'proof-2', handoff_artifact_digest: undefined }, privateKey); + const unbound = jwtVerifier.verifyProof(missingArtifact, profile, context); + assert.equal(unbound.verified, false); + assert.match(unbound.reason, /artifact digest claim/); +}); + +test('handoff v4 detached signatures cover nonce, validity, key, and artifact metadata', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const now = Date.now(); + const fields = { + artifactDigest: compiled.handoff_artifact_digest, + nonce: 'detached-proof-1', + issuedAt: new Date(now - 1_000).toISOString(), + expiresAt: new Date(now + 60_000).toISOString(), + keyId: 'rsa-test-key', + }; + const signer = createSign('RSA-SHA256'); + signer.update(buildDetachedSignatureV4SigningContent(fields)); + const envelope = { + signature: signer.sign(privateKey).toString('base64'), + artifact_digest: fields.artifactDigest, + nonce: fields.nonce, + issued_at: fields.issuedAt, + expires_at: fields.expiresAt, + key_id: fields.keyId, + }; + const claimed = new Set(); + const context = { + artifactPayload: compiled.handoff_artifact_payload, + artifactDigest: compiled.handoff_artifact_digest, + handoffVersion: 4, + now, + runId: 'run-detached', + claimProofReplay({ proofId }) { + if (claimed.has(proofId)) return { claimed: false, reason: 'replay refused' }; + claimed.add(proofId); + return { claimed: true }; + }, + checkProofRevocation: () => ({ revoked: false }), + }; + const profile = { + issuer: 'test-issuer', + public_key: publicKey.export({ type: 'spki', format: 'pem' }), + proof: { value_from: { env: 'PROOF' } }, + }; + + const verified = detachedSignatureVerifier.verifyProof(envelope, profile, context); + assert.equal(verified.verified, true, verified.signature_verification_reason); + assert.equal(verified.artifact_bound, true); + assert.equal(verified.replay_protected, true); + assert.equal(verified.revocation_checked, true); + assert.equal(verified.verified_at, new Date(now).toISOString()); + + const tampered = detachedSignatureVerifier.verifyProof({ + ...envelope, + expires_at: new Date(now + 120_000).toISOString(), + }, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + }); + assert.equal(tampered.verified, false); + assert.match(tampered.signature_verification_reason, /signature verification failed/); + + const replay = detachedSignatureVerifier.verifyProof(envelope, profile, context); + assert.equal(replay.verified, false); + assert.match(replay.signature_verification_reason, /replay refused/); +}); + +test('handoff v4 certificate proof signs its replay and validity controls', t => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-v4-certificate-')); + t.after(() => rmSync(workdir, { recursive: true, force: true })); + const caKeyPath = join(workdir, 'ca-key.pem'); + const caCertPath = join(workdir, 'ca-cert.pem'); + const keyPath = join(workdir, 'leaf-key.pem'); + const csrPath = join(workdir, 'leaf.csr'); + const certPath = join(workdir, 'leaf-cert.pem'); + const extensionPath = join(workdir, 'leaf.ext'); + const commands = [ + ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', caKeyPath, + '-out', caCertPath, '-days', '1', '-subj', '/CN=agentcli-v4-ca', + '-addext', 'basicConstraints=critical,CA:TRUE', + '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], + ['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, + '-out', csrPath, '-subj', '/CN=agentcli-v4-leaf'], + ]; + for (const args of commands) { + const result = spawnSync('openssl', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(result.status, 0, result.stderr); + } + writeFileSync( + extensionPath, + 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n', + ); + const leaf = spawnSync('openssl', [ + 'x509', '-req', '-in', csrPath, '-CA', caCertPath, '-CAkey', caKeyPath, + '-CAcreateserial', '-out', certPath, '-days', '1', '-sha256', '-extfile', extensionPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(leaf.status, 0, leaf.stderr); + + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const now = Date.now(); + const fields = { + artifactDigest: compiled.handoff_artifact_digest, + nonce: 'certificate-proof-1', + issuedAt: new Date(now - 1_000).toISOString(), + expiresAt: new Date(now + 60_000).toISOString(), + keyId: 'leaf-test-key', + }; + const signer = createSign('SHA256'); + signer.update(buildCertificateV4SigningContent(fields)); + const envelope = { + certificate: readFileSync(certPath, 'utf8'), + signature: signer.sign(readFileSync(keyPath, 'utf8')).toString('base64'), + artifact_digest: fields.artifactDigest, + nonce: fields.nonce, + issued_at: fields.issuedAt, + expires_at: fields.expiresAt, + key_id: fields.keyId, + }; + const profile = { + ca_certificate: readFileSync(caCertPath, 'utf8'), + proof: { value_from: { env: 'CERTIFICATE_PROOF' } }, + claims: { subject: 'agentcli-v4-leaf' }, + }; + const context = { + artifactPayload: compiled.handoff_artifact_payload, + artifactDigest: compiled.handoff_artifact_digest, + handoffVersion: 4, + now, + runId: 'run-certificate', + claimProofReplay: () => ({ claimed: true }), + checkProofRevocation: () => ({ revoked: false }), + }; + const verified = certificateVerifier.verifyProof(envelope, profile, context); + assert.equal(verified.verified, true, verified.signature_verification_reason); + assert.equal(verified.verified_at, new Date(now).toISOString()); + + const tampered = certificateVerifier.verifyProof({ + ...envelope, + nonce: 'certificate-proof-tampered', + }, profile, context); + assert.equal(tampered.verified, false); + assert.match(tampered.signature_verification_reason, /signature verification failed/); + + const revoked = certificateVerifier.verifyProof({ + ...envelope, + nonce: 'certificate-proof-revoked', + signature: (() => { + const revokedSigner = createSign('SHA256'); + revokedSigner.update(buildCertificateV4SigningContent({ + ...fields, + nonce: 'certificate-proof-revoked', + })); + return revokedSigner.sign(readFileSync(keyPath, 'utf8')).toString('base64'); + })(), + }, profile, { + ...context, + checkProofRevocation: () => ({ revoked: true, reason: 'certificate revoked' }), + }); + assert.equal(revoked.verified, false); + assert.match(revoked.signature_verification_reason, /certificate revoked/); +}); + +test('inspect advertises every v4 immutable runtime entity', () => { + const entities = listInspectableEntities(); + for (const entity of [ + 'evidence', + 'artifacts', + 'events', + 'provider_sessions', + 'credential_presentations', + ]) { + assert.equal(entities.includes(entity), true, `missing inspect entity ${entity}`); + } +}); diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index 2a5b2eb..67f733b 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -296,7 +296,7 @@ if (!schedulerRuntime.ok) { assert.equal(result.handoff.v02_fields_included, true, 'v0.2 fields should be included'); const expectedVersion = String(Math.min( Number.parseInt(schedulerRuntime.capabilities.handoff_version, 10), - 3, + 4, )); assert.equal(result.handoff.field_version, expectedVersion, 'field_version should match the negotiated runtime version'); }); From 14053120707c2728edf13f1e0038e0c778ac8525 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 22:35:57 -0400 Subject: [PATCH 02/29] ci: pin handoff v4 scheduler contract --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/publish.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b356e..e2f6a58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + OPENCLAW_SCHEDULER_REF: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + ref: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" @@ -47,6 +47,8 @@ jobs: node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test + - name: Verify shared handoff v4 fixture parity + run: cmp fixtures/handoff-v4/conformance.json "$SCHEDULER_PATH/fixtures/handoff-v4/conformance.json" - run: npm run verify:package lint-test: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d28d366..aaf6b22 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + OPENCLAW_SCHEDULER_REF: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + ref: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" @@ -55,6 +55,8 @@ jobs: node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test + - name: Verify shared handoff v4 fixture parity + run: cmp fixtures/handoff-v4/conformance.json "$SCHEDULER_PATH/fixtures/handoff-v4/conformance.json" - run: npm run verify:package publish: From c613f3153470046ebb701b03a6f675638475d093 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 22:43:25 -0400 Subject: [PATCH 03/29] fix: require explicit handoff v4 capabilities --- src/apply.js | 2 +- test/handoff-v4.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/apply.js b/src/apply.js index 62ddedb..40a6144 100644 --- a/src/apply.js +++ b/src/apply.js @@ -288,7 +288,7 @@ export async function applyManifestToScheduler( const runtimeCaps = querySchedulerCapabilities(schedulerRunner); effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); - if (supportsSchedulerHandoffV4(effectiveResult)) { + if (supportsSchedulerHandoffV4(runtimeCaps)) { compiled = compileManifestToScheduler(manifest, { includeExplain, schedulerHandoffVersion: '4', diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index e0f1aa6..31d95e5 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -391,6 +391,16 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { assert.equal(fallback.handoff.field_version, '3'); assert.equal('handoff_version' in missingGateRunner.added[0], false); + const omittedGateFeatures = { ...V4_FEATURES }; + delete omittedGateFeatures.immutable_runtime_events; + const omittedGateRunner = runner({ features: omittedGateFeatures }); + const omittedGate = await applyManifestToScheduler(manifest(), { + runner: omittedGateRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(omittedGate.handoff.field_version, '3'); + assert.equal('handoff_version' in omittedGateRunner.added[0], false); + const oldRunner = runner({ handoffVersion: '3' }); const oldRuntime = await applyManifestToScheduler(manifest(), { runner: oldRunner, From e6f9dc538c7784897062a07c1e06717458d5802d Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 22:44:14 -0400 Subject: [PATCH 04/29] ci: test final scheduler review commit --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2f6a58..2a5987d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 + OPENCLAW_SCHEDULER_REF: 1f40fca64ce2820f065d1ed45c1f71173724567b steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 + ref: 1f40fca64ce2820f065d1ed45c1f71173724567b path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aaf6b22..c949d1b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 + OPENCLAW_SCHEDULER_REF: 1f40fca64ce2820f065d1ed45c1f71173724567b steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 2a0ddf1a64af12a81fb4de096d917aecefdefba1 + ref: 1f40fca64ce2820f065d1ed45c1f71173724567b path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" From cd1298a570712be1457428521d8f6bb1cc6171b2 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 23:15:12 -0400 Subject: [PATCH 05/29] fix: close handoff v4 release gaps --- CHANGELOG.md | 17 + README.md | 12 +- docs/capabilities.md | 17 + docs/conformance.md | 3 + docs/protocol.md | 4 +- docs/runtime-integration-backlog.md | 2 +- docs/spec.md | 45 ++- docs/versioning.md | 2 + examples/handoff-v4.json | 47 +++ scripts/verify-package.mjs | 18 + src/apply.js | 97 +++--- src/authorization-proof/certificate.js | 20 +- src/authorization-proof/detached-signature.js | 42 ++- src/authorization-proof/jwt.js | 36 +- src/authorization-proof/key-identity.js | 10 + src/capabilities.js | 36 +- src/cli.js | 3 +- src/describe.js | 2 +- src/handoff/index.js | 4 + src/handoff/schema-v4.js | 327 ++++++++++++++++++ src/handoff/v4.js | 65 +++- src/jsonrpc.js | 1 + src/schema.js | 3 + src/targets.js | 44 +-- test/agentcli.test.js | 102 +++++- test/handoff-v4.test.js | 242 ++++++++++++- test/integration-scheduler.test.js | 21 +- 27 files changed, 1085 insertions(+), 137 deletions(-) create mode 100644 examples/handoff-v4.json create mode 100644 src/authorization-proof/key-identity.js create mode 100644 src/handoff/schema-v4.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a085be2..5a129b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,25 @@ - scheduler: added handoff v4 canonical artifacts with explicit artifact, canonicalization, execution-binding, and scheduler-binding versions +- discovery: added Draft 2020-12 schema access for the complete handoff v4 + artifact through CLI, JSON-RPC, and the public handoff module +- validation: handoff v4 artifacts now reject missing required identity fields, + invalid nested types, and unknown properties before semantic verification +- negotiation: v4 now requires scheduler schema 29 or newer plus an exact live + artifact, canonicalization, digest, and binding contract; partial or + mismatched runtimes fall back to v3 +- negotiation: every apply, including basic v0.1 manifests, probes the live + runtime so v4 coverage is complete; static fallback enforcement features are + disabled until explicitly advertised - scheduler: complete non-lossy create, replace-style update, null clear, and adopt projections now carry the immutable artifact payload and digest +- scheduler: adoption rebinds the artifact after the final origin is selected, + and apply hashes the same merged operational environment passed to the runner - security: JWT, detached-signature, and certificate proofs bind the exact v4 artifact, replay identifier, validity, key, and revocation result +- security: v4 proofs reject inverted validity intervals, bind asserted key IDs + to the key or certificate that actually verified, and require an explicit + not-revoked result from the runtime checker - security: credential handoff compiles to exactly one runtime medium without persisting values, and delegation binds the exact source run and scope - evidence: the complete AgentCLI evidence payload and verification envelope @@ -17,6 +32,8 @@ credential presentations to the scheduler inspection surface - conformance: added shared positive and negative v4 fixtures and exact digest parity tests with OpenClaw Scheduler +- examples: added a public scheduler manifest for exercising negotiated v4 + apply and schema discovery - compatibility: handoff versions 1 through 3 and manifest versions 0.1 and 0.2 retain their existing behavior diff --git a/README.md b/README.md index 7848483..a0b8477 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ Use `agentcli signing providers` to list the registered signing providers and th | `version` | Show package and manifest spec version. | | `init [--tool program] [--output path] [--workflow-id id] [--task-id id]` | Initialize agentcli home directory with starter manifests. | | `paths` | Show resolved agentcli home, manifest, output, state, and audit paths. | -| `schema [target] [--legacy]` | Emit Draft 2020-12 JSON Schema for manifest, workflow, task, schedulerJob, standalonePlan, rpcRequest, or rpcResponse. `--legacy` opts into the older agentcli descriptor format. | +| `schema [target] [--legacy]` | Emit Draft 2020-12 JSON Schema for manifest, workflow, task, schedulerJob, standalonePlan, handoffV4, rpcRequest, or rpcResponse. The `handoff-v4` alias exposes the immutable scheduler artifact contract. `--legacy` opts into the older agentcli descriptor format. | | `describe [target]` | Describe manifest, workflow, task, targets, commands, or rpc surfaces as structured JSON. | | `targets` | List available compilation targets. | | `skill-path` | Print the path to the agentcli skill manifest for MCP tool registration. | @@ -493,8 +493,18 @@ agentcli compile my-workflow.json --target openclaw-scheduler --explain # Apply to the scheduler (creates or updates jobs) agentcli apply my-workflow.json --dry-run agentcli apply my-workflow.json + +# Discover and exercise the handoff v4 contract +agentcli schema handoff-v4 +agentcli apply examples/handoff-v4.json --check-capabilities ``` +AgentCLI emits v4 only when the live scheduler advertises schema version 29 or +newer, every required v4 feature, and an exact `handoff_contract` match for the +artifact schema, canonicalization, digest, undefined-value handling, execution +binding, and scheduler job binding. Missing or mismatched contract metadata +falls back to handoff v3, so partial v4 support is never persisted. + ## Migration from v0.1 v0.1 manifests continue to work unchanged. The validator accepts both versions, and the execution path for v0.1 manifests is preserved. diff --git a/docs/capabilities.md b/docs/capabilities.md index a1d5a26..39c9fca 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -50,6 +50,21 @@ Feature keys include `approvals`, `model_policy`, `execution_intent`, `output_hi During scheduler apply, a live runtime value is authoritative for every known feature it reports, including `false`. Static target values are used only when the capability command is unavailable or omits a known key. This prevents a stale optimistic declaration from overriding the runtime's observed behavior. +Security-relevant static fallback values are false. This includes runtime +execution, identity resolution, trust, proof, authorization, evidence, +approval, structured output, credential handoff, and every v4 gate. A live +response must explicitly enable each enforcement feature. AgentCLI probes the +runtime on every apply, including basic v0.1 manifests, so a v4-capable runtime +can provide immutable artifacts without weakening offline fallback behavior. + +Handoff v4 additionally requires scheduler schema version 29 or newer and an +exact `handoff_contract` object. The contract identifies artifact schema +`openclaw.scheduler.handoff-artifact` version 1, canonicalization +`json-sort-v1` version 1, SHA-256 digests, undefined values normalized to null, +execution binding version 2, and scheduler job binding version 1. AgentCLI +falls back to v3 when any contract field or required v4 feature is missing or +mismatched. Static target declarations never authorize v4 emission. + ## Current Target Matrix ### `standalone` @@ -119,6 +134,8 @@ Interpretation: - output hints compile into scheduler output preview/offload budgets - queue, approval, and fan-out budgets compile into runtime guardrails - handoff version 3 is required to preserve approval risk, approver scope, and output format fields +- handoff version 4 is selected only through exact live contract negotiation +- `agentcli schema handoff-v4` exposes the complete immutable artifact schema - root manual approvals require `root_approval_gate`; approver scopes require `approval_scope_enforcement`; output formats require `structured_output_format` - `auto-reject` jobs compile disabled so an older dispatcher cannot accidentally run them - inline `shell.env` and `shell.stdin` are rejected because scheduler persistence is not a secret store diff --git a/docs/conformance.md b/docs/conformance.md index 9c62254..f7c7038 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -55,6 +55,8 @@ Must: - document backend-specific constraints - preserve task ordering and trigger semantics - expose or document an explicit capability map for security-relevant runtime behavior +- advertise the exact artifact, canonicalization, digest, and binding contract + before accepting handoff v4 Should: @@ -62,6 +64,7 @@ Should: - expose runtime inspection - document delivery, retry, and approval behavior separately from the core manifest - fail closed when a compiled contract requires an enforcement capability the runtime does not advertise +- reject missing required fields and unknown properties in handoff v4 artifacts ## Reference Implementation diff --git a/docs/protocol.md b/docs/protocol.md index a588482..c325be1 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -69,7 +69,7 @@ Result: Params: -- `target` - defaults to `"manifest"` when omitted. Valid targets: `manifest`, `workflow`, `task`, `schedulerJob`, `standalonePlan`, `rpcRequest`, `rpcResponse`. Also accepts kebab-case aliases: `scheduler-job`, `standalone-plan`, `rpc-request`, `rpc-response`. +- `target` - defaults to `"manifest"` when omitted. Valid targets: `manifest`, `workflow`, `task`, `schedulerJob`, `standalonePlan`, `handoffV4`, `rpcRequest`, `rpcResponse`. Also accepts kebab-case aliases: `scheduler-job`, `standalone-plan`, `handoff-v4`, `rpc-request`, `rpc-response`. - `legacy` - boolean, defaults to `false`. When false, returns JSON Schema Draft 2020-12. When true, returns the legacy agentcli descriptor. Result: @@ -146,7 +146,7 @@ Params: Result: -- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "..." }, "handoff": { "field_version": "1|2|3|4", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` +- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "...", "schema_version": , "handoff_contract": }, "handoff": { "field_version": "1|2|3|4", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` - `adopted_from_job_id` is present only when `action` is `"adopted"` - `capabilities` summarizes runtime capability negotiation for the selected scheduler - `handoff` summarizes which scheduler field version was projected during apply diff --git a/docs/runtime-integration-backlog.md b/docs/runtime-integration-backlog.md index 2dd2a13..54fce1f 100644 --- a/docs/runtime-integration-backlog.md +++ b/docs/runtime-integration-backlog.md @@ -124,7 +124,7 @@ Acceptance criteria: Owner: `agentcli` + `openclaw-scheduler` -Status: shipped in AgentCLI 0.5.0 and OpenClaw Scheduler 0.5.0 as handoff v4. The canonical artifact, complete scheduler binding, shared fixtures, non-lossy apply/update/adopt path, immutable runtime bindings, and negative capability gates are executable release requirements. +Status: shipped in AgentCLI 0.5.0 and OpenClaw Scheduler 0.5.0 as handoff v4. The canonical artifact, complete scheduler binding, shared fixtures, non-lossy apply/update/adopt path, final-origin rebinding, environment parity, immutable runtime bindings, strict public artifact schema, exact live contract negotiation, verified-key revocation identity, and negative capability gates are executable release requirements. Problem: diff --git a/docs/spec.md b/docs/spec.md index 502aaa1..2f80e41 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -848,8 +848,28 @@ digest. ### Runtime gates -The runtime MUST advertise all of these boolean features before AgentCLI emits -v4: `handoff_v4_artifact`, `artifact_bound_proofs`, +The runtime MUST report scheduler schema version 29 or newer and the following +exact `handoff_contract` before AgentCLI emits v4: + +```json +{ + "artifact_schema": "openclaw.scheduler.handoff-artifact", + "artifact_schema_version": 1, + "canonicalization": "json-sort-v1", + "canonicalization_version": 1, + "digest": "sha256", + "undefined": "null", + "execution_binding_version": 2, + "scheduler_job_binding_version": 1 +} +``` + +Every field is required and an exact match. Missing, older, or newer contract +metadata is incompatible with v4 and MUST fall back to handoff v3 or reject +apply. A handoff version number alone is not sufficient negotiation. + +The runtime MUST also advertise all of these boolean features before AgentCLI +emits v4: `handoff_v4_artifact`, `artifact_bound_proofs`, `signed_or_provider_verified_evidence`, `provider_session_cache`, `credential_presentation`, `source_run_bound_delegation`, and `immutable_runtime_events`. AgentCLI uses the live capability response as @@ -861,6 +881,14 @@ single-use replay identifier. Required revocation checks MUST run before user code. Missing, tampered, replayed, transplanted, expired, prematurely valid, revoked, or unbound proofs fail closed. +Proof expiration MUST be later than issuance regardless of clock skew. A +detached-signature or certificate envelope key ID MUST match the key or +certificate that actually verified. JWT revocation uses the trusted JWKS key ID +or a deterministic identity derived from the verified public key, never an +untrusted header value. A revocation checker MUST explicitly return a +not-revoked result such as `{ "revoked": false }`; missing, malformed, or +indeterminate results fail closed. + Credential release MUST use exactly one negotiated medium: environment, temporary file, stdin, or a Gateway capability-bound environment header. Materialization state is recorded before release, values are never persisted, @@ -885,6 +913,19 @@ SHOULD publish identical positive and negative conformance vectors. The reference vectors are packaged under `fixtures/handoff-v4/` in AgentCLI and OpenClaw Scheduler. +The complete artifact structure is discoverable through `agentcli schema +handoff-v4` and JSON-RPC `agentcli.schema` with target `handoff-v4`. Producers +and consumers MUST reject missing required identity fields and unknown artifact +properties before digest or semantic verification. + +AgentCLI probes live scheduler capabilities for every apply, including a basic +v0.1 manifest, so immutable artifacts cover all jobs when v4 is available. An +unavailable capability command leaves security-relevant runtime features false. +During adoption, any final persisted origin override is rebound into the +scheduler job binding before create. The environment used to compile execution +hashes is the same merged operational environment passed to the scheduler +process. + ## Compiler Targets This spec does not require a single runtime. diff --git a/docs/versioning.md b/docs/versioning.md index b76fa1a..a8cef07 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -20,6 +20,8 @@ Backward compatibility for `0.1` remains part of the current release surface: - validators accept both `0.1` and `0.2` - `0.2` is the canonical discovery and schema version reported by `agentcli version`, `agentcli schema manifest`, and JSON-RPC `agentcli.version` - schema discovery emits JSON Schema Draft 2020-12 by default; the legacy descriptor requires an explicit `--legacy` flag or RPC `legacy: true` +- handoff v4 artifact schema version 1 is independently discoverable through + `schema handoff-v4` and requires exact runtime contract negotiation - existing `0.1` manifests remain executable through the preserved legacy execution path ## Breaking Changes diff --git a/examples/handoff-v4.json b/examples/handoff-v4.json new file mode 100644 index 0000000..811236a --- /dev/null +++ b/examples/handoff-v4.json @@ -0,0 +1,47 @@ +{ + "version": "0.2", + "workflows": [ + { + "id": "handoff-v4-example", + "name": "Handoff v4 example", + "tasks": [ + { + "id": "emit-contract-status", + "name": "Emit contract status", + "target": { + "session_target": "shell" + }, + "shell": { + "program": "/usr/bin/printf", + "args": [ + "%s\\n", + "handoff-v4-ready" + ] + }, + "schedule": { + "cron": "17 3 * * *", + "tz": "UTC" + }, + "intent": { + "mode": "execute", + "read_only": true + }, + "runtime": { + "timeout_ms": 30000 + }, + "output": { + "format": "text" + }, + "delivery": { + "mode": "none" + }, + "contract": { + "sandbox": "none", + "network": "none", + "audit": "always" + } + } + ] + } + ] +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 8a49b5b..6b6d2d9 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -42,8 +42,10 @@ try { 'fixtures/handoff-v4/conformance.json', 'scripts/verify-package.mjs', 'src/handoff/index.js', + 'src/handoff/schema-v4.js', 'src/handoff/v4.js', 'src/authorization-proof/jwt.js', + 'src/authorization-proof/key-identity.js', 'src/authorization-proof/detached-signature.js', 'src/authorization-proof/certificate.js', ]; @@ -87,12 +89,28 @@ try { const handoff = await import(pathToFileURL(join(packageRoot, 'src/handoff/index.js')).href); for (const exportName of [ 'buildSchedulerHandoffV4Artifact', + 'rebindSchedulerHandoffV4Job', 'validateSchedulerHandoffV4Artifact', ]) { if (typeof handoff[exportName] !== 'function') { throw new Error(`packed handoff module is missing ${exportName}`); } } + if (handoff.HANDOFF_V4_JSON_SCHEMA?.properties?.handoff_version?.const !== 4) { + throw new Error('packed handoff module is missing the v4 artifact schema'); + } + + const schemaOutput = JSON.parse(run(process.execPath, [ + 'bin/agentcli.js', + 'schema', + 'handoff-v4', + '--json', + ], { cwd: packageRoot })); + if (schemaOutput.ok !== true + || schemaOutput.schema_format !== 'json-schema-draft-2020-12' + || schemaOutput.schema?.properties?.handoff_version?.const !== 4) { + throw new Error('packed CLI does not expose the handoff v4 artifact schema'); + } const help = run(process.execPath, ['bin/agentcli.js', 'help'], { cwd: packageRoot }); if (!help.includes('agentcli')) { diff --git a/src/apply.js b/src/apply.js index 40a6144..54bc793 100644 --- a/src/apply.js +++ b/src/apply.js @@ -22,6 +22,7 @@ import { SCHEDULER_FIELDS_V04, SCHEDULER_FIELD_VERSIONS, } from './scheduler-fields.js'; +import { rebindSchedulerHandoffV4Job } from './handoff/v4.js'; export { shellCommandInvocation } from './command.js'; export { SCHEDULER_FIELDS_V1, @@ -178,16 +179,6 @@ function duplicateNames(items) { .map(([name]) => name); } -function jobRequiresCapabilityNegotiation(job) { - return Boolean( - job.identity || job.identity_ref || job.authorization || job.authorization_ref - || job.evidence || job.evidence_ref || job.child_credential_policy - || job.contract_required_trust_level || job.authorization_proof || job.authorization_proof_ref - || (job.approval_required && !job.parent_id) || job.approval_approver_scope - || job.output_format - ); -} - export function createSchedulerCliRunner(options = {}) { const invocation = resolveSchedulerInvocation(options); const baseEnv = { ...process.env, ...(options.env || {}) }; @@ -263,56 +254,51 @@ export async function applyManifestToScheduler( } = {} ) { let compiled = compileManifestToScheduler(manifest, { includeExplain }); + const schedulerEnv = { ...process.env, ...(env || {}) }; const verificationByTask = new Map(); const resolvedProofsByTask = buildResolvedAuthorizationProofsByTask(manifest); - const requiredHandoffVersion = requiredSchedulerFieldVersion(compiled.jobs); - const requiresCapabilityNegotiation = - manifest.version === '0.2' - || requiredHandoffVersion > 1 - || compiled.jobs.some(jobRequiresCapabilityNegotiation); - - // Construct the scheduler runner once; runtime capability negotiation is only - // needed when the compiled manifest actually uses v0.2 runtime-gated fields. + + // Construct the scheduler runner once. Every apply probes capabilities so a + // basic v0.1 job receives a v4 artifact when the runtime supports the exact + // contract, while an unavailable capability command still falls back safely. const schedulerRunner = runner || createSchedulerCliRunner({ schedulerPrefix, schedulerBin, dbPath, cwd, - env + env: schedulerEnv, }); - let effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', null); - let handoffVersion = '1'; - let capabilityWarnings = []; - if (requiresCapabilityNegotiation) { - const runtimeCaps = querySchedulerCapabilities(schedulerRunner); - effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); - - if (supportsSchedulerHandoffV4(runtimeCaps)) { - compiled = compileManifestToScheduler(manifest, { - includeExplain, - schedulerHandoffVersion: '4', - cwd, - env, - }); - } + const runtimeCaps = querySchedulerCapabilities(schedulerRunner); + const effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); + let handoffVersion; - const { errors: capabilityErrors, warnings } = validateManifestCapabilities(compiled, effectiveResult); - capabilityWarnings = warnings; - if (capabilityErrors.length > 0) { - throw Object.assign( - new Error(capabilityErrors.map(error => error.message).join('; ')), - { code: 'unsupported_capability', capability_errors: capabilityErrors } - ); - } - handoffVersion = negotiateSchedulerFieldVersion( - compiled.jobs, - effectiveResult.handoff_version || '1' + if (supportsSchedulerHandoffV4(runtimeCaps)) { + compiled = compileManifestToScheduler(manifest, { + includeExplain, + schedulerHandoffVersion: '4', + cwd, + env: schedulerEnv, + }); + } + + const { + errors: capabilityErrors, + warnings: capabilityWarnings, + } = validateManifestCapabilities(compiled, effectiveResult); + if (capabilityErrors.length > 0) { + throw Object.assign( + new Error(capabilityErrors.map(error => error.message).join('; ')), + { code: 'unsupported_capability', capability_errors: capabilityErrors } ); - if (capabilityWarnings.length > 0) { - for (const warning of capabilityWarnings) { - process.stderr.write(`warning: ${warning.message}\n`); - } + } + handoffVersion = negotiateSchedulerFieldVersion( + compiled.jobs, + effectiveResult.handoff_version || '1' + ); + if (capabilityWarnings.length > 0) { + for (const warning of capabilityWarnings) { + process.stderr.write(`warning: ${warning.message}\n`); } } const effectiveFeatures = effectiveResult.features; @@ -468,9 +454,14 @@ export async function applyManifestToScheduler( { code: 'scheduler_error' } ); } - schedulerRunner.addJob( - schedulerCreateSpec(job, { originOverride: existingJob?.origin ?? 'system', fieldVersion: handoffVersion }) - ); + const adoptedOrigin = existingJob?.origin ?? 'system'; + const adoptedJob = Number(job.handoff_version) === 4 + ? rebindSchedulerHandoffV4Job(job, { origin: adoptedOrigin }) + : job; + schedulerRunner.addJob(schedulerCreateSpec(adoptedJob, { + originOverride: adoptedOrigin, + fieldVersion: handoffVersion, + })); try { schedulerRunner.deleteJob(existingId); } catch (err) { @@ -518,6 +509,8 @@ export async function applyManifestToScheduler( source: effectiveResult.source, negotiated: effectiveResult.negotiated, handoff_version: effectiveResult.handoff_version || null, + schema_version: effectiveResult.schema_version || null, + handoff_contract: effectiveResult.handoff_contract || null, ...(capabilityWarnings?.length > 0 ? { warnings: capabilityWarnings } : {}), }, handoff: { diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 7f95267..7dd24f7 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -19,6 +19,13 @@ import { registerVerifier } from './index.js'; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; +export function certificateProofKeyId(certificate) { + const parsed = certificate instanceof X509Certificate + ? certificate + : new X509Certificate(certificate); + return `x509-sha256:${parsed.fingerprint256.replaceAll(':', '').toLowerCase()}`; +} + export function buildCertificateV4SigningContent({ artifactDigest, nonce, @@ -153,6 +160,10 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { ? { ok: false, reason: envelope.reason } : { ok: true, replayProtected: true, revocationChecked: true }; } + const verifiedKeyId = certificateProofKeyId(cert); + if (parsed.key_id !== verifiedKeyId) { + return { ok: false, reason: 'certificate proof key_id does not match the verified certificate' }; + } const claimReplay = context.claimProofReplay ?? context.replayStore?.claim?.bind(context.replayStore); @@ -187,7 +198,7 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { subject: cert.subject ?? null, proofId: parsed.nonce, artifactDigest: context.artifactDigest, - keyId: parsed.key_id ?? cert.fingerprint256, + keyId: verifiedKeyId, serialNumber: cert.serialNumber, fingerprint: cert.fingerprint256, }); @@ -197,6 +208,13 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { if (revocation === true || revocation?.revoked === true) { return { ok: false, reason: revocation?.reason || 'certificate proof is revoked' }; } + if (revocation?.revoked !== false) { + return { + ok: false, + reason: revocation?.reason + || 'handoff v4 revocation checker did not explicitly confirm the certificate is not revoked', + }; + } return { ok: true, replayProtected: true, revocationChecked: true }; } diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index ea8ce57..6ae8ba6 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -15,10 +15,15 @@ import { tmpdir } from 'node:os'; import process from 'node:process'; import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; +import { publicKeyId } from './key-identity.js'; const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; +export function detachedSignatureKeyId(publicKey) { + return publicKeyId(publicKey); +} + export function buildDetachedSignatureV4SigningContent({ artifactDigest, nonce, @@ -196,10 +201,16 @@ function parseV4Envelope(proof, context) { return { signature: envelope.signature, envelope, v4: true }; } -function enforceV4RuntimeGuards(parsed, context, profile) { +function enforceV4RuntimeGuards(parsed, context, profile, verifiedKeyId) { if (!parsed.v4) { return { ok: true, replayProtected: true, revocationChecked: true }; } + if (typeof verifiedKeyId !== 'string' || verifiedKeyId.length === 0) { + return { ok: false, reason: 'handoff v4 requires a verified detached-signature key identity' }; + } + if (parsed.envelope.key_id !== verifiedKeyId) { + return { ok: false, reason: 'detached proof key_id does not match the verified signing key' }; + } const claimReplay = context.claimProofReplay ?? context.replayStore?.claim?.bind(context.replayStore); @@ -232,7 +243,7 @@ function enforceV4RuntimeGuards(parsed, context, profile) { issuer: profile.issuer ?? null, proofId: parsed.envelope.nonce, artifactDigest: context.artifactDigest, - keyId: parsed.envelope.key_id, + keyId: verifiedKeyId, }); if (revocation && typeof revocation.then === 'function') { return { ok: false, reason: 'handoff v4 revocation checker must complete synchronously' }; @@ -240,6 +251,13 @@ function enforceV4RuntimeGuards(parsed, context, profile) { if (revocation === true || revocation?.revoked === true) { return { ok: false, reason: revocation?.reason || 'detached proof key is revoked' }; } + if (revocation?.revoked !== false) { + return { + ok: false, + reason: revocation?.reason + || 'handoff v4 revocation checker did not explicitly confirm the detached proof key is not revoked', + }; + } return { ok: true, replayProtected: true, revocationChecked: true }; } @@ -306,7 +324,9 @@ function verifySshSignature(signature, content, options) { }); if (result.status === 0) { - return { verified: true }; + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + const fingerprint = output.match(/SHA256:[A-Za-z0-9+/]+={0,2}/)?.[0] ?? null; + return { verified: true, keyId: fingerprint }; } return { @@ -449,7 +469,7 @@ const detachedSignatureVerifier = { ); const guards = sshResult.verified - ? enforceV4RuntimeGuards(parsed, context, profile || {}) + ? enforceV4RuntimeGuards(parsed, context, profile || {}, sshResult.keyId) : { ok: false, reason: sshResult.reason }; return { verified: sshResult.verified && guards.ok, @@ -465,6 +485,7 @@ const detachedSignatureVerifier = { || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), replay_protected: guards.replayProtected === true, revocation_checked: guards.revocationChecked === true, + key_id: sshResult.keyId ?? null, verified_at: verifiedAt(context), }; } @@ -502,8 +523,17 @@ const detachedSignatureVerifier = { verificationReason = `signature verification error: ${err.message}`; } + let verifiedKeyId = context.trustedKeyId ?? null; + if (signatureValid && !verifiedKeyId) { + try { + verifiedKeyId = detachedSignatureKeyId(context.trustedKey); + } catch (error) { + signatureValid = false; + verificationReason = `could not derive verified detached-signature key identity: ${error.message}`; + } + } const guards = signatureValid - ? enforceV4RuntimeGuards(parsed, context, profile || {}) + ? enforceV4RuntimeGuards(parsed, context, profile || {}, verifiedKeyId) : { ok: false, reason: verificationReason }; return { verified: signatureValid && guards.ok, @@ -519,6 +549,7 @@ const detachedSignatureVerifier = { || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), replay_protected: guards.replayProtected === true, revocation_checked: guards.revocationChecked === true, + key_id: verifiedKeyId, verified_at: verifiedAt(context), }; } @@ -557,6 +588,7 @@ const detachedSignatureVerifier = { artifact_bound: result.artifact_bound === true, replay_protected: result.replay_protected === true, revocation_checked: result.revocation_checked === true, + key_id: result.key_id || null, reason: result.signature_verification_reason || result.reason || null, }; }, diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index 3b19640..2bf3865 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -12,6 +12,7 @@ import { createPublicKey, createVerify } from 'node:crypto'; import { canonicalDigest } from '../canonical.js'; import { registerVerifier } from './index.js'; +import { publicKeyId } from './key-identity.js'; const DEFAULT_JWKS_CACHE_TTL_MS = 5 * 60 * 1000; const AUDIT_SAFE_CLAIMS = [ @@ -702,6 +703,15 @@ const jwtVerifier = { signature_verified: false, }; } + if (payload.exp <= payload.iat) { + return { + verified: false, + method: 'jwt', + reason: 'handoff v4 JWT exp claim must be greater than iat claim', + claims_validated: false, + signature_verified: false, + }; + } if (typeof payload.jti !== 'string' || payload.jti.length === 0) { return { verified: false, @@ -772,15 +782,28 @@ const jwtVerifier = { signatureReason = signatureReason || 'signature required but no trusted key available'; } + let verifiedKeyId = context.trustedKeyId || null; + if (signatureVerified && !verifiedKeyId) { + try { + verifiedKeyId = publicKeyId(context.trustedKey); + } catch (error) { + signatureVerified = false; + signatureReason = `could not derive verified JWT key identity: ${error.message}`; + } + } + let replayProtected = !v4Required; let revocationChecked = !v4Required; let runtimeGuardReason = null; if (v4Required && signatureVerified && manifestBound && artifactBound) { + if (!verifiedKeyId) { + runtimeGuardReason = 'handoff v4 requires a verified signing key identity'; + } const claimReplay = context.claimProofReplay ?? context.replayStore?.claim?.bind(context.replayStore); - if (typeof claimReplay !== 'function') { + if (!runtimeGuardReason && typeof claimReplay !== 'function') { runtimeGuardReason = 'handoff v4 proof replay store is required'; - } else { + } else if (!runtimeGuardReason) { const replayResult = claimReplay({ method: 'jwt', issuer: payload.iss ?? profile.issuer ?? null, @@ -810,14 +833,17 @@ const jwtVerifier = { subject: payload.sub ?? null, proofId: payload.jti, artifactDigest, - keyId: context.trustedKeyId || header.kid || null, + keyId: verifiedKeyId, }); if (revocationResult && typeof revocationResult.then === 'function') { runtimeGuardReason = 'handoff v4 revocation checker must complete synchronously'; } else if (revocationResult?.revoked === true || revocationResult === true) { runtimeGuardReason = revocationResult?.reason || 'JWT proof is revoked'; - } else { + } else if (revocationResult?.revoked === false) { revocationChecked = true; + } else { + runtimeGuardReason = revocationResult?.reason + || 'handoff v4 revocation checker did not explicitly confirm the JWT is not revoked'; } } } @@ -862,7 +888,7 @@ const jwtVerifier = { replay_protected: replayProtected, revocation_checked: revocationChecked, decoded_claims: decodedClaims, - key_id: context.trustedKeyId || header.kid || null, + key_id: verifiedKeyId, key_source: context.trustedKeySource || null, manifest_digest: context.manifestDigest || null, verified_at: new Date().toISOString(), diff --git a/src/authorization-proof/key-identity.js b/src/authorization-proof/key-identity.js new file mode 100644 index 0000000..c34c592 --- /dev/null +++ b/src/authorization-proof/key-identity.js @@ -0,0 +1,10 @@ +import { createHash, createPublicKey } from 'node:crypto'; + +export function publicKeyId(publicKey) { + const normalized = publicKey?.type === 'public' && typeof publicKey.export === 'function' + ? publicKey + : createPublicKey(publicKey); + const spki = normalized.export({ type: 'spki', format: 'der' }); + const digest = createHash('sha256').update(spki).digest('hex'); + return `spki-sha256:${digest}`; +} diff --git a/src/capabilities.js b/src/capabilities.js index a969db0..67a6faa 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -1,4 +1,13 @@ import { TARGETS } from './targets.js'; +import { + HANDOFF_V4_SCHEMA, + HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + HANDOFF_V4_SCHEDULER_SCHEMA_MIN, + HANDOFF_V4_CANONICALIZATION, + HANDOFF_V4_CANONICALIZATION_VERSION, + HANDOFF_V4_EXECUTION_BINDING_VERSION, + HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, +} from './handoff/schema-v4.js'; export const HANDOFF_V4_REQUIRED_FEATURES = Object.freeze([ 'handoff_v4_artifact', @@ -10,11 +19,33 @@ export const HANDOFF_V4_REQUIRED_FEATURES = Object.freeze([ 'immutable_runtime_events', ]); +export const HANDOFF_V4_RUNTIME_CONTRACT = Object.freeze({ + artifact_schema: HANDOFF_V4_SCHEMA, + artifact_schema_version: HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + canonicalization: HANDOFF_V4_CANONICALIZATION, + canonicalization_version: HANDOFF_V4_CANONICALIZATION_VERSION, + digest: 'sha256', + undefined: 'null', + execution_binding_version: HANDOFF_V4_EXECUTION_BINDING_VERSION, + scheduler_job_binding_version: HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, +}); + +function supportsExactV4Contract(contract) { + return contract != null + && typeof contract === 'object' + && Object.entries(HANDOFF_V4_RUNTIME_CONTRACT) + .every(([key, expected]) => contract[key] === expected); +} + export function supportsSchedulerHandoffV4(effectiveCapabilities = {}) { - const version = Number.parseInt(String(effectiveCapabilities.handoff_version ?? '0'), 10); + const version = Number(effectiveCapabilities.handoff_version); + const schemaVersion = Number(effectiveCapabilities.schema_version); const features = effectiveCapabilities.features ?? {}; return Number.isInteger(version) && version >= 4 + && Number.isInteger(schemaVersion) + && schemaVersion >= HANDOFF_V4_SCHEDULER_SCHEMA_MIN + && supportsExactV4Contract(effectiveCapabilities.handoff_contract) && HANDOFF_V4_REQUIRED_FEATURES.every(feature => features[feature] === true); } @@ -38,6 +69,7 @@ export function querySchedulerCapabilities(runner) { version: result.scheduler_version || null, handoff_version: result.handoff_version || null, schema_version: result.schema_version || null, + handoff_contract: result.handoff_contract || null, source: 'runtime', }; } catch (err) { @@ -75,6 +107,8 @@ export function resolveEffectiveFeatures(targetName, runtimeCapabilities) { source: 'runtime', negotiated: true, handoff_version: runtimeCapabilities.handoff_version || null, + schema_version: runtimeCapabilities.schema_version || null, + handoff_contract: runtimeCapabilities.handoff_contract || null, }; } diff --git a/src/cli.js b/src/cli.js index deef655..58ddbe2 100644 --- a/src/cli.js +++ b/src/cli.js @@ -42,7 +42,7 @@ agentcli [args] Commands: version init [--tool program] [--output path] [--workflow-id id] [--task-id id] - schema [manifest|workflow|task|schedulerJob|standalonePlan|rpcRequest|rpcResponse|scheduler-job|standalone-plan|rpc-request|rpc-response] [--legacy] + schema [manifest|workflow|task|schedulerJob|standalonePlan|handoffV4|rpcRequest|rpcResponse|scheduler-job|standalone-plan|handoff-v4|rpc-request|rpc-response] [--legacy] describe [manifest|workflow|task|targets|commands|rpc] targets paths @@ -350,6 +350,7 @@ function pickSchema(name, { legacy = false } = {}) { const aliases = { 'scheduler-job': 'schedulerJob', 'standalone-plan': 'standalonePlan', + 'handoff-v4': 'handoffV4', 'rpc-request': 'rpcRequest', 'rpc-response': 'rpcResponse' }; diff --git a/src/describe.js b/src/describe.js index 69282d0..954b806 100644 --- a/src/describe.js +++ b/src/describe.js @@ -8,7 +8,7 @@ export const COMMAND_DESCRIPTIONS = [ }, { command: 'schema', - summary: 'Emit machine-readable schema fragments for manifests, tasks, targets, and RPC payloads.' + summary: 'Emit machine-readable schema fragments for manifests, compiled artifacts, and RPC payloads.' }, { command: 'describe', diff --git a/src/handoff/index.js b/src/handoff/index.js index 05e9be5..cdc98a4 100644 --- a/src/handoff/index.js +++ b/src/handoff/index.js @@ -6,6 +6,10 @@ export { HANDOFF_V4_CANONICALIZATION, HANDOFF_V4_CANONICALIZATION_VERSION, HANDOFF_V4_EXECUTION_BINDING_VERSION, + HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, buildSchedulerHandoffV4Artifact, + rebindSchedulerHandoffV4Job, validateSchedulerHandoffV4Artifact, } from './v4.js'; + +export { HANDOFF_V4_JSON_SCHEMA } from './schema-v4.js'; diff --git a/src/handoff/schema-v4.js b/src/handoff/schema-v4.js new file mode 100644 index 0000000..46300ad --- /dev/null +++ b/src/handoff/schema-v4.js @@ -0,0 +1,327 @@ +export const HANDOFF_V4_SCHEMA = 'openclaw.scheduler.handoff-artifact'; +export const HANDOFF_V4_ARTIFACT_SCHEMA_VERSION = 1; +export const HANDOFF_V4_VERSION = 4; +export const HANDOFF_V4_SCHEDULER_SCHEMA_MIN = 29; +export const HANDOFF_V4_CANONICALIZATION = 'json-sort-v1'; +export const HANDOFF_V4_CANONICALIZATION_VERSION = 1; +export const HANDOFF_V4_EXECUTION_BINDING_VERSION = 2; +export const HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION = 1; + +const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; +const SHA256_PATTERN = '^sha256:[0-9a-f]{64}$'; + +const nullableString = Object.freeze({ type: ['string', 'null'] }); +const nullableInteger = Object.freeze({ type: ['integer', 'null'] }); +const nullableNumber = Object.freeze({ type: ['number', 'null'] }); +const digest = Object.freeze({ type: 'string', pattern: SHA256_PATTERN }); +const nullableDigest = Object.freeze({ type: ['string', 'null'], pattern: SHA256_PATTERN }); + +function strictObject(properties, required = Object.keys(properties)) { + return { + type: 'object', + required, + properties, + additionalProperties: false, + }; +} + +const presentationMedium = { + type: 'string', + enum: ['none', 'env', 'temp-file', 'stdin', 'gateway-env-header'], +}; + +const presentationBindingSchema = strictObject({ + name: nullableString, + medium: presentationMedium, + env_key: nullableString, + file_name: nullableString, + source_hash: nullableDigest, + required: { type: 'boolean' }, + redact: { type: 'boolean' }, + format: { type: 'string', minLength: 1 }, +}); + +export const HANDOFF_V4_JSON_SCHEMA = Object.freeze({ + $schema: JSON_SCHEMA_DIALECT, + title: 'openclaw-scheduler handoff v4 artifact', + description: 'Immutable, canonical execution contract consumed by openclaw-scheduler.', + ...strictObject({ + schema: { const: HANDOFF_V4_SCHEMA }, + artifact_schema_version: { const: HANDOFF_V4_ARTIFACT_SCHEMA_VERSION }, + handoff_version: { const: HANDOFF_V4_VERSION }, + scheduler_schema_min: { const: HANDOFF_V4_SCHEDULER_SCHEMA_MIN }, + canonicalization: strictObject({ + name: { const: HANDOFF_V4_CANONICALIZATION }, + version: { const: HANDOFF_V4_CANONICALIZATION_VERSION }, + digest: { const: 'sha256' }, + undefined: { const: 'null' }, + }), + execution_binding_version: { const: HANDOFF_V4_EXECUTION_BINDING_VERSION }, + scheduler_job_binding: strictObject({ + version: { const: HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION }, + digest, + }), + manifest: strictObject({ + version: { type: 'string', minLength: 1 }, + digest, + workflow_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string', minLength: 1 }, + }), + compiled: strictObject({ + target: { const: 'openclaw-scheduler' }, + job_id: { type: 'string', minLength: 1 }, + effective_task_hash: digest, + source: strictObject({ + workflow_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string', minLength: 1 }, + }), + }), + lifecycle: strictObject({ + enabled: { type: 'boolean' }, + delete_after_run: { type: 'boolean' }, + target: strictObject({ + session_target: { type: 'string', enum: ['main', 'isolated', 'shell'] }, + agent_id: nullableString, + payload_kind: { type: 'string', enum: ['systemEvent', 'agentTurn', 'shellCommand'] }, + }), + }), + command: strictObject({ + kind: { type: 'string', enum: ['shell', 'prompt', 'system'] }, + program: nullableString, + args_count: { type: 'integer', minimum: 0 }, + args_sha256: { type: 'array', items: digest }, + argv_sha256: nullableDigest, + cwd: nullableString, + stdin_sha256: nullableDigest, + prompt_sha256: nullableDigest, + input_sha256: nullableDigest, + payload_message_sha256: digest, + env: strictObject({ + declared_env_sha256: nullableDigest, + effective_env_keys: { + type: 'array', + items: { type: 'string', minLength: 1 }, + uniqueItems: true, + }, + effective_env_value_sha256: { + type: 'object', + propertyNames: { type: 'string', minLength: 1 }, + additionalProperties: digest, + }, + }), + }), + runtime: strictObject({ + timeout_ms: { type: 'integer', minimum: 1 }, + instance_id: strictObject({ + kind: { const: 'deferred' }, + source: { const: 'run.id' }, + }), + }), + approval: strictObject({ + required: { type: 'boolean' }, + timeout_s: nullableInteger, + auto: { type: ['string', 'null'], enum: ['approve', 'reject', null] }, + risk_level: { type: ['string', 'null'], enum: ['low', 'medium', 'high', null] }, + approver_scope: nullableString, + }), + identity: strictObject({ + ref: nullableString, + provider: nullableString, + scope: nullableString, + subject_kind: nullableString, + subject_principal: nullableString, + subject_hash: nullableDigest, + auth_hash: nullableDigest, + trust_level: nullableString, + delegation_mode: nullableString, + presentation: strictObject({ + mode: nullableString, + handoff: presentationMedium, + bindings: { type: 'array', items: presentationBindingSchema }, + default_redaction: { type: ['boolean', 'null'] }, + cleanup: { type: 'string', enum: ['always', 'on-success', 'never'] }, + }), + }), + contract: strictObject({ + required_trust_level: nullableString, + trust_enforcement: nullableString, + sandbox: { type: ['string', 'null'], enum: ['none', 'permissive', 'strict', null] }, + allowed_paths_sha256: nullableDigest, + network: { type: ['string', 'null'], enum: ['unrestricted', 'restricted', 'none', null] }, + max_cost_usd: nullableNumber, + audit: { type: ['string', 'null'], enum: ['none', 'on-failure', 'always', null] }, + postcondition: strictObject({ + output_format: { type: ['string', 'null'], enum: ['json', 'ndjson', 'text', null] }, + verify_shell_sha256: nullableDigest, + verify_timeout_s: nullableInteger, + verify_on_failure: { type: ['string', 'null'], enum: ['error', 'warn', null] }, + }), + }), + authorization_proof: strictObject({ + ref: nullableString, + method: nullableString, + issuer: nullableString, + audience: nullableString, + claims_hash: nullableDigest, + proof_source_hash: nullableDigest, + verification_context_hash: nullableDigest, + artifact_binding_required: { type: 'boolean' }, + replay_protection_required: { type: 'boolean' }, + revocation_check_required: { type: 'boolean' }, + }, [ + 'ref', + 'method', + 'issuer', + 'audience', + 'claims_hash', + 'proof_source_hash', + 'artifact_binding_required', + 'replay_protection_required', + 'revocation_check_required', + ]), + authorization: strictObject({ + ref: nullableString, + provider: nullableString, + policy_digest: nullableDigest, + on_error: nullableString, + request_hash: nullableDigest, + decision_hash: nullableDigest, + }), + evidence: strictObject({ + ref: nullableString, + provider: nullableString, + methods: { type: 'array', items: { type: 'string', minLength: 1 }, uniqueItems: true }, + payload_bind: { type: 'array', items: { type: 'string', minLength: 1 }, uniqueItems: true }, + payload_hash: nullableDigest, + provider_config_hash: nullableDigest, + verify_required: { type: 'boolean' }, + retention: nullableString, + signed_or_provider_verified_required: { type: 'boolean' }, + }, [ + 'ref', + 'provider', + 'methods', + 'payload_bind', + 'verify_required', + 'retention', + 'signed_or_provider_verified_required', + ]), + verification: strictObject({ + shell_sha256: nullableDigest, + timeout_s: nullableInteger, + on_failure: { type: ['string', 'null'], enum: ['error', 'warn', null] }, + }), + output: strictObject({ + format: { type: ['string', 'null'], enum: ['json', 'ndjson', 'text', null] }, + store_limit_bytes: nullableInteger, + excerpt_limit_bytes: nullableInteger, + summary_limit_bytes: nullableInteger, + offload_threshold_bytes: nullableInteger, + }), + child_credential_policy: { + type: ['string', 'null'], + enum: ['none', 'inherit', 'downscope', 'independent', null], + }, + intent: strictObject({ + mode: { type: 'string', enum: ['execute', 'plan'] }, + read_only: { type: 'boolean' }, + }), + delegation: strictObject({ + mode: nullableString, + source_binding: { const: 'source_run_id' }, + max_depth: { type: 'integer', minimum: 1 }, + target_scope: nullableString, + allowed_delegators: { + type: 'array', + items: { type: 'string', minLength: 1 }, + uniqueItems: true, + }, + allowed_delegators_hash: digest, + require_grant_per_hop: { type: 'boolean' }, + }), + }), +}); + +function jsonType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (Number.isInteger(value)) return 'integer'; + if (typeof value === 'number') return 'number'; + return typeof value; +} + +function matchesType(value, expected) { + if (expected === 'number') return typeof value === 'number' && Number.isFinite(value); + if (expected === 'integer') return Number.isInteger(value); + if (expected === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value); + if (expected === 'array') return Array.isArray(value); + if (expected === 'null') return value === null; + return typeof value === expected; +} + +function validateNode(value, schema, path, errors) { + const expectedTypes = schema.type == null + ? [] + : Array.isArray(schema.type) + ? schema.type + : [schema.type]; + if (expectedTypes.length > 0 && !expectedTypes.some(type => matchesType(value, type))) { + errors.push(`${path} must be ${expectedTypes.join(' or ')}, received ${jsonType(value)}`); + return; + } + if (Object.hasOwn(schema, 'const') && !Object.is(value, schema.const)) { + errors.push(`${path} must equal ${JSON.stringify(schema.const)}`); + } + if (schema.enum && !schema.enum.some(candidate => Object.is(candidate, value))) { + errors.push(`${path} must be one of ${schema.enum.map(candidate => JSON.stringify(candidate)).join(', ')}`); + } + if (typeof value === 'string') { + if (schema.minLength != null && value.length < schema.minLength) { + errors.push(`${path} must contain at least ${schema.minLength} character(s)`); + } + if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) { + errors.push(`${path} must match ${schema.pattern}`); + } + } + if (typeof value === 'number' && schema.minimum != null && value < schema.minimum) { + errors.push(`${path} must be at least ${schema.minimum}`); + } + if (Array.isArray(value)) { + if (schema.minItems != null && value.length < schema.minItems) { + errors.push(`${path} must contain at least ${schema.minItems} item(s)`); + } + if (schema.uniqueItems === true) { + const unique = new Set(value.map(item => JSON.stringify(item))); + if (unique.size !== value.length) errors.push(`${path} must contain unique items`); + } + if (schema.items) { + value.forEach((item, index) => validateNode(item, schema.items, `${path}[${index}]`, errors)); + } + } + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const properties = schema.properties ?? {}; + for (const required of schema.required ?? []) { + if (!Object.hasOwn(value, required)) errors.push(`${path}.${required} is required`); + } + for (const [key, child] of Object.entries(value)) { + if (Object.hasOwn(properties, key)) { + validateNode(child, properties[key], `${path}.${key}`, errors); + } else if (schema.additionalProperties === false) { + errors.push(`${path}.${key} is not allowed`); + } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + validateNode(child, schema.additionalProperties, `${path}.${key}`, errors); + } + } + if (schema.propertyNames) { + for (const key of Object.keys(value)) { + validateNode(key, schema.propertyNames, `${path} property name`, errors); + } + } + } +} + +export function validateHandoffV4Structure(payload) { + const errors = []; + validateNode(payload, HANDOFF_V4_JSON_SCHEMA, 'artifact', errors); + return errors; +} diff --git a/src/handoff/v4.js b/src/handoff/v4.js index a810d62..e47f0df 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -8,14 +8,28 @@ import { buildEffectiveExecutionBinding, computeEffectiveTaskHash, } from '../compiler/shared.js'; +import { + HANDOFF_V4_SCHEMA, + HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + HANDOFF_V4_VERSION, + HANDOFF_V4_SCHEDULER_SCHEMA_MIN, + HANDOFF_V4_CANONICALIZATION, + HANDOFF_V4_CANONICALIZATION_VERSION, + HANDOFF_V4_EXECUTION_BINDING_VERSION, + HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, + validateHandoffV4Structure, +} from './schema-v4.js'; -export const HANDOFF_V4_SCHEMA = 'openclaw.scheduler.handoff-artifact'; -export const HANDOFF_V4_ARTIFACT_SCHEMA_VERSION = 1; -export const HANDOFF_V4_VERSION = 4; -export const HANDOFF_V4_SCHEDULER_SCHEMA_MIN = 29; -export const HANDOFF_V4_CANONICALIZATION = 'json-sort-v1'; -export const HANDOFF_V4_CANONICALIZATION_VERSION = 1; -export const HANDOFF_V4_EXECUTION_BINDING_VERSION = 2; +export { + HANDOFF_V4_SCHEMA, + HANDOFF_V4_ARTIFACT_SCHEMA_VERSION, + HANDOFF_V4_VERSION, + HANDOFF_V4_SCHEDULER_SCHEMA_MIN, + HANDOFF_V4_CANONICALIZATION, + HANDOFF_V4_CANONICALIZATION_VERSION, + HANDOFF_V4_EXECUTION_BINDING_VERSION, + HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, +} from './schema-v4.js'; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; const CRYPTOGRAPHIC_PROOF_METHODS = new Set(['jwt', 'detached-signature', 'certificate']); @@ -453,7 +467,7 @@ export function buildSchedulerHandoffV4Artifact({ }, execution_binding_version: HANDOFF_V4_EXECUTION_BINDING_VERSION, scheduler_job_binding: { - version: 1, + version: HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, digest: canonicalDigest(schedulerJobExecutionProjection(job)), }, manifest: { @@ -555,6 +569,37 @@ export function buildSchedulerHandoffV4Artifact({ return { payload, digest, effectiveTaskHash, canonical }; } +export function rebindSchedulerHandoffV4Job(job, overrides = {}) { + if (!job || Number(job.handoff_version) !== HANDOFF_V4_VERSION) { + throw new TypeError('rebindSchedulerHandoffV4Job requires a handoff v4 job'); + } + const reboundJob = { ...job, ...overrides }; + const payload = structuredClone(normalizePayload(job.handoff_artifact_payload)); + if (!payload?.scheduler_job_binding || typeof payload.scheduler_job_binding !== 'object') { + throw Object.assign(new Error('handoff v4 artifact is missing scheduler_job_binding'), { + code: 'HANDOFF_ARTIFACT_INVALID', + }); + } + payload.scheduler_job_binding = { + version: HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION, + digest: canonicalDigest(schedulerJobExecutionProjection(reboundJob)), + }; + const canonical = canonicalStringify(payload); + const digest = hashString(canonical); + const validation = validateSchedulerHandoffV4Artifact(payload, { expectedDigest: digest }); + if (!validation.ok) { + throw Object.assign( + new Error(`Rebound handoff v4 artifact is invalid: ${validation.errors.join('; ')}`), + { code: 'HANDOFF_ARTIFACT_INVALID', errors: validation.errors }, + ); + } + return { + ...reboundJob, + handoff_artifact_payload: payload, + handoff_artifact_digest: digest, + }; +} + export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = {}) { let payload; try { @@ -563,7 +608,7 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { return { ok: false, digest: null, errors: [error.message] }; } - const errors = []; + const errors = validateHandoffV4Structure(payload); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { ok: false, digest: null, errors: ['artifact payload must be an object'] }; } @@ -586,7 +631,7 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { errors.push('execution_binding_version must be 2'); } - if (payload.scheduler_job_binding?.version !== 1) { + if (payload.scheduler_job_binding?.version !== HANDOFF_V4_SCHEDULER_JOB_BINDING_VERSION) { errors.push('scheduler_job_binding.version must be 1'); } validateHash( diff --git a/src/jsonrpc.js b/src/jsonrpc.js index 5b8267f..33691cd 100644 --- a/src/jsonrpc.js +++ b/src/jsonrpc.js @@ -60,6 +60,7 @@ function schemaByName(name = 'manifest', { legacy = false } = {}) { const aliases = { 'scheduler-job': 'schedulerJob', 'standalone-plan': 'standalonePlan', + 'handoff-v4': 'handoffV4', 'rpc-request': 'rpcRequest', 'rpc-response': 'rpcResponse' }; diff --git a/src/schema.js b/src/schema.js index 0faac2a..5174fc2 100644 --- a/src/schema.js +++ b/src/schema.js @@ -1,3 +1,5 @@ +import { HANDOFF_V4_JSON_SCHEMA } from './handoff/schema-v4.js'; + export const MANIFEST_VERSION = '0.2'; const nullableString = { type: 'string', nullable: true }; @@ -1303,6 +1305,7 @@ export const JSON_SCHEMAS = Object.freeze({ title: 'agentcli standalone compiled plan', ...legacyDescriptorToJsonSchema(MANIFEST_SCHEMA.standalonePlan), }, + handoffV4: HANDOFF_V4_JSON_SCHEMA, rpcRequest: { $schema: JSON_SCHEMA_DIALECT, title: 'agentcli JSON-RPC request', diff --git a/src/targets.js b/src/targets.js index 47cb795..7bb533f 100644 --- a/src/targets.js +++ b/src/targets.js @@ -13,9 +13,9 @@ export const TARGETS = { name: 'openclaw-scheduler', description: 'Compile manifest tasks into OpenClaw Scheduler job specs for the durable runtime.', capabilities: ['compile', 'apply', 'inspect', 'field-mask', 'sanitize-basic', 'ndjson'], - // Static baseline features -- superseded by runtime capability negotiation when available. - // These serve as the fallback when the scheduler is unreachable or does not support - // the 'capabilities' command. + // Compile-shape declarations are available statically. Runtime enforcement + // capabilities remain false until a live scheduler explicitly advertises + // them, so an unavailable capabilities command cannot authorize execution. features: { approvals: 'runtime', model_policy: 'model+thinking', @@ -23,26 +23,26 @@ export const TARGETS = { output_hints: 'runtime', timeout_support: 'runtime', context_retrieval: 'runtime', - runtime_execution: true, - identity_declaration: true, - runtime_identity_resolution: true, - evidence_generation: true, - audit_export: true, - trust_evaluation: true, - delegation_validation: true, - credential_handoff: true, - authorization_proof_verification: true, - authorization_hook: true, - root_approval_gate: true, + runtime_execution: false, + identity_declaration: false, + runtime_identity_resolution: false, + evidence_generation: false, + audit_export: false, + trust_evaluation: false, + delegation_validation: false, + credential_handoff: false, + authorization_proof_verification: false, + authorization_hook: false, + root_approval_gate: false, approval_scope_enforcement: false, - structured_output_format: true, - handoff_v4_artifact: true, - artifact_bound_proofs: true, - signed_or_provider_verified_evidence: true, - provider_session_cache: true, - credential_presentation: true, - source_run_bound_delegation: true, - immutable_runtime_events: true, + structured_output_format: false, + handoff_v4_artifact: false, + artifact_bound_proofs: false, + signed_or_provider_verified_evidence: false, + provider_session_cache: false, + credential_presentation: false, + source_run_bound_delegation: false, + immutable_runtime_events: false, }, compile: compileManifestToScheduler, }, diff --git a/test/agentcli.test.js b/test/agentcli.test.js index dd7dd2f..0b8a64c 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -22,7 +22,13 @@ import { validateManifest } from '../src/validate.js'; import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; import { compileManifestToStandalone } from '../src/compiler/standalone.js'; import { applyManifestToScheduler, resolveSchedulerInvocation, shellCommandInvocation, SCHEDULER_FIELD_VERSIONS, SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02 } from '../src/apply.js'; -import { querySchedulerCapabilities, resolveEffectiveFeatures, validateManifestCapabilities } from '../src/capabilities.js'; +import { + HANDOFF_V4_REQUIRED_FEATURES, + HANDOFF_V4_RUNTIME_CONTRACT, + querySchedulerCapabilities, + resolveEffectiveFeatures, + validateManifestCapabilities, +} from '../src/capabilities.js'; import { resolveCommandValue } from '../src/command.js'; import { runCli } from '../src/cli.js'; import { inspectSchedulerState } from '../src/inspect.js'; @@ -985,16 +991,18 @@ test('applyManifestToScheduler plans and executes scheduler upserts', async () = assert.equal(calls[0].spec.enabled, true); }); -test('openclaw-scheduler target advertises the current verified runtime feature baseline', () => { +test('openclaw-scheduler static target keeps runtime enforcement disabled', () => { const target = listTargets().find(candidate => candidate.name === 'openclaw-scheduler'); assert.ok(target); - assert.equal(target.features.runtime_identity_resolution, true); - assert.equal(target.features.evidence_generation, true); - assert.equal(target.features.trust_evaluation, true); - assert.equal(target.features.delegation_validation, true); - assert.equal(target.features.handoff_v4_artifact, true); - assert.equal(target.features.artifact_bound_proofs, true); - assert.equal(target.features.signed_or_provider_verified_evidence, true); + assert.equal(target.features.model_policy, 'model+thinking'); + assert.equal(target.features.runtime_identity_resolution, false); + assert.equal(target.features.evidence_generation, false); + assert.equal(target.features.trust_evaluation, false); + assert.equal(target.features.delegation_validation, false); + assert.equal(target.features.root_approval_gate, false); + assert.equal(target.features.handoff_v4_artifact, false); + assert.equal(target.features.artifact_bound_proofs, false); + assert.equal(target.features.signed_or_provider_verified_evidence, false); }); test('applyManifestToScheduler projects only versioned runtime fields to backend specs', async () => { @@ -2450,6 +2458,23 @@ test('standalonePlan schema version has const constraint', async () => { assert.equal(output.schema.fields.version.const, '0.2'); }); +test('handoff v4 schema is discoverable from CLI and JSON-RPC', async () => { + const cli = JSON.parse(await runCli(['schema', 'handoff-v4'])); + assert.equal(cli.ok, true); + assert.equal(cli.schema.$schema, 'https://json-schema.org/draft/2020-12/schema'); + assert.equal(cli.schema.properties.handoff_version.const, 4); + assert.deepEqual(cli.schema.required.includes('scheduler_job_binding'), true); + + const rpc = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'handoff-v4-schema', + method: 'agentcli.schema', + params: { target: 'handoff-v4' }, + }); + assert.equal(rpc.result.ok, true); + assert.deepEqual(rpc.result.schema, cli.schema); +}); + test('json-rpc internal error uses fallback message', async () => { const response = await handleJsonRpcRequest({ jsonrpc: '2.0', @@ -7985,6 +8010,7 @@ test('querySchedulerCapabilities returns ok with features from a valid runner', scheduler_version: '0.2.0', schema_version: 22, handoff_version: '2', + handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, features: { approvals: 'runtime', runtime_execution: true, @@ -8007,6 +8033,7 @@ test('querySchedulerCapabilities returns ok with features from a valid runner', assert.strictEqual(result.version, '0.2.0'); assert.strictEqual(result.handoff_version, '2'); assert.strictEqual(result.schema_version, 22); + assert.deepEqual(result.handoff_contract, HANDOFF_V4_RUNTIME_CONTRACT); assert.strictEqual(result.features.authorization_hook, true); assert.strictEqual(result.features.runtime_identity_resolution, true); }); @@ -8056,7 +8083,10 @@ test('resolveEffectiveFeatures upgrades static false to runtime true', () => { runtime_identity_resolution: true, authorization_proof_verification: true, credential_handoff: true, - } + }, + schema_version: 29, + handoff_version: '4', + handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, }; const result = resolveEffectiveFeatures('openclaw-scheduler', caps); assert.strictEqual(result.negotiated, true); @@ -8067,6 +8097,9 @@ test('resolveEffectiveFeatures upgrades static false to runtime true', () => { assert.strictEqual(result.features.runtime_identity_resolution, true); assert.strictEqual(result.features.authorization_proof_verification, true); assert.strictEqual(result.features.credential_handoff, true); + assert.strictEqual(result.schema_version, 29); + assert.strictEqual(result.handoff_version, '4'); + assert.deepEqual(result.handoff_contract, HANDOFF_V4_RUNTIME_CONTRACT); }); test('resolveEffectiveFeatures treats live false values as authoritative downgrades', () => { @@ -8114,9 +8147,9 @@ test('resolveEffectiveFeatures returns static features when no runtime capabilit const result = resolveEffectiveFeatures('openclaw-scheduler', null); assert.strictEqual(result.negotiated, false); assert.strictEqual(result.source, 'static'); - assert.strictEqual(result.features.authorization_hook, true); - assert.strictEqual(result.features.handoff_v4_artifact, true); - assert.strictEqual(result.features.runtime_execution, true); + assert.strictEqual(result.features.authorization_hook, false); + assert.strictEqual(result.features.handoff_v4_artifact, false); + assert.strictEqual(result.features.runtime_execution, false); }); test('resolveEffectiveFeatures returns static features when runtime ok is false', () => { @@ -8560,18 +8593,26 @@ test('applyManifestToScheduler preserves capability warnings in structured resul } }); -test('applyManifestToScheduler skips runtime capability queries for pure v0.1 manifests', async () => { +test('applyManifestToScheduler negotiates v4 for pure v0.1 manifests', async () => { let capabilityCalls = 0; + const added = []; const runner = { invocation: { label: 'fake-scheduler' }, queryCapabilities() { capabilityCalls += 1; - throw new Error('capabilities should not be queried for v0.1 manifests'); + return { + scheduler_version: '0.5.0', + schema_version: 29, + handoff_version: '4', + handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, + features: Object.fromEntries(HANDOFF_V4_REQUIRED_FEATURES.map(feature => [feature, true])), + }; }, listJobs() { return []; }, addJob(spec) { + added.push(spec); return { ok: true, job: spec }; }, updateJob(id, spec) { @@ -8581,10 +8622,14 @@ test('applyManifestToScheduler skips runtime capability queries for pure v0.1 ma const result = await applyManifestToScheduler(exampleManifest, { runner }); assert.strictEqual(result.ok, true); - assert.strictEqual(capabilityCalls, 0); + assert.strictEqual(capabilityCalls, 1); assert.ok(result.capabilities); - assert.strictEqual(result.capabilities.source, 'static'); - assert.strictEqual(result.capabilities.negotiated, false); + assert.strictEqual(result.capabilities.source, 'runtime'); + assert.strictEqual(result.capabilities.negotiated, true); + assert.strictEqual(result.handoff.field_version, '4'); + assert.ok(added.length > 0); + assert.ok(added.every(spec => spec.handoff_version === 4)); + assert.ok(added.every(spec => typeof spec.handoff_artifact_digest === 'string')); }); test('applyManifestToScheduler falls back to static when runner lacks queryCapabilities', async () => { @@ -8608,6 +8653,27 @@ test('applyManifestToScheduler falls back to static when runner lacks queryCapab assert.strictEqual(result.capabilities.negotiated, false); }); +test('static fallback refuses an unconfirmed v0.1 root approval gate', async () => { + const manifest = structuredClone(exampleManifest); + manifest.workflows[0].tasks[0].approval = { required: true, policy: 'manual' }; + const runner = { + invocation: { label: 'scheduler-without-capability-discovery' }, + listJobs: () => [], + addJob() { + throw new Error('unsafe job must not be written'); + }, + updateJob() { + throw new Error('unsafe job must not be written'); + }, + }; + + await assert.rejects( + applyManifestToScheduler(manifest, { runner }), + error => error.code === 'unsupported_capability' + && error.capability_errors?.some(item => item.feature === 'root_approval_gate'), + ); +}); + // --------------------------------------------------------------------------- // Versioned Scheduler Field Projection (Track 8) // --------------------------------------------------------------------------- diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 31d95e5..7b2f74a 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -11,18 +11,22 @@ import { requiredSchedulerFieldVersion, schedulerCreateSpec, } from '../src/apply.js'; +import { HANDOFF_V4_RUNTIME_CONTRACT } from '../src/capabilities.js'; import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; import { + rebindSchedulerHandoffV4Job, validateSchedulerHandoffV4Artifact, } from '../src/handoff/v4.js'; import { canonicalStringify } from '../src/canonical.js'; import { jwtVerifier } from '../src/authorization-proof/jwt.js'; import { buildDetachedSignatureV4SigningContent, + detachedSignatureKeyId, detachedSignatureVerifier, } from '../src/authorization-proof/detached-signature.js'; import { buildCertificateV4SigningContent, + certificateProofKeyId, certificateVerifier, } from '../src/authorization-proof/certificate.js'; import { listInspectableEntities } from '../src/inspect.js'; @@ -87,7 +91,12 @@ function manifest() { }; } -function runner({ handoffVersion = '4', features = V4_FEATURES } = {}) { +function runner({ + handoffVersion = '4', + schemaVersion = 29, + handoffContract = HANDOFF_V4_RUNTIME_CONTRACT, + features = V4_FEATURES, +} = {}) { const added = []; return { invocation: { label: 'mock-scheduler' }, @@ -95,8 +104,9 @@ function runner({ handoffVersion = '4', features = V4_FEATURES } = {}) { queryCapabilities() { return { scheduler_version: 'test', - schema_version: 29, + schema_version: schemaVersion, handoff_version: handoffVersion, + handoff_contract: handoffContract, features, }; }, @@ -121,6 +131,7 @@ function statefulRunner(initialJobs = []) { scheduler_version: 'test', schema_version: 29, handoff_version: '4', + handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, features: V4_FEATURES, }; }, @@ -202,6 +213,52 @@ test('handoff v4 artifact is canonical, deterministic, and tamper evident', () = assert.match(futureValidation.errors.join('; '), /exactly 29/); }); +test('handoff v4 validation rejects missing identity fields and unknown properties', () => { + const payload = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload; + + for (const path of [ + ['manifest', 'workflow_id'], + ['manifest', 'task_id'], + ['compiled', 'job_id'], + ['command', 'kind'], + ['runtime', 'timeout_ms'], + ]) { + const missing = structuredClone(payload); + delete missing[path[0]][path[1]]; + const validation = validateSchedulerHandoffV4Artifact(missing); + assert.equal(validation.ok, false, path.join('.')); + assert.match(validation.errors.join('; '), new RegExp(`${path.join('\\.')} is required`)); + } + + const unknown = structuredClone(payload); + unknown.command.secret = 'must-not-persist'; + const validation = validateSchedulerHandoffV4Artifact(unknown); + assert.equal(validation.ok, false); + assert.match(validation.errors.join('; '), /artifact\.command\.secret is not allowed/); + + for (const field of ['access_token', 'password', 'private_key']) { + const credentialBearing = structuredClone(payload); + credentialBearing.identity.presentation.bindings = [{ + name: 'credential', + medium: 'env', + env_key: 'CREDENTIAL', + file_name: null, + source_hash: null, + required: true, + redact: true, + format: 'raw', + [field]: 'must-not-persist', + }]; + const credentialValidation = validateSchedulerHandoffV4Artifact(credentialBearing); + assert.equal(credentialValidation.ok, false, field); + assert.match(credentialValidation.errors.join('; '), new RegExp(`${field} is not allowed`)); + } +}); + test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { const fixture = sharedConformanceFixture; const [job] = compileManifestToScheduler(fixture.manifest, { @@ -342,6 +399,26 @@ test('scheduler execution binding changes when execution controls change', () => assert.notEqual(compile(first), compile(formattedDifferently)); }); +test('handoff v4 scheduler rebinding replaces adoption metadata atomically', () => { + const job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const originalDigest = job.handoff_artifact_digest; + const originalBinding = job.handoff_artifact_payload.scheduler_job_binding.digest; + const rebound = rebindSchedulerHandoffV4Job(job, { origin: 'legacy-origin' }); + + assert.equal(rebound.origin, 'legacy-origin'); + assert.notEqual(rebound.handoff_artifact_digest, originalDigest); + assert.notEqual(rebound.handoff_artifact_payload.scheduler_job_binding.digest, originalBinding); + assert.equal(job.origin, 'system'); + assert.equal(job.handoff_artifact_digest, originalDigest); + assert.equal(validateSchedulerHandoffV4Artifact(rebound.handoff_artifact_payload, { + expectedDigest: rebound.handoff_artifact_digest, + }).ok, true); +}); + test('v4 builder rejects raw credential bindings', () => { const compiled = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -407,6 +484,42 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { env: { PATH: '/usr/bin' }, }); assert.equal(oldRuntime.handoff.field_version, '3'); + + for (const [name, options] of [ + ['missing contract', { handoffContract: null }], + ['old artifact schema', { + handoffContract: { ...HANDOFF_V4_RUNTIME_CONTRACT, artifact_schema_version: 0 }, + }], + ['future canonicalization', { + handoffContract: { ...HANDOFF_V4_RUNTIME_CONTRACT, canonicalization_version: 2 }, + }], + ['old scheduler schema', { schemaVersion: 28 }], + ]) { + const incompatibleRunner = runner(options); + const result = await applyManifestToScheduler(manifest(), { + runner: incompatibleRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(result.handoff.field_version, '3', name); + assert.equal('handoff_version' in incompatibleRunner.added[0], false, name); + } +}); + +test('v4 apply hashes the same merged operational environment given to the scheduler', async () => { + const scheduler = runner(); + const env = { PATH: '/v4-test/bin' }; + await applyManifestToScheduler(manifest(), { + runner: scheduler, + cwd: '/tmp', + env, + }); + const expected = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { ...process.env, ...env }, + }).jobs[0]; + assert.equal(scheduler.added[0].effective_task_hash, expected.effective_task_hash); + assert.equal(scheduler.added[0].handoff_artifact_digest, expected.handoff_artifact_digest); }); test('v4 apply add, update, clear-null, and adopt preserve complete immutable artifacts', async () => { @@ -463,7 +576,21 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar assert.equal(adopted.actions[0].adopted_from_job_id, legacyId); assert.deepEqual(adopter.history.map(entry => entry.action), ['add', 'delete']); assert.equal(adopter.history[0].spec.origin, 'legacy-origin'); - assert.equal(JSON.parse(adopter.history[0].spec.handoff_artifact_payload).handoff_version, 4); + const adoptedPayload = JSON.parse(adopter.history[0].spec.handoff_artifact_payload); + assert.equal(adoptedPayload.handoff_version, 4); + const expectedAdopted = rebindSchedulerHandoffV4Job( + compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { ...process.env, PATH: '/usr/bin' }, + }).jobs[0], + { origin: 'legacy-origin' }, + ); + assert.equal( + adoptedPayload.scheduler_job_binding.digest, + expectedAdopted.handoff_artifact_payload.scheduler_job_binding.digest, + ); + assert.equal(adopter.history[0].spec.handoff_artifact_digest, expectedAdopted.handoff_artifact_digest); }); test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { @@ -520,6 +647,28 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che const unbound = jwtVerifier.verifyProof(missingArtifact, profile, context); assert.equal(unbound.verified, false); assert.match(unbound.reason, /artifact digest claim/); + + const inverted = signJwt({ + ...payload, + iat: now + 30, + exp: now - 30, + jti: 'proof-inverted', + }, privateKey); + const invertedResult = jwtVerifier.verifyProof(inverted, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + }); + assert.equal(invertedResult.verified, false); + assert.match(invertedResult.reason, /exp claim must be greater than iat/); + + const indeterminateRevocation = signJwt({ ...payload, jti: 'proof-indeterminate' }, privateKey); + const indeterminateResult = jwtVerifier.verifyProof(indeterminateRevocation, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + checkProofRevocation: () => undefined, + }); + assert.equal(indeterminateResult.verified, false); + assert.match(indeterminateResult.reason, /did not explicitly confirm/); }); test('handoff v4 detached signatures cover nonce, validity, key, and artifact metadata', () => { @@ -534,7 +683,7 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me nonce: 'detached-proof-1', issuedAt: new Date(now - 1_000).toISOString(), expiresAt: new Date(now + 60_000).toISOString(), - keyId: 'rsa-test-key', + keyId: detachedSignatureKeyId(publicKey), }; const signer = createSign('RSA-SHA256'); signer.update(buildDetachedSignatureV4SigningContent(fields)); @@ -586,6 +735,45 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me const replay = detachedSignatureVerifier.verifyProof(envelope, profile, context); assert.equal(replay.verified, false); assert.match(replay.signature_verification_reason, /replay refused/); + + const wrongKeyFields = { + ...fields, + nonce: 'detached-proof-wrong-key', + keyId: 'spki-sha256:unrelated', + }; + const wrongKeySigner = createSign('RSA-SHA256'); + wrongKeySigner.update(buildDetachedSignatureV4SigningContent(wrongKeyFields)); + const wrongKey = detachedSignatureVerifier.verifyProof({ + signature: wrongKeySigner.sign(privateKey).toString('base64'), + artifact_digest: wrongKeyFields.artifactDigest, + nonce: wrongKeyFields.nonce, + issued_at: wrongKeyFields.issuedAt, + expires_at: wrongKeyFields.expiresAt, + key_id: wrongKeyFields.keyId, + }, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + }); + assert.equal(wrongKey.verified, false); + assert.match(wrongKey.signature_verification_reason, /key_id does not match/); + + const uncheckedFields = { ...fields, nonce: 'detached-proof-unchecked' }; + const uncheckedSigner = createSign('RSA-SHA256'); + uncheckedSigner.update(buildDetachedSignatureV4SigningContent(uncheckedFields)); + const unchecked = detachedSignatureVerifier.verifyProof({ + signature: uncheckedSigner.sign(privateKey).toString('base64'), + artifact_digest: uncheckedFields.artifactDigest, + nonce: uncheckedFields.nonce, + issued_at: uncheckedFields.issuedAt, + expires_at: uncheckedFields.expiresAt, + key_id: uncheckedFields.keyId, + }, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + checkProofRevocation: () => null, + }); + assert.equal(unchecked.verified, false); + assert.match(unchecked.signature_verification_reason, /did not explicitly confirm/); }); test('handoff v4 certificate proof signs its replay and validity controls', t => { @@ -627,17 +815,18 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => env: { PATH: '/usr/bin' }, }).jobs[0]; const now = Date.now(); + const certificatePem = readFileSync(certPath, 'utf8'); const fields = { artifactDigest: compiled.handoff_artifact_digest, nonce: 'certificate-proof-1', issuedAt: new Date(now - 1_000).toISOString(), expiresAt: new Date(now + 60_000).toISOString(), - keyId: 'leaf-test-key', + keyId: certificateProofKeyId(certificatePem), }; const signer = createSign('SHA256'); signer.update(buildCertificateV4SigningContent(fields)); const envelope = { - certificate: readFileSync(certPath, 'utf8'), + certificate: certificatePem, signature: signer.sign(readFileSync(keyPath, 'utf8')).toString('base64'), artifact_digest: fields.artifactDigest, nonce: fields.nonce, @@ -687,6 +876,47 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => }); assert.equal(revoked.verified, false); assert.match(revoked.signature_verification_reason, /certificate revoked/); + + const wrongKeyFields = { + ...fields, + nonce: 'certificate-proof-wrong-key', + keyId: 'x509-sha256:unrelated', + }; + const wrongKeySigner = createSign('SHA256'); + wrongKeySigner.update(buildCertificateV4SigningContent(wrongKeyFields)); + const wrongKey = certificateVerifier.verifyProof({ + certificate: certificatePem, + signature: wrongKeySigner.sign(readFileSync(keyPath, 'utf8')).toString('base64'), + artifact_digest: wrongKeyFields.artifactDigest, + nonce: wrongKeyFields.nonce, + issued_at: wrongKeyFields.issuedAt, + expires_at: wrongKeyFields.expiresAt, + key_id: wrongKeyFields.keyId, + }, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + }); + assert.equal(wrongKey.verified, false); + assert.match(wrongKey.signature_verification_reason, /key_id does not match/); + + const uncheckedFields = { ...fields, nonce: 'certificate-proof-unchecked' }; + const uncheckedSigner = createSign('SHA256'); + uncheckedSigner.update(buildCertificateV4SigningContent(uncheckedFields)); + const unchecked = certificateVerifier.verifyProof({ + certificate: certificatePem, + signature: uncheckedSigner.sign(readFileSync(keyPath, 'utf8')).toString('base64'), + artifact_digest: uncheckedFields.artifactDigest, + nonce: uncheckedFields.nonce, + issued_at: uncheckedFields.issuedAt, + expires_at: uncheckedFields.expiresAt, + key_id: uncheckedFields.keyId, + }, profile, { + ...context, + claimProofReplay: () => ({ claimed: true }), + checkProofRevocation: () => ({ ok: false, reason: 'revocation backend unavailable' }), + }); + assert.equal(unchecked.verified, false); + assert.match(unchecked.signature_verification_reason, /revocation backend unavailable/); }); test('inspect advertises every v4 immutable runtime entity', () => { diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index 67f733b..a8315a2 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -85,7 +85,11 @@ const v02RuntimeSkipReason = schedulerRuntime.ok if (!schedulerRuntime.ok) { describe('integration-scheduler (skipped)', { skip: schedulerRuntime.reason }, () => { - it('placeholder', () => {}); + it('records a concrete scheduler probe failure', () => { + assert.equal(schedulerRuntime.ok, false); + assert.equal(typeof schedulerRuntime.reason, 'string'); + assert.ok(schedulerRuntime.reason.length > 0); + }); }); } else { describe('integration-scheduler', () => { @@ -398,8 +402,8 @@ if (!schedulerRuntime.ok) { assert.equal(result.ok, true); assert.ok(result.handoff, 'result should include handoff metadata'); assert.ok( - result.handoff.field_version === '1' || result.handoff.field_version === '2', - `field_version should be '1' or '2', got '${result.handoff.field_version}'` + ['1', '2', '3', '4'].includes(result.handoff.field_version), + `field_version should be a supported handoff version, got '${result.handoff.field_version}'` ); assert.equal(typeof result.handoff.projected_fields, 'number', 'projected_fields should be a number'); assert.ok(result.handoff.projected_fields > 0, 'projected_fields should be positive'); @@ -452,7 +456,7 @@ if (!schedulerRuntime.ok) { assert.ok(!afterJobs.find(j => j.id === legacyId), 'legacy id job should be removed after adoption'); }); - it('v0.1 manifest backward compatibility with v22 scheduler', async () => { + it('v0.1 manifest backward compatibility with the current scheduler', async () => { // hello-world.json is a v0.1 manifest. This test explicitly verifies // that v0.1 manifests still produce successful results against the // current scheduler after all v0.2 capability negotiation changes. @@ -470,12 +474,11 @@ if (!schedulerRuntime.ok) { assert.equal(result.target, 'openclaw-scheduler'); assert.equal(result.dry_run, false); - // Pure v0.1 manifests skip the extra runtime capability probe on apply. - // Use `agentcli apply --check-capabilities` when the caller wants a full - // runtime capability snapshot without requiring v0.2 fields. + // Every apply probes capabilities so v0.1 jobs also receive immutable v4 + // artifacts when the runtime advertises the exact contract. assert.ok(result.capabilities, 'v0.1 apply should include capability metadata'); - assert.equal(result.capabilities.source, 'static', 'v0.1 apply should use the static baseline'); - assert.equal(result.capabilities.negotiated, false, 'v0.1 apply should skip negotiation'); + assert.equal(result.capabilities.source, 'runtime', 'v0.1 apply should use live capabilities'); + assert.equal(result.capabilities.negotiated, true, 'v0.1 apply should negotiate the handoff'); // All jobs should be created successfully for (const action of result.actions) { From 597e955bce450ae3b458c90eaa47ca8d666e379b Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 23:19:52 -0400 Subject: [PATCH 06/29] test: honor exact handoff v4 negotiation --- test/integration-scheduler.test.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index a8315a2..263d34e 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { applyManifestToScheduler, createSchedulerCliRunner, resolveSchedulerInvocation } from '../src/apply.js'; +import { supportsSchedulerHandoffV4 } from '../src/capabilities.js'; import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; const SCHEDULER_PATH = process.env.SCHEDULER_PATH || resolve(import.meta.dirname, '../../openclaw-scheduler'); @@ -298,11 +299,15 @@ if (!schedulerRuntime.ok) { assert.equal(result.capabilities.negotiated, true, 'capabilities should be negotiated'); assert.ok(result.handoff, 'result should include handoff metadata'); assert.equal(result.handoff.v02_fields_included, true, 'v0.2 fields should be included'); - const expectedVersion = String(Math.min( - Number.parseInt(schedulerRuntime.capabilities.handoff_version, 10), - 4, - )); - assert.equal(result.handoff.field_version, expectedVersion, 'field_version should match the negotiated runtime version'); + const advertisedVersion = Number.parseInt(schedulerRuntime.capabilities.handoff_version, 10); + const expectedVersion = supportsSchedulerHandoffV4(schedulerRuntime.capabilities) + ? 4 + : Math.min(advertisedVersion, 3); + assert.equal( + result.handoff.field_version, + String(expectedVersion), + 'field_version should match the exact compatible runtime contract', + ); }); it('v0.2 identity fields are stored in scheduler', { skip: v02RuntimeSkipReason || false }, async () => { From aebb6bc77bd169de6917996cd762629d6cb8ecc7 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 23:23:52 -0400 Subject: [PATCH 07/29] fix: bind scheduler routing controls --- fixtures/handoff-v4/conformance.json | 4 ++-- src/handoff/v4.js | 3 +++ test/handoff-v4.test.js | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json index 710b82b..1edd603 100644 --- a/fixtures/handoff-v4/conformance.json +++ b/fixtures/handoff-v4/conformance.json @@ -62,10 +62,10 @@ ] }, "expected": { - "artifact_digest": "sha256:ff67b39864301a09e9b22d59ea5cda12164e23f61ed19f8a236d12baa23d58f6", + "artifact_digest": "sha256:d33f79dfe30b618040497f01f708667b7e4a613806cadd385294844186b550b7", "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", "effective_task_hash": "sha256:ffc128392f8cd4bbbdea2d68799fe72cfeef0e72f7daa6b02ab507c2371bb352", - "scheduler_job_binding_digest": "sha256:b01c09ed6fee8b142c8b40e63db40c6580118be303de6146977b978efeeb70af" + "scheduler_job_binding_digest": "sha256:1b9e18a83a48d085f0b2bdb03c99365e79cb4de21ffb71358c32c8e0eb71ef7d" }, "negative_artifact_cases": [ { diff --git a/src/handoff/v4.js b/src/handoff/v4.js index e47f0df..1ab5e97 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -64,6 +64,8 @@ function schedulerJobExecutionProjection(job) { }, reliability: { overlap_policy: job.overlap_policy ?? 'skip', + resource_pool: job.resource_pool ?? null, + job_class: job.job_class ?? 'standard', max_retries: job.max_retries ?? 0, max_queued_dispatches: job.max_queued_dispatches ?? 25, max_pending_approvals: job.max_pending_approvals ?? 10, @@ -88,6 +90,7 @@ function schedulerJobExecutionProjection(job) { session_target: job.session_target ?? null, agent_id: job.agent_id ?? 'main', payload_kind: job.payload_kind ?? null, + payload_scope: job.payload_scope ?? 'own', }, command: { payload_message_sha256: hashString(job.payload_message ?? ''), diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 7b2f74a..b727a27 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -399,6 +399,28 @@ test('scheduler execution binding changes when execution controls change', () => assert.notEqual(compile(first), compile(formattedDifferently)); }); +test('scheduler execution binding covers routing and resource controls', () => { + const job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const originalBinding = job.handoff_artifact_payload.scheduler_job_binding.digest; + + for (const override of [ + { payload_scope: 'global' }, + { resource_pool: 'different-pool' }, + { job_class: 'pre_compaction_flush' }, + ]) { + const rebound = rebindSchedulerHandoffV4Job(job, override); + assert.notEqual( + rebound.handoff_artifact_payload.scheduler_job_binding.digest, + originalBinding, + `${Object.keys(override)[0]} must affect scheduler_job_binding.digest`, + ); + } +}); + test('handoff v4 scheduler rebinding replaces adoption metadata atomically', () => { const job = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', From 3bd1aab69d5d8b78816e1c7adb0c8b57e18bf025 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sat, 18 Jul 2026 23:28:09 -0400 Subject: [PATCH 08/29] fix: preserve v4 compile environment --- CHANGELOG.md | 3 ++- docs/spec.md | 7 ++++--- src/apply.js | 2 +- test/handoff-v4.test.js | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a129b4..29ef99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ - scheduler: complete non-lossy create, replace-style update, null clear, and adopt projections now carry the immutable artifact payload and digest - scheduler: adoption rebinds the artifact after the final origin is selected, - and apply hashes the same merged operational environment passed to the runner + and apply preserves direct-compile hashes for the caller's explicit compile + environment while host variables are merged only for scheduler process spawn - security: JWT, detached-signature, and certificate proofs bind the exact v4 artifact, replay identifier, validity, key, and revocation result - security: v4 proofs reject inverted validity intervals, bind asserted key IDs diff --git a/docs/spec.md b/docs/spec.md index 2f80e41..11c43eb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -922,9 +922,10 @@ AgentCLI probes live scheduler capabilities for every apply, including a basic v0.1 manifest, so immutable artifacts cover all jobs when v4 is available. An unavailable capability command leaves security-relevant runtime features false. During adoption, any final persisted origin override is rebound into the -scheduler job binding before create. The environment used to compile execution -hashes is the same merged operational environment passed to the scheduler -process. +scheduler job binding before create. The caller's compile environment is used +unchanged for execution hashes so direct compile and apply remain deterministic. +Host process variables MAY be merged separately when spawning the scheduler CLI, +but that transport environment MUST NOT alter the compiled artifact. ## Compiler Targets diff --git a/src/apply.js b/src/apply.js index 54bc793..f0deec3 100644 --- a/src/apply.js +++ b/src/apply.js @@ -278,7 +278,7 @@ export async function applyManifestToScheduler( includeExplain, schedulerHandoffVersion: '4', cwd, - env: schedulerEnv, + env, }); } diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index b727a27..47cffed 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -527,7 +527,7 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { } }); -test('v4 apply hashes the same merged operational environment given to the scheduler', async () => { +test('v4 apply preserves direct-compile digests for an explicit environment', async () => { const scheduler = runner(); const env = { PATH: '/v4-test/bin' }; await applyManifestToScheduler(manifest(), { @@ -538,7 +538,7 @@ test('v4 apply hashes the same merged operational environment given to the sched const expected = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', cwd: '/tmp', - env: { ...process.env, ...env }, + env, }).jobs[0]; assert.equal(scheduler.added[0].effective_task_hash, expected.effective_task_hash); assert.equal(scheduler.added[0].handoff_artifact_digest, expected.handoff_artifact_digest); @@ -604,7 +604,7 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', cwd: '/tmp', - env: { ...process.env, PATH: '/usr/bin' }, + env: { PATH: '/usr/bin' }, }).jobs[0], { origin: 'legacy-origin' }, ); From cce0e4066b3d51062b51c7120450dede1ea01b58 Mon Sep 17 00:00:00 2001 From: Alex Mittell Date: Sun, 19 Jul 2026 00:00:08 -0400 Subject: [PATCH 09/29] fix: close final handoff v4 review gaps --- src/apply.js | 24 ++++++- src/authorization-proof/certificate.js | 2 +- src/handoff/schema-v4.js | 7 +- test/handoff-v4.test.js | 90 ++++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/apply.js b/src/apply.js index f0deec3..2f6bd92 100644 --- a/src/apply.js +++ b/src/apply.js @@ -412,6 +412,7 @@ export async function applyManifestToScheduler( if (exactMatch) { action = 'updated'; + existingJob = exactMatch; } else { duplicateLegacyJobs = sameNameJobs; } @@ -435,18 +436,37 @@ export async function applyManifestToScheduler( } } } else { - if (existingById.has(job.id)) { + existingJob = existingById.get(job.id) || null; + if (existingJob) { action = 'updated'; } else { action = 'created'; } } + if ( + action === 'updated' + && Number(existingJob?.handoff_version) === 4 + && Number(job.handoff_version) !== 4 + ) { + throw Object.assign( + new Error( + `Cannot update handoff v4 scheduler job "${job.id}" after runtime capability downgrade; ` + + 'restore the exact v4 runtime contract before applying changes' + ), + { code: 'unsupported_capability' } + ); + } + if (!dryRun) { if (action === 'created') { schedulerRunner.addJob(schedulerCreateSpec(job, { fieldVersion: handoffVersion })); } else if (action === 'updated') { - schedulerRunner.updateJob(job.id, schedulerUpdateSpec(job, { fieldVersion: handoffVersion })); + const existingOrigin = existingJob?.origin ?? job.origin ?? 'system'; + const updateJob = Number(job.handoff_version) === 4 + ? rebindSchedulerHandoffV4Job(job, { origin: existingOrigin }) + : job; + schedulerRunner.updateJob(job.id, schedulerUpdateSpec(updateJob, { fieldVersion: handoffVersion })); } else if (action === 'adopted') { if (typeof schedulerRunner.deleteJob !== 'function') { throw Object.assign( diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 7dd24f7..bf5f1da 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -644,7 +644,7 @@ const certificateVerifier = { : context.manifestContent; const possession = context.manifestContextError ? { verified: false, reason: context.manifestContextError } - : context.requireProofOfPossession + : envelope.v4 || context.requireProofOfPossession ? verifyProofOfPossession(cert, parsedProof.signature, possessionContent) : { verified: true }; diff --git a/src/handoff/schema-v4.js b/src/handoff/schema-v4.js index 46300ad..db1895d 100644 --- a/src/handoff/schema-v4.js +++ b/src/handoff/schema-v4.js @@ -139,7 +139,7 @@ export const HANDOFF_V4_JSON_SCHEMA = Object.freeze({ handoff: presentationMedium, bindings: { type: 'array', items: presentationBindingSchema }, default_redaction: { type: ['boolean', 'null'] }, - cleanup: { type: 'string', enum: ['always', 'on-success', 'never'] }, + cleanup: { type: 'string', enum: ['always', 'on-success', 'on-failure', 'never'] }, }), }), contract: strictObject({ @@ -159,7 +159,10 @@ export const HANDOFF_V4_JSON_SCHEMA = Object.freeze({ }), authorization_proof: strictObject({ ref: nullableString, - method: nullableString, + method: { + type: ['string', 'null'], + enum: ['none', 'jwt', 'detached-signature', 'certificate', null], + }, issuer: nullableString, audience: nullableString, claims_hash: nullableDigest, diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 47cffed..b097305 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -120,7 +120,12 @@ function runner({ }; } -function statefulRunner(initialJobs = []) { +function statefulRunner(initialJobs = [], { + handoffVersion = '4', + schemaVersion = 29, + handoffContract = HANDOFF_V4_RUNTIME_CONTRACT, + features = V4_FEATURES, +} = {}) { const jobs = new Map(initialJobs.map(job => [job.id, structuredClone(job)])); const history = []; return { @@ -129,10 +134,10 @@ function statefulRunner(initialJobs = []) { queryCapabilities() { return { scheduler_version: 'test', - schema_version: 29, - handoff_version: '4', - handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, - features: V4_FEATURES, + schema_version: schemaVersion, + handoff_version: handoffVersion, + handoff_contract: handoffContract, + features, }; }, listJobs() { @@ -259,6 +264,25 @@ test('handoff v4 validation rejects missing identity fields and unknown properti } }); +test('handoff v4 schema accepts every valid cleanup policy and rejects unknown proof methods', () => { + const payload = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload; + + const cleanupOnFailure = structuredClone(payload); + cleanupOnFailure.identity.presentation.cleanup = 'on-failure'; + const cleanupValidation = validateSchedulerHandoffV4Artifact(cleanupOnFailure); + assert.equal(cleanupValidation.ok, true, cleanupValidation.errors.join('; ')); + + const unknownProof = structuredClone(payload); + unknownProof.authorization_proof.method = 'jtw'; + const proofValidation = validateSchedulerHandoffV4Artifact(unknownProof); + assert.equal(proofValidation.ok, false); + assert.match(proofValidation.errors.join('; '), /authorization_proof\.method/); +}); + test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { const fixture = sharedConformanceFixture; const [job] = compileManifestToScheduler(fixture.manifest, { @@ -615,6 +639,50 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar assert.equal(adopter.history[0].spec.handoff_artifact_digest, expectedAdopted.handoff_artifact_digest); }); +test('v4 updates preserve the stored origin and reject runtime-contract downgrades', async () => { + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const existing = rebindSchedulerHandoffV4Job(compiled, { origin: 'legacy-origin' }); + const scheduler = statefulRunner([ + schedulerCreateSpec(existing, { originOverride: 'legacy-origin', fieldVersion: '4' }), + ]); + + const result = await applyManifestToScheduler(manifest(), { + runner: scheduler, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }); + assert.equal(result.actions[0].action, 'updated'); + const update = scheduler.history.at(-1); + assert.equal(update.action, 'update'); + const expected = rebindSchedulerHandoffV4Job(compiled, { origin: 'legacy-origin' }); + assert.equal(update.spec.handoff_artifact_digest, expected.handoff_artifact_digest); + assert.equal( + JSON.parse(update.spec.handoff_artifact_payload).scheduler_job_binding.digest, + expected.handoff_artifact_payload.scheduler_job_binding.digest, + ); + + const downgraded = statefulRunner([ + schedulerCreateSpec(existing, { originOverride: 'legacy-origin', fieldVersion: '4' }), + ], { + handoffVersion: '3', + features: { ...V4_FEATURES, immutable_runtime_events: false }, + }); + await assert.rejects( + applyManifestToScheduler(manifest(), { + runner: downgraded, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }), + error => error.code === 'unsupported_capability' + && /runtime capability downgrade/.test(error.message), + ); + assert.deepEqual(downgraded.history, []); +}); + test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const artifactDigest = `sha256:${'a'.repeat(64)}`; @@ -874,6 +942,18 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => assert.equal(verified.verified, true, verified.signature_verification_reason); assert.equal(verified.verified_at, new Date(now).toISOString()); + const forged = certificateVerifier.verifyProof({ + ...envelope, + nonce: 'certificate-proof-forged', + signature: Buffer.from('forged-signature').toString('base64'), + }, profile, { + ...context, + requireProofOfPossession: false, + }); + assert.equal(forged.verified, false); + assert.equal(forged.proof_of_possession_verified, false); + assert.match(forged.signature_verification_reason, /signature verification failed/); + const tampered = certificateVerifier.verifyProof({ ...envelope, nonce: 'certificate-proof-tampered', From 656abee8d655cd476e7914cbad17a5e6a88dfa5c Mon Sep 17 00:00:00 2001 From: Alex Mittell Date: Sun, 19 Jul 2026 00:19:42 -0400 Subject: [PATCH 10/29] ci: pin final handoff v4 scheduler --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a5987d..391199a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: 1f40fca64ce2820f065d1ed45c1f71173724567b + OPENCLAW_SCHEDULER_REF: bb9dae60270d2945bec62403bc6f08adc7c50ec8 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 1f40fca64ce2820f065d1ed45c1f71173724567b + ref: bb9dae60270d2945bec62403bc6f08adc7c50ec8 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c949d1b..5f2adad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: 1f40fca64ce2820f065d1ed45c1f71173724567b + OPENCLAW_SCHEDULER_REF: bb9dae60270d2945bec62403bc6f08adc7c50ec8 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 1f40fca64ce2820f065d1ed45c1f71173724567b + ref: bb9dae60270d2945bec62403bc6f08adc7c50ec8 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" From 1f160d20bd7ed56ae23e00e7786d96676943d181 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 00:35:42 -0400 Subject: [PATCH 11/29] fix: close late handoff v4 review gaps --- fixtures/handoff-v4/conformance.json | 4 +- src/apply.js | 5 +- src/authorization-proof/certificate.js | 50 +++--- src/authorization-proof/detached-signature.js | 56 ++++--- src/authorization-proof/jwt.js | 77 +++++---- src/capabilities.js | 1 + src/compiler/shared.js | 6 +- src/handoff/v4.js | 94 +++++++++++ test/handoff-v4.test.js | 156 +++++++++++++++++- 9 files changed, 364 insertions(+), 85 deletions(-) diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json index 1edd603..3a1a901 100644 --- a/fixtures/handoff-v4/conformance.json +++ b/fixtures/handoff-v4/conformance.json @@ -62,9 +62,9 @@ ] }, "expected": { - "artifact_digest": "sha256:d33f79dfe30b618040497f01f708667b7e4a613806cadd385294844186b550b7", + "artifact_digest": "sha256:1910808223999c0e2d6cd469cc08b80a8d48fcec76d3e29163ecfb53bd55a2f7", "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", - "effective_task_hash": "sha256:ffc128392f8cd4bbbdea2d68799fe72cfeef0e72f7daa6b02ab507c2371bb352", + "effective_task_hash": "sha256:0d6b7a63ba7ff75811e5db236b84648dc9547d2ce3d22cdc42eec621c9ff98e4", "scheduler_job_binding_digest": "sha256:1b9e18a83a48d085f0b2bdb03c99365e79cb4de21ffb71358c32c8e0eb71ef7d" }, "negative_artifact_cases": [ diff --git a/src/apply.js b/src/apply.js index 2f6bd92..9a5d64c 100644 --- a/src/apply.js +++ b/src/apply.js @@ -445,13 +445,14 @@ export async function applyManifestToScheduler( } if ( - action === 'updated' + (action === 'updated' || action === 'adopted') && Number(existingJob?.handoff_version) === 4 && Number(job.handoff_version) !== 4 ) { throw Object.assign( new Error( - `Cannot update handoff v4 scheduler job "${job.id}" after runtime capability downgrade; ` + + `Cannot ${action === 'adopted' ? 'adopt' : 'update'} handoff v4 scheduler job ` + + `"${existingJob?.id ?? job.id}" after runtime capability downgrade; ` + 'restore the exact v4 runtime contract before applying changes' ), { code: 'unsupported_capability' } diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index bf5f1da..13e452e 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -137,7 +137,11 @@ function validateV4CertificateEnvelope(parsed, context) { if (normalizeDigest(parsed.artifact_digest) !== normalizeDigest(context.artifactDigest)) { return { ok: false, v4: true, reason: 'certificate proof artifact digest does not match' }; } - const now = typeof context.now === 'number' ? context.now : Date.now(); + const now = typeof context.now === 'number' + ? context.now + : context.now instanceof Date + ? context.now.getTime() + : Date.now(); const issuedAt = Date.parse(parsed.issued_at); const expiresAt = Date.parse(parsed.expires_at); const skewMs = (context.clockSkewSeconds ?? 60) * 1000; @@ -165,28 +169,6 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { return { ok: false, reason: 'certificate proof key_id does not match the verified certificate' }; } - const claimReplay = context.claimProofReplay - ?? context.replayStore?.claim?.bind(context.replayStore); - if (typeof claimReplay !== 'function') { - return { ok: false, reason: 'handoff v4 proof replay store is required' }; - } - const replay = claimReplay({ - method: 'certificate', - issuer: profile.issuer ?? cert.issuer ?? null, - subject: cert.subject ?? null, - proofId: parsed.nonce, - artifactDigest: context.artifactDigest, - expiresAt: parsed.expires_at, - runId: context.runId ?? null, - }); - if (replay && typeof replay.then === 'function') { - return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; - } - const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; - if (!replayProtected) { - return { ok: false, reason: replay?.reason || 'certificate proof nonce was already used' }; - } - const checkRevocation = context.checkProofRevocation ?? context.revocationChecker?.check?.bind(context.revocationChecker); if (typeof checkRevocation !== 'function') { @@ -215,6 +197,28 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { || 'handoff v4 revocation checker did not explicitly confirm the certificate is not revoked', }; } + + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (typeof claimReplay !== 'function') { + return { ok: false, reason: 'handoff v4 proof replay store is required' }; + } + const replay = claimReplay({ + method: 'certificate', + issuer: profile.issuer ?? cert.issuer ?? null, + subject: cert.subject ?? null, + proofId: parsed.nonce, + artifactDigest: context.artifactDigest, + expiresAt: parsed.expires_at, + runId: context.runId ?? null, + }); + if (replay && typeof replay.then === 'function') { + return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; + } + const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + if (!replayProtected) { + return { ok: false, reason: replay?.reason || 'certificate proof nonce was already used' }; + } return { ok: true, replayProtected: true, revocationChecked: true }; } diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index 6ae8ba6..b57779b 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -185,7 +185,11 @@ function parseV4Envelope(proof, context) { return { error: 'detached proof artifact digest does not match', v4: true }; } - const now = typeof context.now === 'number' ? context.now : Date.now(); + const now = typeof context.now === 'number' + ? context.now + : context.now instanceof Date + ? context.now.getTime() + : Date.now(); const issuedAt = Date.parse(envelope.issued_at); const expiresAt = Date.parse(envelope.expires_at); const skewMs = (context.clockSkewSeconds ?? 60) * 1000; @@ -212,27 +216,6 @@ function enforceV4RuntimeGuards(parsed, context, profile, verifiedKeyId) { return { ok: false, reason: 'detached proof key_id does not match the verified signing key' }; } - const claimReplay = context.claimProofReplay - ?? context.replayStore?.claim?.bind(context.replayStore); - if (typeof claimReplay !== 'function') { - return { ok: false, reason: 'handoff v4 proof replay store is required' }; - } - const replay = claimReplay({ - method: 'detached-signature', - issuer: profile.issuer ?? null, - proofId: parsed.envelope.nonce, - artifactDigest: context.artifactDigest, - expiresAt: parsed.envelope.expires_at, - runId: context.runId ?? null, - }); - if (replay && typeof replay.then === 'function') { - return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; - } - const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; - if (!replayProtected) { - return { ok: false, reason: replay?.reason || 'detached proof nonce was already used' }; - } - const checkRevocation = context.checkProofRevocation ?? context.revocationChecker?.check?.bind(context.revocationChecker); if (typeof checkRevocation !== 'function') { @@ -258,6 +241,27 @@ function enforceV4RuntimeGuards(parsed, context, profile, verifiedKeyId) { || 'handoff v4 revocation checker did not explicitly confirm the detached proof key is not revoked', }; } + + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (typeof claimReplay !== 'function') { + return { ok: false, reason: 'handoff v4 proof replay store is required' }; + } + const replay = claimReplay({ + method: 'detached-signature', + issuer: profile.issuer ?? null, + proofId: parsed.envelope.nonce, + artifactDigest: context.artifactDigest, + expiresAt: parsed.envelope.expires_at, + runId: context.runId ?? null, + }); + if (replay && typeof replay.then === 'function') { + return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; + } + const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + if (!replayProtected) { + return { ok: false, reason: replay?.reason || 'detached proof nonce was already used' }; + } return { ok: true, replayProtected: true, revocationChecked: true }; } @@ -523,10 +527,14 @@ const detachedSignatureVerifier = { verificationReason = `signature verification error: ${err.message}`; } - let verifiedKeyId = context.trustedKeyId ?? null; - if (signatureValid && !verifiedKeyId) { + let verifiedKeyId = null; + if (signatureValid) { try { verifiedKeyId = detachedSignatureKeyId(context.trustedKey); + if (context.trustedKeyId && context.trustedKeyId !== verifiedKeyId) { + signatureValid = false; + verificationReason = 'trusted detached-signature key ID does not match the verified signing key'; + } } catch (error) { signatureValid = false; verificationReason = `could not derive verified detached-signature key identity: ${error.message}`; diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index 2bf3865..c864e01 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -296,10 +296,22 @@ async function resolveJwtTrustedKey(proof, profile, ctx = {}) { }; } + let trustedKeyId; + try { + trustedKeyId = publicKeyId(selected.key); + } catch (error) { + return { + trustedKey: null, + trustedKeySource: 'jwks_uri', + trustedKeyId: null, + trustedKeyError: `could not derive selected JWKS key identity: ${error.message}`, + }; + } + return { trustedKey: selected.key, trustedKeySource: 'jwks_uri', - trustedKeyId: selected.key.kid || decoded.header?.kid || null, + trustedKeyId, trustedKeyError: null, }; } @@ -566,6 +578,11 @@ const jwtVerifier = { const clockSkewSeconds = v4Required ? (context.clockSkewSeconds ?? 60) : (context.clockSkewSeconds ?? 0); + const verificationNowMs = typeof context.now === 'number' + ? context.now + : context.now instanceof Date + ? context.now.getTime() + : Date.now(); // Validate proof is a non-empty string if (!proof || typeof proof !== 'string') { @@ -630,7 +647,7 @@ const jwtVerifier = { // Check expiry and issuance claims. Handoff v4 requires an explicit // bounded lifetime and replay identifier. const now = Math.floor( - (typeof context.now === 'number' ? context.now : Date.now()) / 1000, + verificationNowMs / 1000, ); if (v4Required && payload.exp === undefined) { return { @@ -782,10 +799,14 @@ const jwtVerifier = { signatureReason = signatureReason || 'signature required but no trusted key available'; } - let verifiedKeyId = context.trustedKeyId || null; - if (signatureVerified && !verifiedKeyId) { + let verifiedKeyId = null; + if (signatureVerified) { try { verifiedKeyId = publicKeyId(context.trustedKey); + if (context.trustedKeyId && context.trustedKeyId !== verifiedKeyId) { + signatureVerified = false; + signatureReason = 'trusted JWT key ID does not match the verified signing key'; + } } catch (error) { signatureVerified = false; signatureReason = `could not derive verified JWT key identity: ${error.message}`; @@ -799,29 +820,6 @@ const jwtVerifier = { if (!verifiedKeyId) { runtimeGuardReason = 'handoff v4 requires a verified signing key identity'; } - const claimReplay = context.claimProofReplay - ?? context.replayStore?.claim?.bind(context.replayStore); - if (!runtimeGuardReason && typeof claimReplay !== 'function') { - runtimeGuardReason = 'handoff v4 proof replay store is required'; - } else if (!runtimeGuardReason) { - const replayResult = claimReplay({ - method: 'jwt', - issuer: payload.iss ?? profile.issuer ?? null, - subject: payload.sub ?? null, - proofId: payload.jti, - artifactDigest, - expiresAt: new Date(payload.exp * 1000).toISOString(), - runId: context.runId ?? null, - }); - if (replayResult && typeof replayResult.then === 'function') { - runtimeGuardReason = 'handoff v4 replay store must complete synchronously'; - } else { - replayProtected = replayResult === true || replayResult?.claimed === true - || replayResult?.ok === true; - if (!replayProtected) runtimeGuardReason = replayResult?.reason || 'JWT jti was already used'; - } - } - const checkRevocation = context.checkProofRevocation ?? context.revocationChecker?.check?.bind(context.revocationChecker); if (!runtimeGuardReason && typeof checkRevocation !== 'function') { @@ -846,6 +844,29 @@ const jwtVerifier = { || 'handoff v4 revocation checker did not explicitly confirm the JWT is not revoked'; } } + + const claimReplay = context.claimProofReplay + ?? context.replayStore?.claim?.bind(context.replayStore); + if (!runtimeGuardReason && typeof claimReplay !== 'function') { + runtimeGuardReason = 'handoff v4 proof replay store is required'; + } else if (!runtimeGuardReason) { + const replayResult = claimReplay({ + method: 'jwt', + issuer: payload.iss ?? profile.issuer ?? null, + subject: payload.sub ?? null, + proofId: payload.jti, + artifactDigest, + expiresAt: new Date(payload.exp * 1000).toISOString(), + runId: context.runId ?? null, + }); + if (replayResult && typeof replayResult.then === 'function') { + runtimeGuardReason = 'handoff v4 replay store must complete synchronously'; + } else { + replayProtected = replayResult === true || replayResult?.claimed === true + || replayResult?.ok === true; + if (!replayProtected) runtimeGuardReason = replayResult?.reason || 'JWT jti was already used'; + } + } } // Build audit-safe subset of decoded claims @@ -891,7 +912,7 @@ const jwtVerifier = { key_id: verifiedKeyId, key_source: context.trustedKeySource || null, manifest_digest: context.manifestDigest || null, - verified_at: new Date().toISOString(), + verified_at: new Date(verificationNowMs).toISOString(), }; if (!signatureVerified) { diff --git a/src/capabilities.js b/src/capabilities.js index 67a6faa..c40a80a 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -11,6 +11,7 @@ import { export const HANDOFF_V4_REQUIRED_FEATURES = Object.freeze([ 'handoff_v4_artifact', + 'authorization_proof_verification', 'artifact_bound_proofs', 'signed_or_provider_verified_evidence', 'provider_session_cache', diff --git a/src/compiler/shared.js b/src/compiler/shared.js index c93a1ef..e7926bc 100644 --- a/src/compiler/shared.js +++ b/src/compiler/shared.js @@ -652,10 +652,14 @@ export function buildEffectiveExecutionBinding({ env = process.env, timeoutMs, instanceId, + bindingVersion = 1, } = {}) { if (!workflow || !task) { throw new TypeError('workflow and task are required to build an execution binding'); } + if (!Number.isInteger(bindingVersion) || bindingVersion < 1) { + throw new TypeError('bindingVersion must be a positive integer'); + } const resolvedIdentity = resolveIdentity(workflow, task); const identityProfile = resolvedIdentity?.ref @@ -684,7 +688,7 @@ export function buildEffectiveExecutionBinding({ const evidence = evidenceRef ? mergeEvidenceProfile(evidenceProfile, evidenceRef) : null; return { - binding_version: 1, + binding_version: bindingVersion, manifest_version: expanded?.version ?? manifest?.version ?? null, manifest_digest: manifest ? canonicalDigest(manifest) diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 1ab5e97..1b8d555 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -34,6 +34,86 @@ export { const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; const CRYPTOGRAPHIC_PROOF_METHODS = new Set(['jwt', 'detached-signature', 'certificate']); const PRESENTATION_MEDIA = new Set(['none', 'env', 'temp-file', 'stdin', 'gateway-env-header']); +const SCHEDULER_JOB_REBINDABLE_FIELDS = new Set([ + 'agent_id', + 'approval_approver_scope', + 'approval_auto', + 'approval_required', + 'approval_risk_level', + 'approval_timeout_s', + 'auth_profile', + 'auth_profile_fallback', + 'authorization', + 'authorization_proof', + 'authorization_proof_ref', + 'authorization_ref', + 'child_credential_policy', + 'context_retrieval', + 'context_retrieval_limit', + 'contract_allowed_paths', + 'contract_audit', + 'contract_max_cost_usd', + 'contract_network', + 'contract_required_trust_level', + 'contract_sandbox', + 'contract_trust_enforcement', + 'delete_after_run', + 'delivery_channel', + 'delivery_guarantee', + 'delivery_mode', + 'delivery_opt_out_reason', + 'delivery_to', + 'enabled', + 'evidence', + 'evidence_ref', + 'execution_intent', + 'execution_read_only', + 'identity', + 'identity_attestation', + 'identity_delegation_mode', + 'identity_principal', + 'identity_ref', + 'identity_run_as', + 'identity_subject_kind', + 'identity_subject_principal', + 'identity_trust_level', + 'job_class', + 'max_pending_approvals', + 'max_queued_dispatches', + 'max_retries', + 'max_trigger_fanout', + 'name', + 'origin', + 'output_excerpt_limit_bytes', + 'output_format', + 'output_offload_threshold_bytes', + 'output_store_limit_bytes', + 'output_summary_limit_bytes', + 'overlap_policy', + 'parent_id', + 'payload_kind', + 'payload_message', + 'payload_model', + 'payload_model_fallback', + 'payload_scope', + 'payload_thinking', + 'payload_timeout_seconds', + 'preferred_session_key', + 'resource_pool', + 'run_timeout_ms', + 'schedule_at', + 'schedule_cron', + 'schedule_kind', + 'schedule_tz', + 'session_target', + 'shell_env_policy', + 'trigger_condition', + 'trigger_delay_s', + 'trigger_on', + 'verify_on_failure', + 'verify_shell', + 'verify_timeout_s', +]); function hashObject(value) { return value == null ? null : canonicalDigest(value); @@ -443,6 +523,7 @@ export function buildSchedulerHandoffV4Artifact({ env, timeoutMs: job.run_timeout_ms, instanceId: null, + bindingVersion: HANDOFF_V4_EXECUTION_BINDING_VERSION, }); const effectiveTaskHash = computeEffectiveTaskHash(executionBinding); const identity = identityBinding(executionBinding); @@ -576,6 +657,19 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { if (!job || Number(job.handoff_version) !== HANDOFF_V4_VERSION) { throw new TypeError('rebindSchedulerHandoffV4Job requires a handoff v4 job'); } + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { + throw new TypeError('rebindSchedulerHandoffV4Job overrides must be an object'); + } + const invalidFields = Object.keys(overrides) + .filter(field => !SCHEDULER_JOB_REBINDABLE_FIELDS.has(field)); + if (invalidFields.length > 0) { + throw Object.assign( + new TypeError( + `rebindSchedulerHandoffV4Job cannot override artifact-bound field(s): ${invalidFields.join(', ')}`, + ), + { code: 'HANDOFF_REBIND_OVERRIDE_INVALID', fields: invalidFields }, + ); + } const reboundJob = { ...job, ...overrides }; const payload = structuredClone(normalizePayload(job.handoff_artifact_payload)); if (!payload?.scheduler_job_binding || typeof payload.scheduler_job_binding !== 'object') { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index b097305..d992960 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -14,10 +14,16 @@ import { import { HANDOFF_V4_RUNTIME_CONTRACT } from '../src/capabilities.js'; import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; import { + buildEffectiveExecutionBinding, + computeEffectiveTaskHash, +} from '../src/compiler/shared.js'; +import { + HANDOFF_V4_EXECUTION_BINDING_VERSION, rebindSchedulerHandoffV4Job, validateSchedulerHandoffV4Artifact, } from '../src/handoff/v4.js'; import { canonicalStringify } from '../src/canonical.js'; +import { expandManifestShorthands } from '../src/shorthand.js'; import { jwtVerifier } from '../src/authorization-proof/jwt.js'; import { buildDetachedSignatureV4SigningContent, @@ -423,6 +429,38 @@ test('scheduler execution binding changes when execution controls change', () => assert.notEqual(compile(first), compile(formattedDifferently)); }); +test('handoff v4 effective task hashes use execution binding version 2', () => { + const input = manifest(); + const expanded = expandManifestShorthands(input); + const workflow = expanded.workflows[0]; + const task = workflow.tasks[0]; + const job = compileManifestToScheduler(input, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const bindingOptions = { + manifest: input, + expanded, + workflow, + task, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + timeoutMs: job.run_timeout_ms, + instanceId: null, + }; + const v2Binding = buildEffectiveExecutionBinding({ + ...bindingOptions, + bindingVersion: HANDOFF_V4_EXECUTION_BINDING_VERSION, + }); + const v1Binding = buildEffectiveExecutionBinding({ ...bindingOptions, bindingVersion: 1 }); + + assert.equal(v2Binding.binding_version, 2); + assert.equal(job.handoff_artifact_payload.execution_binding_version, 2); + assert.equal(job.effective_task_hash, computeEffectiveTaskHash(v2Binding)); + assert.notEqual(job.effective_task_hash, computeEffectiveTaskHash(v1Binding)); +}); + test('scheduler execution binding covers routing and resource controls', () => { const job = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -465,6 +503,29 @@ test('handoff v4 scheduler rebinding replaces adoption metadata atomically', () }).ok, true); }); +test('handoff v4 scheduler rebinding rejects artifact-bound overrides', () => { + const job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + + for (const [field, value] of [ + ['id', 'different-id'], + ['handoff_version', 3], + ['effective_task_hash', `sha256:${'0'.repeat(64)}`], + ['handoff_artifact_payload', {}], + ['handoff_artifact_digest', `sha256:${'1'.repeat(64)}`], + ]) { + assert.throws( + () => rebindSchedulerHandoffV4Job(job, { [field]: value }), + error => error.code === 'HANDOFF_REBIND_OVERRIDE_INVALID' + && error.fields.includes(field), + field, + ); + } +}); + test('v4 builder rejects raw credential bindings', () => { const compiled = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -524,6 +585,16 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { assert.equal(omittedGate.handoff.field_version, '3'); assert.equal('handoff_version' in omittedGateRunner.added[0], false); + const missingProofVerificationRunner = runner({ + features: { ...V4_FEATURES, authorization_proof_verification: false }, + }); + const missingProofVerification = await applyManifestToScheduler(manifest(), { + runner: missingProofVerificationRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(missingProofVerification.handoff.field_version, '3'); + assert.equal('handoff_version' in missingProofVerificationRunner.added[0], false); + const oldRunner = runner({ handoffVersion: '3' }); const oldRuntime = await applyManifestToScheduler(manifest(), { runner: oldRunner, @@ -681,6 +752,34 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad && /runtime capability downgrade/.test(error.message), ); assert.deepEqual(downgraded.history, []); + + const legacyManifest = manifest(); + legacyManifest.workflows[0].id = 'legacy-v4-workflow'; + legacyManifest.workflows[0].tasks[0].id = 'legacy-v4-root'; + const legacyV4 = compileManifestToScheduler(legacyManifest, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + assert.notEqual(legacyV4.id, compiled.id); + assert.equal(legacyV4.name, compiled.name); + const downgradedAdopter = statefulRunner([ + schedulerCreateSpec(legacyV4, { originOverride: 'legacy-origin', fieldVersion: '4' }), + ], { + handoffVersion: '3', + features: { ...V4_FEATURES, immutable_runtime_events: false }, + }); + await assert.rejects( + applyManifestToScheduler(manifest(), { + runner: downgradedAdopter, + adoptBy: 'name', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }), + error => error.code === 'unsupported_capability' + && /runtime capability downgrade/.test(error.message), + ); + assert.deepEqual(downgradedAdopter.history, []); }); test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { @@ -712,6 +811,7 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che manifestDigest: payload.manifest_digest, artifactDigest, handoffVersion: 4, + now: new Date(now * 1000), runId: 'run-1', claimProofReplay({ proofId }) { if (claimed.has(proofId)) return { claimed: false, reason: 'replay' }; @@ -728,6 +828,7 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(verified.artifact_bound, true); assert.equal(verified.replay_protected, true); assert.equal(verified.revocation_checked, true); + assert.equal(verified.verified_at, new Date(now * 1000).toISOString()); const replay = jwtVerifier.verifyProof(token, profile, context); assert.equal(replay.verified, false); @@ -751,14 +852,31 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(invertedResult.verified, false); assert.match(invertedResult.reason, /exp claim must be greater than iat/); + const mismatchedKeyId = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-mismatched-key-id' }, privateKey), + profile, + { + ...context, + trustedKeyId: 'spki-sha256:unrelated', + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(mismatchedKeyId.verified, false); + assert.match(mismatchedKeyId.reason, /key ID does not match/); + const indeterminateRevocation = signJwt({ ...payload, jti: 'proof-indeterminate' }, privateKey); + let indeterminateReplayClaims = 0; const indeterminateResult = jwtVerifier.verifyProof(indeterminateRevocation, profile, { ...context, - claimProofReplay: () => ({ claimed: true }), + claimProofReplay: () => { + indeterminateReplayClaims += 1; + return { claimed: true }; + }, checkProofRevocation: () => undefined, }); assert.equal(indeterminateResult.verified, false); assert.match(indeterminateResult.reason, /did not explicitly confirm/); + assert.equal(indeterminateReplayClaims, 0); }); test('handoff v4 detached signatures cover nonce, validity, key, and artifact metadata', () => { @@ -790,7 +908,7 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me artifactPayload: compiled.handoff_artifact_payload, artifactDigest: compiled.handoff_artifact_digest, handoffVersion: 4, - now, + now: new Date(now), runId: 'run-detached', claimProofReplay({ proofId }) { if (claimed.has(proofId)) return { claimed: false, reason: 'replay refused' }; @@ -847,9 +965,28 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me assert.equal(wrongKey.verified, false); assert.match(wrongKey.signature_verification_reason, /key_id does not match/); + const mismatchedTrustedKeyFields = { ...fields, nonce: 'detached-proof-context-key-id' }; + const mismatchedTrustedKeySigner = createSign('RSA-SHA256'); + mismatchedTrustedKeySigner.update(buildDetachedSignatureV4SigningContent(mismatchedTrustedKeyFields)); + const mismatchedTrustedKey = detachedSignatureVerifier.verifyProof({ + signature: mismatchedTrustedKeySigner.sign(privateKey).toString('base64'), + artifact_digest: mismatchedTrustedKeyFields.artifactDigest, + nonce: mismatchedTrustedKeyFields.nonce, + issued_at: mismatchedTrustedKeyFields.issuedAt, + expires_at: mismatchedTrustedKeyFields.expiresAt, + key_id: mismatchedTrustedKeyFields.keyId, + }, profile, { + ...context, + trustedKeyId: 'spki-sha256:unrelated', + claimProofReplay: () => ({ claimed: true }), + }); + assert.equal(mismatchedTrustedKey.verified, false); + assert.match(mismatchedTrustedKey.signature_verification_reason, /key ID does not match/); + const uncheckedFields = { ...fields, nonce: 'detached-proof-unchecked' }; const uncheckedSigner = createSign('RSA-SHA256'); uncheckedSigner.update(buildDetachedSignatureV4SigningContent(uncheckedFields)); + let uncheckedReplayClaims = 0; const unchecked = detachedSignatureVerifier.verifyProof({ signature: uncheckedSigner.sign(privateKey).toString('base64'), artifact_digest: uncheckedFields.artifactDigest, @@ -859,11 +996,15 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me key_id: uncheckedFields.keyId, }, profile, { ...context, - claimProofReplay: () => ({ claimed: true }), + claimProofReplay: () => { + uncheckedReplayClaims += 1; + return { claimed: true }; + }, checkProofRevocation: () => null, }); assert.equal(unchecked.verified, false); assert.match(unchecked.signature_verification_reason, /did not explicitly confirm/); + assert.equal(uncheckedReplayClaims, 0); }); test('handoff v4 certificate proof signs its replay and validity controls', t => { @@ -933,7 +1074,7 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => artifactPayload: compiled.handoff_artifact_payload, artifactDigest: compiled.handoff_artifact_digest, handoffVersion: 4, - now, + now: new Date(now), runId: 'run-certificate', claimProofReplay: () => ({ claimed: true }), checkProofRevocation: () => ({ revoked: false }), @@ -1004,6 +1145,7 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => const uncheckedFields = { ...fields, nonce: 'certificate-proof-unchecked' }; const uncheckedSigner = createSign('SHA256'); uncheckedSigner.update(buildCertificateV4SigningContent(uncheckedFields)); + let uncheckedReplayClaims = 0; const unchecked = certificateVerifier.verifyProof({ certificate: certificatePem, signature: uncheckedSigner.sign(readFileSync(keyPath, 'utf8')).toString('base64'), @@ -1014,11 +1156,15 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => key_id: uncheckedFields.keyId, }, profile, { ...context, - claimProofReplay: () => ({ claimed: true }), + claimProofReplay: () => { + uncheckedReplayClaims += 1; + return { claimed: true }; + }, checkProofRevocation: () => ({ ok: false, reason: 'revocation backend unavailable' }), }); assert.equal(unchecked.verified, false); assert.match(unchecked.signature_verification_reason, /revocation backend unavailable/); + assert.equal(uncheckedReplayClaims, 0); }); test('inspect advertises every v4 immutable runtime entity', () => { From 7e2201174061d0529877b9d5fe4131e155e264b1 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 00:53:07 -0400 Subject: [PATCH 12/29] fix: bind watchdog execution controls --- fixtures/handoff-v4/conformance.json | 4 +- src/handoff/v4.js | 96 +++++++--------------------- test/handoff-v4.test.js | 62 ++++++++++++++++++ 3 files changed, 86 insertions(+), 76 deletions(-) diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json index 3a1a901..4df4951 100644 --- a/fixtures/handoff-v4/conformance.json +++ b/fixtures/handoff-v4/conformance.json @@ -62,10 +62,10 @@ ] }, "expected": { - "artifact_digest": "sha256:1910808223999c0e2d6cd469cc08b80a8d48fcec76d3e29163ecfb53bd55a2f7", + "artifact_digest": "sha256:b1f4dd7048447fac314e5986260b90a3b500a8fb5b049cadc83b8df528a860fa", "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", "effective_task_hash": "sha256:0d6b7a63ba7ff75811e5db236b84648dc9547d2ce3d22cdc42eec621c9ff98e4", - "scheduler_job_binding_digest": "sha256:1b9e18a83a48d085f0b2bdb03c99365e79cb4de21ffb71358c32c8e0eb71ef7d" + "scheduler_job_binding_digest": "sha256:daac076adbaaac470e748f94d66297b3b018910867d817b78c3bc24768ffa2e6" }, "negative_artifact_cases": [ { diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 1b8d555..f024976 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -35,84 +35,18 @@ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; const CRYPTOGRAPHIC_PROOF_METHODS = new Set(['jwt', 'detached-signature', 'certificate']); const PRESENTATION_MEDIA = new Set(['none', 'env', 'temp-file', 'stdin', 'gateway-env-header']); const SCHEDULER_JOB_REBINDABLE_FIELDS = new Set([ - 'agent_id', - 'approval_approver_scope', - 'approval_auto', - 'approval_required', - 'approval_risk_level', - 'approval_timeout_s', - 'auth_profile', - 'auth_profile_fallback', - 'authorization', - 'authorization_proof', - 'authorization_proof_ref', - 'authorization_ref', - 'child_credential_policy', - 'context_retrieval', - 'context_retrieval_limit', - 'contract_allowed_paths', - 'contract_audit', - 'contract_max_cost_usd', - 'contract_network', - 'contract_required_trust_level', - 'contract_sandbox', - 'contract_trust_enforcement', - 'delete_after_run', - 'delivery_channel', - 'delivery_guarantee', - 'delivery_mode', - 'delivery_opt_out_reason', - 'delivery_to', - 'enabled', - 'evidence', - 'evidence_ref', - 'execution_intent', - 'execution_read_only', - 'identity', - 'identity_attestation', - 'identity_delegation_mode', - 'identity_principal', - 'identity_ref', - 'identity_run_as', - 'identity_subject_kind', - 'identity_subject_principal', - 'identity_trust_level', 'job_class', - 'max_pending_approvals', - 'max_queued_dispatches', - 'max_retries', - 'max_trigger_fanout', - 'name', 'origin', - 'output_excerpt_limit_bytes', - 'output_format', - 'output_offload_threshold_bytes', - 'output_store_limit_bytes', - 'output_summary_limit_bytes', - 'overlap_policy', - 'parent_id', - 'payload_kind', - 'payload_message', - 'payload_model', - 'payload_model_fallback', 'payload_scope', - 'payload_thinking', - 'payload_timeout_seconds', - 'preferred_session_key', 'resource_pool', - 'run_timeout_ms', - 'schedule_at', - 'schedule_cron', - 'schedule_kind', - 'schedule_tz', - 'session_target', - 'shell_env_policy', - 'trigger_condition', - 'trigger_delay_s', - 'trigger_on', - 'verify_on_failure', - 'verify_shell', - 'verify_timeout_s', + 'watchdog_alert_channel', + 'watchdog_alert_target', + 'watchdog_check_cmd', + 'watchdog_self_destruct', + 'watchdog_started_at', + 'watchdog_target_label', + 'watchdog_timeout_min', + 'job_type', ]); function hashObject(value) { @@ -175,6 +109,20 @@ function schedulerJobExecutionProjection(job) { command: { payload_message_sha256: hashString(job.payload_message ?? ''), }, + watchdog: { + job_type: job.job_type ?? 'standard', + target_label: job.watchdog_target_label ?? null, + check_cmd_sha256: job.watchdog_check_cmd == null + ? null + : hashString(String(job.watchdog_check_cmd)), + timeout_min: job.watchdog_timeout_min ?? null, + alert_channel: job.watchdog_alert_channel ?? null, + alert_target: job.watchdog_alert_target ?? null, + self_destruct: job.watchdog_self_destruct == null + ? true + : Boolean(job.watchdog_self_destruct), + started_at: job.watchdog_started_at ?? null, + }, runtime: { run_timeout_ms: job.run_timeout_ms ?? 300000, payload_timeout_seconds: job.payload_timeout_seconds ?? 120, diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index d992960..82cc32e 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -483,6 +483,54 @@ test('scheduler execution binding covers routing and resource controls', () => { } }); +test('scheduler execution binding covers every watchdog execution control', () => { + const job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const originalBinding = job.handoff_artifact_payload.scheduler_job_binding.digest; + const explicitDefaults = rebindSchedulerHandoffV4Job(job, { + job_type: 'standard', + watchdog_target_label: null, + watchdog_check_cmd: null, + watchdog_timeout_min: null, + watchdog_alert_channel: null, + watchdog_alert_target: null, + watchdog_self_destruct: true, + watchdog_started_at: null, + }); + + assert.equal( + explicitDefaults.handoff_artifact_payload.scheduler_job_binding.digest, + originalBinding, + 'standard jobs must bind the documented watchdog defaults', + ); + + for (const override of [ + { job_type: 'watchdog' }, + { watchdog_target_label: 'gateway' }, + { watchdog_check_cmd: '/usr/bin/health-check --strict' }, + { watchdog_timeout_min: 15 }, + { watchdog_alert_channel: 'ops' }, + { watchdog_alert_target: 'on-call' }, + { watchdog_self_destruct: false }, + { watchdog_started_at: '2026-07-19T04:45:00.000Z' }, + ]) { + const rebound = rebindSchedulerHandoffV4Job(job, override); + assert.notEqual( + rebound.handoff_artifact_payload.scheduler_job_binding.digest, + originalBinding, + `${Object.keys(override)[0]} must affect scheduler_job_binding.digest`, + ); + assert.equal( + canonicalStringify(rebound.handoff_artifact_payload).includes('/usr/bin/health-check --strict'), + false, + 'watchdog check commands must be represented only by their digest', + ); + } +}); + test('handoff v4 scheduler rebinding replaces adoption metadata atomically', () => { const job = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -516,6 +564,20 @@ test('handoff v4 scheduler rebinding rejects artifact-bound overrides', () => { ['effective_task_hash', `sha256:${'0'.repeat(64)}`], ['handoff_artifact_payload', {}], ['handoff_artifact_digest', `sha256:${'1'.repeat(64)}`], + ['enabled', 0], + ['session_target', 'isolated'], + ['payload_message', 'different payload'], + ['run_timeout_ms', 1], + ['approval_required', false], + ['output_format', 'json'], + ['identity', null], + ['authorization_proof', null], + ['authorization', null], + ['evidence', null], + ['contract_audit', 'never'], + ['verify_shell', 'exit 0'], + ['child_credential_policy', null], + ['execution_intent', 'dry-run'], ]) { assert.throws( () => rebindSchedulerHandoffV4Job(job, { [field]: value }), From 7c8688c6f9ab1ea16a963dafc0ac37edaa5ee4b4 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 00:58:14 -0400 Subject: [PATCH 13/29] fix: close final handoff v4 review gaps --- docs/spec.md | 3 +- fixtures/handoff-v4/conformance.json | 2 +- src/apply.js | 31 +++++--- src/authorization-proof/certificate.js | 7 +- src/authorization-proof/detached-signature.js | 7 +- src/handoff/schema-v4.js | 1 + src/handoff/v4.js | 8 +- test/handoff-v4.test.js | 79 +++++++++++++++++++ 8 files changed, 118 insertions(+), 20 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 11c43eb..5cc86b2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -869,7 +869,8 @@ metadata is incompatible with v4 and MUST fall back to handoff v3 or reject apply. A handoff version number alone is not sufficient negotiation. The runtime MUST also advertise all of these boolean features before AgentCLI -emits v4: `handoff_v4_artifact`, `artifact_bound_proofs`, +emits v4: `authorization_proof_verification`, `handoff_v4_artifact`, +`artifact_bound_proofs`, `signed_or_provider_verified_evidence`, `provider_session_cache`, `credential_presentation`, `source_run_bound_delegation`, and `immutable_runtime_events`. AgentCLI uses the live capability response as diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json index 4df4951..537d836 100644 --- a/fixtures/handoff-v4/conformance.json +++ b/fixtures/handoff-v4/conformance.json @@ -62,7 +62,7 @@ ] }, "expected": { - "artifact_digest": "sha256:b1f4dd7048447fac314e5986260b90a3b500a8fb5b049cadc83b8df528a860fa", + "artifact_digest": "sha256:5bb0727036d352c87b6cad16577eafe4fdb35985ae79c73053474ceadd91f62d", "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", "effective_task_hash": "sha256:0d6b7a63ba7ff75811e5db236b84648dc9547d2ce3d22cdc42eec621c9ff98e4", "scheduler_job_binding_digest": "sha256:daac076adbaaac470e748f94d66297b3b018910867d817b78c3bc24768ffa2e6" diff --git a/src/apply.js b/src/apply.js index 9a5d64c..223e8d9 100644 --- a/src/apply.js +++ b/src/apply.js @@ -397,10 +397,8 @@ export async function applyManifestToScheduler( } } - const actions = []; + const plannedActions = []; for (const job of compiled.jobs) { - const verificationEntry = verificationByTask.get(`${job.source.workflow_id}:${job.source.task_id}`) ?? null; - let action; let existingId; let existingJob; @@ -444,11 +442,13 @@ export async function applyManifestToScheduler( } } - if ( - (action === 'updated' || action === 'adopted') + plannedActions.push({ job, action, existingId, existingJob }); + } + + for (const { job, action, existingJob } of plannedActions) { + if ((action === 'updated' || action === 'adopted') && Number(existingJob?.handoff_version) === 4 - && Number(job.handoff_version) !== 4 - ) { + && Number(job.handoff_version) !== 4) { throw Object.assign( new Error( `Cannot ${action === 'adopted' ? 'adopt' : 'update'} handoff v4 scheduler job ` + @@ -458,6 +458,17 @@ export async function applyManifestToScheduler( { code: 'unsupported_capability' } ); } + if (!dryRun && action === 'adopted' && typeof schedulerRunner.deleteJob !== 'function') { + throw Object.assign( + new Error('Scheduler runner does not support deleteJob(); cannot adopt legacy rows by name'), + { code: 'scheduler_error' } + ); + } + } + + const actions = []; + for (const { job, action, existingId, existingJob } of plannedActions) { + const verificationEntry = verificationByTask.get(`${job.source.workflow_id}:${job.source.task_id}`) ?? null; if (!dryRun) { if (action === 'created') { @@ -469,12 +480,6 @@ export async function applyManifestToScheduler( : job; schedulerRunner.updateJob(job.id, schedulerUpdateSpec(updateJob, { fieldVersion: handoffVersion })); } else if (action === 'adopted') { - if (typeof schedulerRunner.deleteJob !== 'function') { - throw Object.assign( - new Error('Scheduler runner does not support deleteJob(); cannot adopt legacy rows by name'), - { code: 'scheduler_error' } - ); - } const adoptedOrigin = existingJob?.origin ?? 'system'; const adoptedJob = Number(job.handoff_version) === 4 ? rebindSchedulerHandoffV4Job(job, { origin: adoptedOrigin }) diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 13e452e..abb4375 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -119,7 +119,8 @@ function parseCertificateProof(proof) { } function validateV4CertificateEnvelope(parsed, context) { - const v4 = context.handoffVersion === 4 || context.artifactDigest != null; + const v4 = Number(context.handoffVersion ?? context.handoff_version) === 4 + || context.artifactDigest != null; if (!v4) return { ok: true, v4: false }; for (const field of [ 'certificate', @@ -294,7 +295,9 @@ export function resolveCertificateVerificationContext(profile = {}, ctx = {}) { artifactDigest: manifest.artifactMode ? manifest.digest : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), - handoffVersion: manifest.artifactMode ? 4 : (ctx.handoffVersion ?? null), + handoffVersion: manifest.artifactMode + ? 4 + : (ctx.handoffVersion ?? ctx.handoff_version ?? null), manifestContextError: manifest.error || null, requireProofOfPossession: ctx.requireProofOfPossession ?? true, }; diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index b57779b..ed60c57 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -138,7 +138,9 @@ export function resolveDetachedSignatureVerificationContext(profile = {}, ctx = artifactDigest: canonical.artifactMode ? canonical.digest : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), - handoffVersion: canonical.artifactMode ? 4 : (ctx.handoffVersion ?? null), + handoffVersion: canonical.artifactMode + ? 4 + : (ctx.handoffVersion ?? ctx.handoff_version ?? null), manifestContextError: canonical.error || null, trustedKey: ctx.trustedKey || profile.public_key || null, allowedSignersPath, @@ -160,7 +162,8 @@ function decodeBase64Signature(proof) { } function parseV4Envelope(proof, context) { - const v4 = context.handoffVersion === 4 || context.artifactDigest != null; + const v4 = Number(context.handoffVersion ?? context.handoff_version) === 4 + || context.artifactDigest != null; if (!v4) return { signature: proof, v4: false }; let envelope = proof; diff --git a/src/handoff/schema-v4.js b/src/handoff/schema-v4.js index db1895d..e6b341e 100644 --- a/src/handoff/schema-v4.js +++ b/src/handoff/schema-v4.js @@ -178,6 +178,7 @@ export const HANDOFF_V4_JSON_SCHEMA = Object.freeze({ 'audience', 'claims_hash', 'proof_source_hash', + 'verification_context_hash', 'artifact_binding_required', 'replay_protection_required', 'revocation_check_required', diff --git a/src/handoff/v4.js b/src/handoff/v4.js index f024976..7bec922 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -302,6 +302,7 @@ function proofBinding(binding) { audience: null, claims_hash: null, proof_source_hash: null, + verification_context_hash: null, artifact_binding_required: false, replay_protection_required: false, revocation_check_required: false, @@ -704,7 +705,6 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { ['identity.auth_hash', payload.identity?.auth_hash], ['authorization_proof.claims_hash', payload.authorization_proof?.claims_hash], ['authorization_proof.proof_source_hash', payload.authorization_proof?.proof_source_hash], - ['authorization_proof.verification_context_hash', payload.authorization_proof?.verification_context_hash], ['authorization.policy_digest', payload.authorization?.policy_digest], ['authorization.request_hash', payload.authorization?.request_hash], ['authorization.decision_hash', payload.authorization?.decision_hash], @@ -760,6 +760,12 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { } const proof = payload.authorization_proof ?? {}; + validateHash( + proof.verification_context_hash, + 'authorization_proof.verification_context_hash', + errors, + { required: CRYPTOGRAPHIC_PROOF_METHODS.has(proof.method) }, + ); if (CRYPTOGRAPHIC_PROOF_METHODS.has(proof.method) && (!proof.artifact_binding_required || !proof.replay_protection_required diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 82cc32e..9b93628 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -287,6 +287,31 @@ test('handoff v4 schema accepts every valid cleanup policy and rejects unknown p const proofValidation = validateSchedulerHandoffV4Artifact(unknownProof); assert.equal(proofValidation.ok, false); assert.match(proofValidation.errors.join('; '), /authorization_proof\.method/); + + assert.equal(payload.authorization_proof.verification_context_hash, null); + const cryptographicProof = structuredClone(payload); + cryptographicProof.authorization_proof.method = 'jwt'; + cryptographicProof.authorization_proof.verification_context_hash = `sha256:${'a'.repeat(64)}`; + cryptographicProof.authorization_proof.artifact_binding_required = true; + cryptographicProof.authorization_proof.replay_protection_required = true; + cryptographicProof.authorization_proof.revocation_check_required = true; + const cryptographicValidation = validateSchedulerHandoffV4Artifact(cryptographicProof); + assert.equal(cryptographicValidation.ok, true, cryptographicValidation.errors.join('; ')); + + for (const missingValue of ['delete', 'null']) { + const missingContext = structuredClone(cryptographicProof); + if (missingValue === 'delete') { + delete missingContext.authorization_proof.verification_context_hash; + } else { + missingContext.authorization_proof.verification_context_hash = null; + } + const missingContextValidation = validateSchedulerHandoffV4Artifact(missingContext); + assert.equal(missingContextValidation.ok, false, missingValue); + assert.match( + missingContextValidation.errors.join('; '), + /authorization_proof\.verification_context_hash.*required/, + ); + } }); test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { @@ -842,6 +867,45 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad && /runtime capability downgrade/.test(error.message), ); assert.deepEqual(downgradedAdopter.history, []); + + const multiJobManifest = manifest(); + multiJobManifest.workflows[0].tasks = [ + { + ...structuredClone(multiJobManifest.workflows[0].tasks[0]), + id: 'new-before-conflict', + name: 'New before conflict', + }, + { + ...structuredClone(multiJobManifest.workflows[0].tasks[0]), + id: 'existing-v4-conflict', + name: 'Existing v4 conflict', + }, + ]; + const existingConflict = compileManifestToScheduler(multiJobManifest, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[1]; + const partiallyApplicable = statefulRunner([ + schedulerCreateSpec(existingConflict, { fieldVersion: '4' }), + ], { + handoffVersion: '3', + features: { ...V4_FEATURES, immutable_runtime_events: false }, + }); + await assert.rejects( + applyManifestToScheduler(multiJobManifest, { + runner: partiallyApplicable, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }), + error => error.code === 'unsupported_capability' + && /runtime capability downgrade/.test(error.message), + ); + assert.deepEqual( + partiallyApplicable.history, + [], + 'all downgrade conflicts must be detected before the first scheduler write', + ); }); test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { @@ -1067,6 +1131,14 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me assert.equal(unchecked.verified, false); assert.match(unchecked.signature_verification_reason, /did not explicitly confirm/); assert.equal(uncheckedReplayClaims, 0); + + const snakeCaseContext = detachedSignatureVerifier.verifyProof(envelope, profile, { + handoff_version: 4, + manifest: manifest(), + trustedKey: profile.public_key, + }); + assert.equal(snakeCaseContext.verified, false); + assert.match(snakeCaseContext.signature_verification_reason, /artifact digest/); }); test('handoff v4 certificate proof signs its replay and validity controls', t => { @@ -1227,6 +1299,13 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => assert.equal(unchecked.verified, false); assert.match(unchecked.signature_verification_reason, /revocation backend unavailable/); assert.equal(uncheckedReplayClaims, 0); + + const snakeCaseContext = certificateVerifier.verifyProof(envelope, profile, { + handoff_version: 4, + manifest: manifest(), + }); + assert.equal(snakeCaseContext.verified, false); + assert.match(snakeCaseContext.reason, /artifact digest/); }); test('inspect advertises every v4 immutable runtime entity', () => { From 660ebe3bddef51b3e0638bae0d4e648b00fa8d0e Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 01:20:00 -0400 Subject: [PATCH 14/29] fix: preserve handoff v4 runtime bindings --- src/apply.js | 15 ++++++++-- src/authorization-proof/jwt.js | 2 +- src/handoff/v4.js | 9 ++++++ src/scheduler-fields.js | 11 +++++++ test/handoff-v4.test.js | 55 ++++++++++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/apply.js b/src/apply.js index 223e8d9..ad81663 100644 --- a/src/apply.js +++ b/src/apply.js @@ -22,7 +22,10 @@ import { SCHEDULER_FIELDS_V04, SCHEDULER_FIELD_VERSIONS, } from './scheduler-fields.js'; -import { rebindSchedulerHandoffV4Job } from './handoff/v4.js'; +import { + rebindSchedulerHandoffV4Job, + schedulerHandoffV4RebindableOverrides, +} from './handoff/v4.js'; export { shellCommandInvocation } from './command.js'; export { SCHEDULER_FIELDS_V1, @@ -476,13 +479,19 @@ export async function applyManifestToScheduler( } else if (action === 'updated') { const existingOrigin = existingJob?.origin ?? job.origin ?? 'system'; const updateJob = Number(job.handoff_version) === 4 - ? rebindSchedulerHandoffV4Job(job, { origin: existingOrigin }) + ? rebindSchedulerHandoffV4Job(job, { + ...schedulerHandoffV4RebindableOverrides(existingJob), + origin: existingOrigin, + }) : job; schedulerRunner.updateJob(job.id, schedulerUpdateSpec(updateJob, { fieldVersion: handoffVersion })); } else if (action === 'adopted') { const adoptedOrigin = existingJob?.origin ?? 'system'; const adoptedJob = Number(job.handoff_version) === 4 - ? rebindSchedulerHandoffV4Job(job, { origin: adoptedOrigin }) + ? rebindSchedulerHandoffV4Job(job, { + ...schedulerHandoffV4RebindableOverrides(existingJob), + origin: adoptedOrigin, + }) : job; schedulerRunner.addJob(schedulerCreateSpec(adoptedJob, { originOverride: adoptedOrigin, diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index c864e01..9e4e201 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -573,7 +573,7 @@ const jwtVerifier = { const signatureRequired = Boolean(context.requireSignature); const manifestBindingRequired = context.requireManifestBinding !== false; const artifactDigest = context.artifactDigest ?? context.handoffArtifactDigest ?? null; - const v4Required = context.handoffVersion === 4 || context.handoff_version === 4 + const v4Required = Number(context.handoffVersion ?? context.handoff_version) === 4 || artifactDigest != null; const clockSkewSeconds = v4Required ? (context.clockSkewSeconds ?? 60) diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 7bec922..93ea716 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -49,6 +49,15 @@ const SCHEDULER_JOB_REBINDABLE_FIELDS = new Set([ 'job_type', ]); +export function schedulerHandoffV4RebindableOverrides(job) { + if (!job || typeof job !== 'object' || Array.isArray(job)) return {}; + return Object.fromEntries( + [...SCHEDULER_JOB_REBINDABLE_FIELDS] + .filter(field => Object.hasOwn(job, field)) + .map(field => [field, job[field]]), + ); +} + function hashObject(value) { return value == null ? null : canonicalDigest(value); } diff --git a/src/scheduler-fields.js b/src/scheduler-fields.js index 6b241a9..745eac0 100644 --- a/src/scheduler-fields.js +++ b/src/scheduler-fields.js @@ -51,6 +51,17 @@ export const SCHEDULER_FIELDS_V04 = [ 'handoff_artifact_digest', 'handoff_artifact_payload', 'effective_task_hash', + 'payload_scope', + 'resource_pool', + 'job_class', + 'job_type', + 'watchdog_target_label', + 'watchdog_check_cmd', + 'watchdog_timeout_min', + 'watchdog_alert_channel', + 'watchdog_alert_target', + 'watchdog_self_destruct', + 'watchdog_started_at', ]; export const SCHEDULER_FIELD_VERSIONS = { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 9b93628..bf779d1 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -765,10 +765,24 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar }).ok, true); const legacyId = 'legacy-v3-row'; + const legacyRuntimeOverrides = { + payload_scope: 'global', + resource_pool: 'legacy-pool', + job_class: 'pre_compaction_flush', + job_type: 'watchdog', + watchdog_target_label: 'legacy-target', + watchdog_check_cmd: '/usr/bin/legacy-health-check', + watchdog_timeout_min: 9, + watchdog_alert_channel: 'ops', + watchdog_alert_target: 'legacy-on-call', + watchdog_self_destruct: false, + watchdog_started_at: '2026-07-19T04:30:00.000Z', + }; const adopter = statefulRunner([{ id: legacyId, name: manifest().workflows[0].tasks[0].name, origin: 'legacy-origin', + ...legacyRuntimeOverrides, }]); const adopted = await applyManifestToScheduler(manifest(), { runner: adopter, @@ -780,6 +794,9 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar assert.equal(adopted.actions[0].adopted_from_job_id, legacyId); assert.deepEqual(adopter.history.map(entry => entry.action), ['add', 'delete']); assert.equal(adopter.history[0].spec.origin, 'legacy-origin'); + for (const [field, value] of Object.entries(legacyRuntimeOverrides)) { + assert.equal(adopter.history[0].spec[field], value, `${field} must survive v4 adoption`); + } const adoptedPayload = JSON.parse(adopter.history[0].spec.handoff_artifact_payload); assert.equal(adoptedPayload.handoff_version, 4); const expectedAdopted = rebindSchedulerHandoffV4Job( @@ -788,7 +805,7 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar cwd: '/tmp', env: { PATH: '/usr/bin' }, }).jobs[0], - { origin: 'legacy-origin' }, + { origin: 'legacy-origin', ...legacyRuntimeOverrides }, ); assert.equal( adoptedPayload.scheduler_job_binding.digest, @@ -803,7 +820,21 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad cwd: '/tmp', env: { PATH: '/usr/bin' }, }).jobs[0]; - const existing = rebindSchedulerHandoffV4Job(compiled, { origin: 'legacy-origin' }); + const preservedRuntimeOverrides = { + origin: 'legacy-origin', + payload_scope: 'global', + resource_pool: 'preserved-pool', + job_class: 'pre_compaction_flush', + job_type: 'watchdog', + watchdog_target_label: 'preserved-target', + watchdog_check_cmd: '/usr/bin/preserved-health-check --strict', + watchdog_timeout_min: 17, + watchdog_alert_channel: 'ops', + watchdog_alert_target: 'on-call', + watchdog_self_destruct: false, + watchdog_started_at: '2026-07-19T05:00:00.000Z', + }; + const existing = rebindSchedulerHandoffV4Job(compiled, preservedRuntimeOverrides); const scheduler = statefulRunner([ schedulerCreateSpec(existing, { originOverride: 'legacy-origin', fieldVersion: '4' }), ]); @@ -816,7 +847,11 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad assert.equal(result.actions[0].action, 'updated'); const update = scheduler.history.at(-1); assert.equal(update.action, 'update'); - const expected = rebindSchedulerHandoffV4Job(compiled, { origin: 'legacy-origin' }); + const expected = rebindSchedulerHandoffV4Job(compiled, preservedRuntimeOverrides); + for (const [field, value] of Object.entries(preservedRuntimeOverrides)) { + if (field === 'origin') continue; + assert.equal(update.spec[field], value, `${field} must survive the v4 update`); + } assert.equal(update.spec.handoff_artifact_digest, expected.handoff_artifact_digest); assert.equal( JSON.parse(update.spec.handoff_artifact_payload).scheduler_job_binding.digest, @@ -965,6 +1000,20 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(unbound.verified, false); assert.match(unbound.reason, /artifact digest claim/); + const stringVersionUnbound = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-string-version' }, privateKey), + profile, + { + ...context, + artifactDigest: undefined, + handoffVersion: undefined, + handoff_version: '4', + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(stringVersionUnbound.verified, false); + assert.match(stringVersionUnbound.reason, /trusted handoff artifact digest/); + const inverted = signJwt({ ...payload, iat: now + 30, From 9d9fa89d355fe328e4152dc71dd7af81ce61d8e3 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 01:34:49 -0400 Subject: [PATCH 15/29] ci: pin final Scheduler handoff candidate --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 391199a..5ad3eb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: bb9dae60270d2945bec62403bc6f08adc7c50ec8 + OPENCLAW_SCHEDULER_REF: 0643d94914230e0384452cb795c6be27984a240b steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: bb9dae60270d2945bec62403bc6f08adc7c50ec8 + ref: 0643d94914230e0384452cb795c6be27984a240b path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f2adad..658d227 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: bb9dae60270d2945bec62403bc6f08adc7c50ec8 + OPENCLAW_SCHEDULER_REF: 0643d94914230e0384452cb795c6be27984a240b steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: bb9dae60270d2945bec62403bc6f08adc7c50ec8 + ref: 0643d94914230e0384452cb795c6be27984a240b path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" From 4ff8272d839dc18c0ed1e7249dd74f461cbeaee1 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 01:40:34 -0400 Subject: [PATCH 16/29] fix: close final handoff v4 integrity gaps --- src/authorization-proof/jwt.js | 18 +++++++++++- src/handoff/v4.js | 18 +++++++++++- src/scheduler-fields.js | 4 +++ src/schema.js | 26 +++++++++++++++++ test/agentcli.test.js | 16 ++++++++++ test/handoff-v4.test.js | 53 ++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index 9e4e201..cf74b52 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -575,9 +575,15 @@ const jwtVerifier = { const artifactDigest = context.artifactDigest ?? context.handoffArtifactDigest ?? null; const v4Required = Number(context.handoffVersion ?? context.handoff_version) === 4 || artifactDigest != null; - const clockSkewSeconds = v4Required + const clockSkewInput = v4Required ? (context.clockSkewSeconds ?? 60) : (context.clockSkewSeconds ?? 0); + const clockSkewSeconds = ( + (typeof clockSkewInput === 'number' || typeof clockSkewInput === 'string') + && !(typeof clockSkewInput === 'string' && clockSkewInput.trim() === '') + ) + ? Number(clockSkewInput) + : Number.NaN; const verificationNowMs = typeof context.now === 'number' ? context.now : context.now instanceof Date @@ -595,6 +601,16 @@ const jwtVerifier = { }; } + if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0) { + return { + verified: false, + method: 'jwt', + reason: 'clockSkewSeconds must be a finite non-negative number', + claims_validated: false, + signature_verified: false, + }; + } + // Parse JWT structure let decoded; try { diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 93ea716..0d7fdc9 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -35,10 +35,14 @@ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; const CRYPTOGRAPHIC_PROOF_METHODS = new Set(['jwt', 'detached-signature', 'certificate']); const PRESENTATION_MEDIA = new Set(['none', 'env', 'temp-file', 'stdin', 'gateway-env-header']); const SCHEDULER_JOB_REBINDABLE_FIELDS = new Set([ + 'auth_profile_fallback', 'job_class', 'origin', + 'payload_model_fallback', 'payload_scope', + 'payload_timeout_seconds', 'resource_pool', + 'shell_env_policy', 'watchdog_alert_channel', 'watchdog_alert_target', 'watchdog_check_cmd', @@ -628,8 +632,20 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { { code: 'HANDOFF_REBIND_OVERRIDE_INVALID', fields: invalidFields }, ); } + const originalValidation = validateSchedulerHandoffV4Artifact( + job.handoff_artifact_payload, + { expectedDigest: job.handoff_artifact_digest }, + ); + if (!originalValidation.ok) { + throw Object.assign( + new Error( + `Cannot rebind invalid handoff v4 artifact: ${originalValidation.errors.join('; ')}`, + ), + { code: 'HANDOFF_ARTIFACT_INVALID', errors: originalValidation.errors }, + ); + } const reboundJob = { ...job, ...overrides }; - const payload = structuredClone(normalizePayload(job.handoff_artifact_payload)); + const payload = structuredClone(originalValidation.payload); if (!payload?.scheduler_job_binding || typeof payload.scheduler_job_binding !== 'object') { throw Object.assign(new Error('handoff v4 artifact is missing scheduler_job_binding'), { code: 'HANDOFF_ARTIFACT_INVALID', diff --git a/src/scheduler-fields.js b/src/scheduler-fields.js index 745eac0..5bfcf7d 100644 --- a/src/scheduler-fields.js +++ b/src/scheduler-fields.js @@ -54,6 +54,10 @@ export const SCHEDULER_FIELDS_V04 = [ 'payload_scope', 'resource_pool', 'job_class', + 'payload_timeout_seconds', + 'payload_model_fallback', + 'auth_profile_fallback', + 'shell_env_policy', 'job_type', 'watchdog_target_label', 'watchdog_check_cmd', diff --git a/src/schema.js b/src/schema.js index 5174fc2..f75b7f0 100644 --- a/src/schema.js +++ b/src/schema.js @@ -806,6 +806,9 @@ Object.assign(MANIFEST_SCHEMA.standalonePlan.fields.capabilities.fields, { }); Object.assign(MANIFEST_SCHEMA.schedulerJob.fields, { + approval_risk_level: nullableString, + approval_approver_scope: nullableString, + output_format: nullableString, identity_ref: nullableString, identity_subject_kind: nullableString, identity_subject_principal: nullableString, @@ -826,6 +829,29 @@ Object.assign(MANIFEST_SCHEMA.schedulerJob.fields, { verify_timeout_s: { type: 'integer', nullable: true, min: 1 }, verify_on_failure: nullableString, auth_profile: { type: 'string', nullable: true, note: 'Auth profile ID for scheduler dispatch (e.g. \'anthropic:me.com\'). Scheduler-target only — ignored by other backends.' }, + handoff_version: { type: 'integer', nullable: true }, + handoff_artifact_digest: nullableString, + handoff_artifact_payload: { type: 'object', nullable: true }, + effective_task_hash: nullableString, + payload_scope: nullableString, + resource_pool: nullableString, + job_class: nullableString, + payload_timeout_seconds: { type: 'integer', nullable: true, min: 1 }, + payload_model_fallback: nullableString, + auth_profile_fallback: nullableString, + shell_env_policy: nullableString, + job_type: nullableString, + watchdog_target_label: nullableString, + watchdog_check_cmd: nullableString, + watchdog_timeout_min: { type: 'integer', nullable: true, min: 1 }, + watchdog_alert_channel: nullableString, + watchdog_alert_target: nullableString, + watchdog_self_destruct: { + type: ['integer', 'boolean'], + nullable: true, + note: '1, 0, true, or false', + }, + watchdog_started_at: nullableString, }); const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 0b8a64c..cea3118 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -3306,6 +3306,22 @@ test('cli schema accepts kebab-case aliases', async () => { assert.ok(output.schema.fields.id); }); +test('scheduler-job JSON Schema describes every v4 projected and compiled field', async () => { + const schemaOutput = JSON.parse(await runCli(['schema', 'scheduler-job'])); + const schema = schemaOutput.schema; + const compiled = compileManifestToScheduler(readExample('hello-world.json'), { + schedulerHandoffVersion: '4', + }).jobs[0]; + + assert.equal(schema.additionalProperties, false); + for (const field of SCHEDULER_FIELD_VERSIONS['4']) { + assert.ok(schema.properties[field], `${field} must be described by scheduler-job schema`); + } + for (const field of Object.keys(compiled)) { + assert.ok(schema.properties[field], `${field} from compiled v4 output must be described`); + } +}); + test('subpath exports resolve correctly', async () => { const describe = await import('../src/describe.js'); assert.equal(typeof describe.describeTarget, 'function'); diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index bf779d1..41f47e6 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -498,6 +498,10 @@ test('scheduler execution binding covers routing and resource controls', () => { { payload_scope: 'global' }, { resource_pool: 'different-pool' }, { job_class: 'pre_compaction_flush' }, + { payload_timeout_seconds: 321 }, + { payload_model_fallback: 'fallback-model' }, + { auth_profile_fallback: 'fallback-profile' }, + { shell_env_policy: 'inherit' }, ]) { const rebound = rebindSchedulerHandoffV4Job(job, override); assert.notEqual( @@ -576,6 +580,22 @@ test('handoff v4 scheduler rebinding replaces adoption metadata atomically', () }).ok, true); }); +test('handoff v4 scheduler rebinding rejects a tampered original artifact', () => { + const job = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const tampered = structuredClone(job); + tampered.handoff_artifact_payload.manifest.digest = `sha256:${'a'.repeat(64)}`; + + assert.throws( + () => rebindSchedulerHandoffV4Job(tampered, { origin: 'legacy-origin' }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /artifact digest does not match payload/.test(error.message), + ); +}); + test('handoff v4 scheduler rebinding rejects artifact-bound overrides', () => { const job = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -769,6 +789,10 @@ test('v4 apply add, update, clear-null, and adopt preserve complete immutable ar payload_scope: 'global', resource_pool: 'legacy-pool', job_class: 'pre_compaction_flush', + payload_timeout_seconds: 321, + payload_model_fallback: 'legacy-fallback-model', + auth_profile_fallback: 'legacy-fallback-profile', + shell_env_policy: 'inherit', job_type: 'watchdog', watchdog_target_label: 'legacy-target', watchdog_check_cmd: '/usr/bin/legacy-health-check', @@ -825,6 +849,10 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad payload_scope: 'global', resource_pool: 'preserved-pool', job_class: 'pre_compaction_flush', + payload_timeout_seconds: 654, + payload_model_fallback: 'preserved-fallback-model', + auth_profile_fallback: 'preserved-fallback-profile', + shell_env_policy: 'inherit', job_type: 'watchdog', watchdog_target_label: 'preserved-target', watchdog_check_cmd: '/usr/bin/preserved-health-check --strict', @@ -1014,6 +1042,31 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(stringVersionUnbound.verified, false); assert.match(stringVersionUnbound.reason, /trusted handoff artifact digest/); + const expiredBeyondStringSkew = jwtVerifier.verifyProof( + signJwt({ + ...payload, + iat: now - 300, + exp: now - 61, + jti: 'proof-expired-string-skew', + }, privateKey), + profile, + { + ...context, + clockSkewSeconds: '60', + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(expiredBeyondStringSkew.verified, false); + assert.match(expiredBeyondStringSkew.reason, /expired/); + + const invalidSkew = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-invalid-skew' }, privateKey), + profile, + { ...context, clockSkewSeconds: 'not-a-number' }, + ); + assert.equal(invalidSkew.verified, false); + assert.match(invalidSkew.reason, /clockSkewSeconds/); + const inverted = signJwt({ ...payload, iat: now + 30, From 484fdaad6dd361b0e45bcafa1d04dcab2357370a Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 01:47:47 -0400 Subject: [PATCH 17/29] fix: verify v4 job binding before rebind --- src/handoff/v4.js | 19 +++++++++++++++++++ test/handoff-v4.test.js | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 0d7fdc9..ca60afc 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -632,6 +632,15 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { { code: 'HANDOFF_REBIND_OVERRIDE_INVALID', fields: invalidFields }, ); } + if (!SHA256_PATTERN.test(job.handoff_artifact_digest)) { + throw Object.assign( + new Error('Cannot rebind handoff v4 job without a valid original artifact digest'), + { + code: 'HANDOFF_ARTIFACT_INVALID', + errors: ['handoff_artifact_digest must be a sha256 digest'], + }, + ); + } const originalValidation = validateSchedulerHandoffV4Artifact( job.handoff_artifact_payload, { expectedDigest: job.handoff_artifact_digest }, @@ -644,6 +653,16 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { { code: 'HANDOFF_ARTIFACT_INVALID', errors: originalValidation.errors }, ); } + const currentBindingDigest = canonicalDigest(schedulerJobExecutionProjection(job)); + if (originalValidation.payload.scheduler_job_binding.digest !== currentBindingDigest) { + throw Object.assign( + new Error('Cannot rebind handoff v4 job whose execution projection no longer matches its artifact'), + { + code: 'HANDOFF_ARTIFACT_INVALID', + errors: ['scheduler_job_binding.digest does not match the current job projection'], + }, + ); + } const reboundJob = { ...job, ...overrides }; const payload = structuredClone(originalValidation.payload); if (!payload?.scheduler_job_binding || typeof payload.scheduler_job_binding !== 'object') { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 41f47e6..6b603b6 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -594,6 +594,22 @@ test('handoff v4 scheduler rebinding rejects a tampered original artifact', () = error => error.code === 'HANDOFF_ARTIFACT_INVALID' && /artifact digest does not match payload/.test(error.message), ); + + const missingDigest = structuredClone(job); + delete missingDigest.handoff_artifact_digest; + assert.throws( + () => rebindSchedulerHandoffV4Job(missingDigest, { origin: 'legacy-origin' }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /valid original artifact digest/.test(error.message), + ); + + const changedJobProjection = structuredClone(job); + changedJobProjection.payload_message = 'tampered outside the artifact'; + assert.throws( + () => rebindSchedulerHandoffV4Job(changedJobProjection, { origin: 'legacy-origin' }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /execution projection no longer matches/.test(error.message), + ); }); test('handoff v4 scheduler rebinding rejects artifact-bound overrides', () => { From 0314a133c9328b962fe05fa6443708bf34337d7c Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 02:00:03 -0400 Subject: [PATCH 18/29] fix: fail closed on invalid proof clocks --- src/authorization-proof/certificate.js | 39 +++++++++++----- src/authorization-proof/detached-signature.js | 44 ++++++++++++------ src/authorization-proof/jwt.js | 23 +++------- src/authorization-proof/time.js | 46 +++++++++++++++++++ test/handoff-v4.test.js | 36 +++++++++++++++ 5 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 src/authorization-proof/time.js diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index abb4375..3b9c4a9 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -16,6 +16,7 @@ import { import { canonicalStringify, hashString } from '../canonical.js'; import { resolveValueFrom } from '../command.js'; import { registerVerifier } from './index.js'; +import { normalizeProofTimeContext } from './time.js'; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; @@ -138,14 +139,14 @@ function validateV4CertificateEnvelope(parsed, context) { if (normalizeDigest(parsed.artifact_digest) !== normalizeDigest(context.artifactDigest)) { return { ok: false, v4: true, reason: 'certificate proof artifact digest does not match' }; } - const now = typeof context.now === 'number' - ? context.now - : context.now instanceof Date - ? context.now.getTime() - : Date.now(); + const proofTime = normalizeProofTimeContext(context); + if (!proofTime.ok) { + return { ok: false, v4: true, reason: proofTime.reason }; + } + const now = proofTime.nowMs; const issuedAt = Date.parse(parsed.issued_at); const expiresAt = Date.parse(parsed.expires_at); - const skewMs = (context.clockSkewSeconds ?? 60) * 1000; + const skewMs = proofTime.clockSkewMs; if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) { return { ok: false, v4: true, reason: 'certificate proof has invalid issued_at or expires_at' }; } @@ -486,12 +487,26 @@ const certificateVerifier = { * @returns {object} Verification result. */ verifyProof(proof, profile, ctx) { - const context = resolveCertificateVerificationContext(profile, ctx || {}); - const verificationNowMs = typeof context.now === 'number' - ? context.now - : context.now instanceof Date - ? context.now.getTime() - : Date.now(); + let context = resolveCertificateVerificationContext(profile, ctx || {}); + const proofTime = normalizeProofTimeContext(context); + if (!proofTime.ok) { + return { + verified: false, + method: 'certificate', + reason: proofTime.reason, + claims_validated: false, + signature_verified: false, + proof_of_possession_verified: false, + manifest_digest: context.manifestDigest || null, + verified_at: null, + }; + } + context = { + ...context, + now: proofTime.nowMs, + clockSkewSeconds: proofTime.clockSkewSeconds, + }; + const verificationNowMs = proofTime.nowMs; const verifiedAt = new Date(verificationNowMs).toISOString(); const claims = (profile && profile.claims) || {}; diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index ed60c57..2839e92 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -16,6 +16,7 @@ import process from 'node:process'; import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; import { publicKeyId } from './key-identity.js'; +import { normalizeProofTimeContext } from './time.js'; const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; @@ -188,14 +189,14 @@ function parseV4Envelope(proof, context) { return { error: 'detached proof artifact digest does not match', v4: true }; } - const now = typeof context.now === 'number' - ? context.now - : context.now instanceof Date - ? context.now.getTime() - : Date.now(); + const proofTime = normalizeProofTimeContext(context); + if (!proofTime.ok) { + return { error: proofTime.reason, v4: true }; + } + const now = proofTime.nowMs; const issuedAt = Date.parse(envelope.issued_at); const expiresAt = Date.parse(envelope.expires_at); - const skewMs = (context.clockSkewSeconds ?? 60) * 1000; + const skewMs = proofTime.clockSkewMs; if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) { return { error: 'detached proof has invalid issued_at or expires_at', v4: true }; } @@ -280,12 +281,8 @@ function signedContent(parsed, context) { } function verifiedAt(context) { - const now = typeof context.now === 'number' - ? context.now - : context.now instanceof Date - ? context.now.getTime() - : Date.now(); - return new Date(now).toISOString(); + const proofTime = normalizeProofTimeContext(context); + return proofTime.ok ? new Date(proofTime.nowMs).toISOString() : null; } /** @@ -431,8 +428,29 @@ const detachedSignatureVerifier = { * @returns {object} Verification result. */ verifyProof(proof, profile, ctx) { - const context = resolveDetachedSignatureVerificationContext(profile, ctx || {}); + let context = resolveDetachedSignatureVerificationContext(profile, ctx || {}); const digest = context.manifestDigest; + const proofTime = normalizeProofTimeContext(context); + if (!proofTime.ok) { + return { + verified: false, + method: 'detached-signature', + issuer: profile?.issuer ?? null, + signature_verified: false, + signature_verification_reason: proofTime.reason, + manifest_digest: digest, + artifact_digest: context.artifactDigest ?? null, + artifact_bound: false, + replay_protected: false, + revocation_checked: false, + verified_at: null, + }; + } + context = { + ...context, + now: proofTime.nowMs, + clockSkewSeconds: proofTime.clockSkewSeconds, + }; const parsed = parseV4Envelope(proof, context); if (parsed.error) { diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index cf74b52..b811416 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -13,6 +13,7 @@ import { createPublicKey, createVerify } from 'node:crypto'; import { canonicalDigest } from '../canonical.js'; import { registerVerifier } from './index.js'; import { publicKeyId } from './key-identity.js'; +import { normalizeProofTimeContext } from './time.js'; const DEFAULT_JWKS_CACHE_TTL_MS = 5 * 60 * 1000; const AUDIT_SAFE_CLAIMS = [ @@ -575,20 +576,9 @@ const jwtVerifier = { const artifactDigest = context.artifactDigest ?? context.handoffArtifactDigest ?? null; const v4Required = Number(context.handoffVersion ?? context.handoff_version) === 4 || artifactDigest != null; - const clockSkewInput = v4Required - ? (context.clockSkewSeconds ?? 60) - : (context.clockSkewSeconds ?? 0); - const clockSkewSeconds = ( - (typeof clockSkewInput === 'number' || typeof clockSkewInput === 'string') - && !(typeof clockSkewInput === 'string' && clockSkewInput.trim() === '') - ) - ? Number(clockSkewInput) - : Number.NaN; - const verificationNowMs = typeof context.now === 'number' - ? context.now - : context.now instanceof Date - ? context.now.getTime() - : Date.now(); + const proofTime = normalizeProofTimeContext(context, { + defaultClockSkewSeconds: v4Required ? 60 : 0, + }); // Validate proof is a non-empty string if (!proof || typeof proof !== 'string') { @@ -601,15 +591,16 @@ const jwtVerifier = { }; } - if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0) { + if (!proofTime.ok) { return { verified: false, method: 'jwt', - reason: 'clockSkewSeconds must be a finite non-negative number', + reason: proofTime.reason, claims_validated: false, signature_verified: false, }; } + const { clockSkewSeconds, nowMs: verificationNowMs } = proofTime; // Parse JWT structure let decoded; diff --git a/src/authorization-proof/time.js b/src/authorization-proof/time.js new file mode 100644 index 0000000..f78ac93 --- /dev/null +++ b/src/authorization-proof/time.js @@ -0,0 +1,46 @@ +/** + * Normalize the trusted clock inputs used by authorization-proof verifiers. + * Explicit invalid values fail closed instead of disabling temporal checks + * through NaN arithmetic or throwing while formatting audit timestamps. + */ +export function normalizeProofTimeContext( + context = {}, + { defaultClockSkewSeconds = 60 } = {}, +) { + const clockSkewInput = context.clockSkewSeconds ?? defaultClockSkewSeconds; + const clockSkewSeconds = ( + (typeof clockSkewInput === 'number' || typeof clockSkewInput === 'string') + && !(typeof clockSkewInput === 'string' && clockSkewInput.trim() === '') + ) + ? Number(clockSkewInput) + : Number.NaN; + const clockSkewMs = clockSkewSeconds * 1000; + if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0 || !Number.isFinite(clockSkewMs)) { + return { + ok: false, + reason: 'clockSkewSeconds must be a finite non-negative number', + }; + } + + const nowInput = context.now; + const nowMs = nowInput === undefined + ? Date.now() + : typeof nowInput === 'number' + ? nowInput + : nowInput instanceof Date + ? nowInput.getTime() + : Number.NaN; + if (!Number.isFinite(nowMs)) { + return { + ok: false, + reason: 'now must be a valid Date or finite millisecond timestamp', + }; + } + + return { + ok: true, + nowMs, + clockSkewSeconds, + clockSkewMs, + }; +} diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 6b603b6..bfc3a13 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -1083,6 +1083,14 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(invalidSkew.verified, false); assert.match(invalidSkew.reason, /clockSkewSeconds/); + const invalidNow = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-invalid-now' }, privateKey), + profile, + { ...context, now: new Date(Number.NaN) }, + ); + assert.equal(invalidNow.verified, false); + assert.match(invalidNow.reason, /valid Date/); + const inverted = signJwt({ ...payload, iat: now + 30, @@ -1174,6 +1182,20 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me assert.equal(verified.revocation_checked, true); assert.equal(verified.verified_at, new Date(now).toISOString()); + const invalidSkew = detachedSignatureVerifier.verifyProof(envelope, profile, { + ...context, + clockSkewSeconds: 'not-a-number', + }); + assert.equal(invalidSkew.verified, false); + assert.match(invalidSkew.signature_verification_reason, /clockSkewSeconds/); + + const invalidNow = detachedSignatureVerifier.verifyProof(envelope, profile, { + ...context, + now: new Date(Number.NaN), + }); + assert.equal(invalidNow.verified, false); + assert.match(invalidNow.signature_verification_reason, /valid Date/); + const tampered = detachedSignatureVerifier.verifyProof({ ...envelope, expires_at: new Date(now + 120_000).toISOString(), @@ -1335,6 +1357,20 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => assert.equal(verified.verified, true, verified.signature_verification_reason); assert.equal(verified.verified_at, new Date(now).toISOString()); + const invalidSkew = certificateVerifier.verifyProof(envelope, profile, { + ...context, + clockSkewSeconds: 'not-a-number', + }); + assert.equal(invalidSkew.verified, false); + assert.match(invalidSkew.reason, /clockSkewSeconds/); + + const invalidNow = certificateVerifier.verifyProof(envelope, profile, { + ...context, + now: new Date(Number.NaN), + }); + assert.equal(invalidNow.verified, false); + assert.match(invalidNow.reason, /valid Date/); + const forged = certificateVerifier.verifyProof({ ...envelope, nonce: 'certificate-proof-forged', From 047ec5a51b03d1278791c8cba2469f7c84104b9c Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 02:01:26 -0400 Subject: [PATCH 19/29] fix: reject out-of-range proof timestamps --- src/authorization-proof/time.js | 5 +++-- test/handoff-v4.test.js | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/authorization-proof/time.js b/src/authorization-proof/time.js index f78ac93..f9b5e99 100644 --- a/src/authorization-proof/time.js +++ b/src/authorization-proof/time.js @@ -30,7 +30,8 @@ export function normalizeProofTimeContext( : nowInput instanceof Date ? nowInput.getTime() : Number.NaN; - if (!Number.isFinite(nowMs)) { + const normalizedNowMs = new Date(nowMs).getTime(); + if (!Number.isFinite(nowMs) || !Number.isFinite(normalizedNowMs)) { return { ok: false, reason: 'now must be a valid Date or finite millisecond timestamp', @@ -39,7 +40,7 @@ export function normalizeProofTimeContext( return { ok: true, - nowMs, + nowMs: normalizedNowMs, clockSkewSeconds, clockSkewMs, }; diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index bfc3a13..1812369 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -1091,6 +1091,14 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(invalidNow.verified, false); assert.match(invalidNow.reason, /valid Date/); + const outOfRangeNow = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-out-of-range-now' }, privateKey), + profile, + { ...context, now: Number.MAX_VALUE }, + ); + assert.equal(outOfRangeNow.verified, false); + assert.match(outOfRangeNow.reason, /valid Date/); + const inverted = signJwt({ ...payload, iat: now + 30, From e35478d54cf75e64b8e8466f6efff5876c0bfba1 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 02:35:31 -0400 Subject: [PATCH 20/29] fix: close v4 fail-closed review gaps --- src/authorization-proof/jwt.js | 27 +++++++++---- src/handoff/v4.js | 17 ++++++++ src/schema.js | 24 ++++++++--- src/validate.js | 29 +++++++++++--- test/agentcli.test.js | 73 ++++++++++++++++++++++++++++++++++ test/handoff-v4.test.js | 56 +++++++++++++++++++++++++- 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index b811416..8cb53a9 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -665,16 +665,29 @@ const jwtVerifier = { signature_verified: false, }; } + let replayExpiresAt = null; if (payload.exp !== undefined) { - if (typeof payload.exp !== 'number') { + if (!Number.isFinite(payload.exp)) { return { verified: false, method: 'jwt', - reason: 'JWT "exp" claim is not a number', + reason: 'JWT "exp" claim must be a finite number', claims_validated: false, signature_verified: false, }; } + const expirationMs = payload.exp * 1000; + const expirationDate = new Date(expirationMs); + if (!Number.isFinite(expirationMs) || !Number.isFinite(expirationDate.getTime())) { + return { + verified: false, + method: 'jwt', + reason: 'JWT "exp" claim is outside the supported Date range', + claims_validated: false, + signature_verified: false, + }; + } + replayExpiresAt = expirationDate.toISOString(); if (now >= payload.exp + clockSkewSeconds) { return { verified: false, @@ -688,11 +701,11 @@ const jwtVerifier = { // Check not-before (nbf claim) if (payload.nbf !== undefined) { - if (typeof payload.nbf !== 'number') { + if (!Number.isFinite(payload.nbf)) { return { verified: false, method: 'jwt', - reason: 'JWT "nbf" claim is not a number', + reason: 'JWT "nbf" claim must be a finite number', claims_validated: false, signature_verified: false, }; @@ -709,11 +722,11 @@ const jwtVerifier = { } if (v4Required) { - if (typeof payload.iat !== 'number') { + if (!Number.isFinite(payload.iat)) { return { verified: false, method: 'jwt', - reason: 'handoff v4 JWT is missing a numeric iat claim', + reason: 'handoff v4 JWT is missing a finite numeric iat claim', claims_validated: false, signature_verified: false, }; @@ -863,7 +876,7 @@ const jwtVerifier = { subject: payload.sub ?? null, proofId: payload.jti, artifactDigest, - expiresAt: new Date(payload.exp * 1000).toISOString(), + expiresAt: replayExpiresAt, runId: context.runId ?? null, }); if (replayResult && typeof replayResult.then === 'function') { diff --git a/src/handoff/v4.js b/src/handoff/v4.js index ca60afc..3320f6d 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -653,6 +653,23 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { { code: 'HANDOFF_ARTIFACT_INVALID', errors: originalValidation.errors }, ); } + const compiledIdentityErrors = []; + if (job.id !== originalValidation.payload.compiled.job_id) { + compiledIdentityErrors.push('compiled.job_id does not match the current job id'); + } + if (job.effective_task_hash !== originalValidation.payload.compiled.effective_task_hash) { + compiledIdentityErrors.push( + 'compiled.effective_task_hash does not match the current job effective_task_hash', + ); + } + if (compiledIdentityErrors.length > 0) { + throw Object.assign( + new Error( + `Cannot rebind handoff v4 job whose compiled identity no longer matches its artifact: ${compiledIdentityErrors.join('; ')}`, + ), + { code: 'HANDOFF_ARTIFACT_INVALID', errors: compiledIdentityErrors }, + ); + } const currentBindingDigest = canonicalDigest(schedulerJobExecutionProjection(job)); if (originalValidation.payload.scheduler_job_binding.digest !== currentBindingDigest) { throw Object.assign( diff --git a/src/schema.js b/src/schema.js index f75b7f0..773c826 100644 --- a/src/schema.js +++ b/src/schema.js @@ -122,6 +122,13 @@ const childCredentialPolicyField = { nullable: true, }; +const uniqueNonEmptyStringArrayField = { + type: 'array', + nullable: true, + uniqueItems: true, + items: { type: 'string', minLength: 1 }, +}; + const identityField = { type: 'object', nullable: true, @@ -648,7 +655,7 @@ export const evidenceRefField = { type: 'object', nullable: true, fields: { - bind: { type: 'array', nullable: true, items: { type: 'string' } }, + bind: uniqueNonEmptyStringArrayField, context: { type: 'object', nullable: true }, format: { type: 'string', enum: ['canonical-json', 'json'], nullable: true }, }, @@ -672,13 +679,13 @@ export const evidenceProfileField = { fields: { id: { type: 'string' }, provider: { type: 'string' }, - methods: { type: 'array', nullable: true, items: { type: 'string' } }, + methods: uniqueNonEmptyStringArrayField, provider_config: { type: 'object', nullable: true }, payload: { type: 'object', nullable: true, fields: { - bind: { type: 'array', nullable: true, items: { type: 'string' } }, + bind: uniqueNonEmptyStringArrayField, context: { type: 'object', nullable: true }, format: { type: 'string', enum: ['canonical-json', 'json'], nullable: true }, }, @@ -876,6 +883,11 @@ function nullableSchema(schema) { const nullableStringSchema = nullableSchema({ type: 'string' }); const nullableTokenSchema = nullableSchema({ type: 'string', pattern: TOKEN_PATTERN }); const nullableBooleanSchema = nullableSchema({ type: 'boolean' }); +const nullableUniqueNonEmptyStringArraySchema = nullableSchema({ + type: 'array', + uniqueItems: true, + items: { type: 'string', minLength: 1 }, +}); const jsonSchemaDefs = { valueFrom: objectSchema({ @@ -1084,7 +1096,7 @@ const jsonSchemaDefs = { decision: nullableSchema({ $ref: '#/$defs/authorizationDecision' }), }, { required: ['ref'] }), evidencePayload: objectSchema({ - bind: nullableSchema({ type: 'array', items: { type: 'string' } }), + bind: nullableUniqueNonEmptyStringArraySchema, context: nullableSchema({ type: 'object', additionalProperties: true }), format: nullableSchema({ type: 'string', enum: ['canonical-json', 'json'] }), }), @@ -1136,7 +1148,7 @@ const jsonSchemaDefs = { evidenceProfile: objectSchema({ id: { type: 'string', pattern: IDENTIFIER_PATTERN }, provider: { type: 'string', minLength: 1 }, - methods: nullableSchema({ type: 'array', items: { type: 'string' } }), + methods: nullableUniqueNonEmptyStringArraySchema, provider_config: nullableSchema({ type: 'object', additionalProperties: true }), payload: nullableSchema({ $ref: '#/$defs/evidencePayload' }), verify: nullableSchema(objectSchema({ required: nullableBooleanSchema })), @@ -1294,7 +1306,9 @@ function legacyDescriptorToJsonSchema(descriptor) { if (descriptor.enum) result.enum = [...descriptor.enum]; if (descriptor.required) result.required = [...descriptor.required]; if (descriptor.min !== undefined) result.minimum = descriptor.min; + if (descriptor.minLength !== undefined) result.minLength = descriptor.minLength; if (descriptor.minItems !== undefined) result.minItems = descriptor.minItems; + if (descriptor.uniqueItems !== undefined) result.uniqueItems = descriptor.uniqueItems; if (descriptor.note) result.description = descriptor.note; if (descriptor.format === 'token') result.pattern = TOKEN_PATTERN; if (descriptor.fields) { diff --git a/src/validate.js b/src/validate.js index 4b01a49..98fc187 100644 --- a/src/validate.js +++ b/src/validate.js @@ -165,6 +165,26 @@ function checkString(errors, path, value, { required = true } = {}) { } } +function checkUniqueNonEmptyStringArray(errors, path, value) { + if (value == null) return; + if (!Array.isArray(value)) { + addError(errors, path, 'must be an array'); + return; + } + + const seen = new Set(); + for (const [index, item] of value.entries()) { + const itemPath = `${path}[${index}]`; + checkString(errors, itemPath, item); + if (typeof item !== 'string' || item.trim() === '') continue; + if (seen.has(item)) { + addError(errors, itemPath, 'must be unique within the array'); + } else { + seen.add(item); + } + } +} + function checkIdentifier(errors, path, value, { required = true } = {}) { checkString(errors, path, value, { required }); if (value == null || typeof value !== 'string' || value.trim() === '') return; @@ -539,9 +559,7 @@ function validateEvidenceRef(errors, path, value) { if (!isObject(value)) { addError(errors, path, 'must be an object'); return; } checkString(errors, `${path}.ref`, value.ref, { required: false }); if (checkOptionalObject(errors, `${path}.payload`, value.payload)) { - if (value.payload.bind != null && !Array.isArray(value.payload.bind)) { - addError(errors, `${path}.payload.bind`, 'must be an array'); - } + checkUniqueNonEmptyStringArray(errors, `${path}.payload.bind`, value.payload.bind); if (value.payload.context != null && !isObject(value.payload.context)) { addError(errors, `${path}.payload.context`, 'must be an object'); } @@ -1181,13 +1199,12 @@ export function validateManifest(manifest) { if (evidIds.has(profile.id)) addError(errors, `${pp}.id`, 'must be unique'); evidIds.add(profile.id); } + checkUniqueNonEmptyStringArray(errors, `${pp}.methods`, profile.methods); if (profile.provider_config != null && !isObject(profile.provider_config)) { addError(errors, `${pp}.provider_config`, 'must be an object'); } if (checkOptionalObject(errors, `${pp}.payload`, profile.payload)) { - if (profile.payload.bind != null && !Array.isArray(profile.payload.bind)) { - addError(errors, `${pp}.payload.bind`, 'must be an array'); - } + checkUniqueNonEmptyStringArray(errors, `${pp}.payload.bind`, profile.payload.bind); if (profile.payload.context != null && !isObject(profile.payload.context)) { addError(errors, `${pp}.payload.context`, 'must be an object'); } diff --git a/test/agentcli.test.js b/test/agentcli.test.js index cea3118..7f0b885 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -824,6 +824,24 @@ test('cli schema manifest reflects v0.2 identity surfaces', async () => { assert.ok(output.schema.fields.evidence_profiles); }); +test('cli schema manifest exposes unique non-empty evidence binding lists', async () => { + const legacy = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); + const legacyProfile = legacy.schema.fields.evidence_profiles.items; + assert.equal(legacyProfile.fields.methods.uniqueItems, true); + assert.equal(legacyProfile.fields.methods.items.minLength, 1); + assert.equal(legacyProfile.fields.payload.fields.bind.uniqueItems, true); + assert.equal(legacyProfile.fields.payload.fields.bind.items.minLength, 1); + + const jsonSchema = JSON.parse(await runCli(['schema', 'manifest'])); + const profile = jsonSchema.schema.$defs.evidenceProfile.properties; + const methods = profile.methods.anyOf[0]; + const payloadBind = jsonSchema.schema.$defs.evidencePayload.properties.bind.anyOf[0]; + assert.equal(methods.uniqueItems, true); + assert.equal(methods.items.minLength, 1); + assert.equal(payloadBind.uniqueItems, true); + assert.equal(payloadBind.items.minLength, 1); +}); + test('cli schema manifest exposes authorization proof value_from sources', async () => { const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); const proofValueFrom = output.schema.fields.authorization_proof_profiles.items.fields.proof.fields.value_from.fields; @@ -5816,6 +5834,61 @@ test('v0.2 manifest version is accepted', () => { assert.strictEqual(result.ok, true); }); +test('validation rejects duplicate or empty evidence methods and payload bindings', () => { + const manifest = { + version: '0.2', + evidence_profiles: [{ + id: 'evidence', + provider: 'none', + methods: ['signed', '', 'signed'], + payload: { bind: ['command', 'command'] }, + }], + workflows: [{ + id: 'w', + name: 'W', + tasks: [{ + id: 't', + name: 'T', + target: { session_target: 'shell' }, + shell: { program: 'echo' }, + schedule: { cron: '* * * * *' }, + evidence: { + ref: 'evidence', + payload: { bind: ['', 'result', 'result'] }, + }, + }], + }], + }; + + const result = validateManifest(manifest); + assert.equal(result.ok, false); + assert.ok(result.errors.some(error => ( + error.path === '$.evidence_profiles[0].methods[1]' + && /cannot be empty/.test(error.message) + ))); + assert.ok(result.errors.some(error => ( + error.path === '$.evidence_profiles[0].methods[2]' + && /unique/.test(error.message) + ))); + assert.ok(result.errors.some(error => ( + error.path === '$.evidence_profiles[0].payload.bind[1]' + && /unique/.test(error.message) + ))); + assert.ok(result.errors.some(error => ( + error.path === '$.workflows[0].tasks[0].evidence.payload.bind[0]' + && /cannot be empty/.test(error.message) + ))); + assert.ok(result.errors.some(error => ( + error.path === '$.workflows[0].tasks[0].evidence.payload.bind[2]' + && /unique/.test(error.message) + ))); + assert.throws( + () => compileManifestToScheduler(manifest, { schedulerHandoffVersion: '4' }), + error => error.message === 'Manifest validation failed' + && error.validation?.errors.some(item => item.path.includes('evidence_profiles[0].methods')), + ); +}); + // -- Identity Provider Registry Tests -- test('identity provider registry lists none and env-bearer', async () => { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 1812369..4c3bcfb 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -176,15 +176,19 @@ function base64url(value) { return Buffer.from(value).toString('base64url'); } -function signJwt(payload, privateKey) { +function signJwtJson(payloadJson, privateKey) { const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: 'test-key' })); - const body = base64url(JSON.stringify(payload)); + const body = base64url(payloadJson); const signingInput = `${header}.${body}`; const signer = createSign('RSA-SHA256'); signer.update(signingInput); return `${signingInput}.${signer.sign(privateKey).toString('base64url')}`; } +function signJwt(payload, privateKey) { + return signJwtJson(JSON.stringify(payload), privateKey); +} + test('handoff v4 artifact is canonical, deterministic, and tamper evident', () => { const compiled = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', @@ -610,6 +614,22 @@ test('handoff v4 scheduler rebinding rejects a tampered original artifact', () = error => error.code === 'HANDOFF_ARTIFACT_INVALID' && /execution projection no longer matches/.test(error.message), ); + + const changedJobId = structuredClone(job); + changedJobId.id = 'different-job-id'; + assert.throws( + () => rebindSchedulerHandoffV4Job(changedJobId, { origin: 'legacy-origin' }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /compiled\.job_id does not match/.test(error.message), + ); + + const changedEffectiveTaskHash = structuredClone(job); + changedEffectiveTaskHash.effective_task_hash = `sha256:${'0'.repeat(64)}`; + assert.throws( + () => rebindSchedulerHandoffV4Job(changedEffectiveTaskHash, { origin: 'legacy-origin' }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /compiled\.effective_task_hash does not match/.test(error.message), + ); }); test('handoff v4 scheduler rebinding rejects artifact-bound overrides', () => { @@ -1099,6 +1119,38 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(outOfRangeNow.verified, false); assert.match(outOfRangeNow.reason, /valid Date/); + const overflowingExpiry = jwtVerifier.verifyProof( + signJwt({ ...payload, exp: Number.MAX_VALUE, jti: 'proof-overflowing-expiry' }, privateKey), + profile, + context, + ); + assert.equal(overflowingExpiry.verified, false); + assert.match(overflowingExpiry.reason, /supported Date range/); + assert.equal(claimed.has('proof-overflowing-expiry'), false); + + const infiniteExpiryJson = JSON.stringify({ + ...payload, + exp: '__INFINITE_EXPIRY__', + jti: 'proof-infinite-expiry', + }).replace('"__INFINITE_EXPIRY__"', '1e309'); + const infiniteExpiry = jwtVerifier.verifyProof( + signJwtJson(infiniteExpiryJson, privateKey), + profile, + context, + ); + assert.equal(infiniteExpiry.verified, false); + assert.match(infiniteExpiry.reason, /finite number/); + assert.equal(claimed.has('proof-infinite-expiry'), false); + + const outOfRangeExpiry = jwtVerifier.verifyProof( + signJwt({ ...payload, exp: 8_640_000_000_001, jti: 'proof-out-of-range-expiry' }, privateKey), + profile, + context, + ); + assert.equal(outOfRangeExpiry.verified, false); + assert.match(outOfRangeExpiry.reason, /supported Date range/); + assert.equal(claimed.has('proof-out-of-range-expiry'), false); + const inverted = signJwt({ ...payload, iat: now + 30, From 92eb60f2763c57879d6898a9451e70bc393a4fb7 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 02:46:37 -0400 Subject: [PATCH 21/29] ci: pin final handoff v4 scheduler --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ad3eb4..ee9dd13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: 0643d94914230e0384452cb795c6be27984a240b + OPENCLAW_SCHEDULER_REF: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 0643d94914230e0384452cb795c6be27984a240b + ref: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 658d227..5256c31 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: 0643d94914230e0384452cb795c6be27984a240b + OPENCLAW_SCHEDULER_REF: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 0643d94914230e0384452cb795c6be27984a240b + ref: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" From 176ef200727b15e2b64ff4cc8e2388718fd7403a Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 03:09:31 -0400 Subject: [PATCH 22/29] Fix final handoff v4 validation gaps --- CHANGELOG.md | 10 +++- docs/spec.md | 9 ++++ fixtures/handoff-v4/conformance.json | 22 ++++++++- src/apply.js | 7 ++- src/authorization-proof/key-identity.js | 7 ++- src/handoff/schema-v4.js | 2 + src/handoff/v4.js | 50 +++++++++++-------- test/agentcli.test.js | 66 +++++++++++++++++++++++-- test/handoff-v4.test.js | 65 +++++++++++++++++++++++- 9 files changed, 208 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ef99b..eaab99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.5.0 (2026-07-18) +## 0.5.0 (2026-07-19) - scheduler: added handoff v4 canonical artifacts with explicit artifact, canonicalization, execution-binding, and scheduler-binding versions @@ -19,16 +19,24 @@ - scheduler: adoption rebinds the artifact after the final origin is selected, and apply preserves direct-compile hashes for the caller's explicit compile environment while host variables are merged only for scheduler process spawn +- scheduler: update and adoption validate the persisted v4 artifact, compiled + identity, and current execution projection before preserving runtime + overrides; the CLI adapter requests opt-in artifact hydration for this check - security: JWT, detached-signature, and certificate proofs bind the exact v4 artifact, replay identifier, validity, key, and revocation result - security: v4 proofs reject inverted validity intervals, bind asserted key IDs to the key or certificate that actually verified, and require an explicit not-revoked result from the runtime checker +- security: JWKS-provided raw JWK objects are normalized before stable public + key identity derivation, while private JWK material remains forbidden - security: credential handoff compiles to exactly one runtime medium without persisting values, and delegation binds the exact source run and scope - evidence: the complete AgentCLI evidence payload and verification envelope are available to the scheduler runtime for signed or provider-verified terminal evidence +- evidence: payload and provider-configuration hashes are explicit required + nullable fields, preventing structurally incomplete artifacts from becoming + valid after digest recalculation - inspect: added immutable artifacts, runtime events, provider sessions, and credential presentations to the scheduler inspection surface - conformance: added shared positive and negative v4 fixtures and exact digest diff --git a/docs/spec.md b/docs/spec.md index 5cc86b2..f5fde6b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -846,6 +846,15 @@ presentation, and evidence record MUST carry the exact artifact digest. Chain and retry work MUST also carry the exact source run ID and source artifact digest. +Before an update or adoption preserves runtime-owned overrides from an existing +v4 job, the control plane MUST retrieve that job's persisted artifact and +validate its digest, compiled job ID, effective task hash, and current scheduler +execution projection. Missing artifacts and projection drift fail closed, +including during dry-run planning. The OpenClaw Scheduler CLI adapter requests +this data with `jobs list --include-handoff-artifacts`; a v4-capable runtime MUST +return the canonical `handoff_artifact_payload` for each v4 row or reject the +request. + ### Runtime gates The runtime MUST report scheduler schema version 29 or newer and the following diff --git a/fixtures/handoff-v4/conformance.json b/fixtures/handoff-v4/conformance.json index 537d836..4aa80cf 100644 --- a/fixtures/handoff-v4/conformance.json +++ b/fixtures/handoff-v4/conformance.json @@ -62,7 +62,7 @@ ] }, "expected": { - "artifact_digest": "sha256:5bb0727036d352c87b6cad16577eafe4fdb35985ae79c73053474ceadd91f62d", + "artifact_digest": "sha256:ffc67fa20585fd9e6b94f8a12f3be45c6a826f948b577aeb2e28d82fdef5c9d4", "manifest_digest": "sha256:03afcc6f88e60a7e3358578425ee79ff91d31b855e5ae97a8dc20495ef9f2dc4", "effective_task_hash": "sha256:0d6b7a63ba7ff75811e5db236b84648dc9547d2ce3d22cdc42eec621c9ff98e4", "scheduler_job_binding_digest": "sha256:daac076adbaaac470e748f94d66297b3b018910867d817b78c3bc24768ffa2e6" @@ -123,6 +123,26 @@ ], "expected_error": "approval.required must be a boolean" }, + { + "name": "missing evidence payload hash", + "changes": [ + { + "op": "delete", + "path": ["evidence", "payload_hash"] + } + ], + "expected_error": "evidence.payload_hash is required" + }, + { + "name": "missing evidence provider config hash", + "changes": [ + { + "op": "delete", + "path": ["evidence", "provider_config_hash"] + } + ], + "expected_error": "evidence.provider_config_hash is required" + }, { "name": "cryptographic proof downgrade", "changes": [ diff --git a/src/apply.js b/src/apply.js index ad81663..bd0f82a 100644 --- a/src/apply.js +++ b/src/apply.js @@ -23,6 +23,7 @@ import { SCHEDULER_FIELD_VERSIONS, } from './scheduler-fields.js'; import { + assertValidSchedulerHandoffV4Job, rebindSchedulerHandoffV4Job, schedulerHandoffV4RebindableOverrides, } from './handoff/v4.js'; @@ -200,7 +201,7 @@ export function createSchedulerCliRunner(options = {}) { catch { return null; } }, listJobs() { - const payload = invoke(['jobs', 'list']); + const payload = invoke(['jobs', 'list', '--include-handoff-artifacts']); return Array.isArray(payload) ? payload : []; }, addJob(spec) { @@ -449,6 +450,10 @@ export async function applyManifestToScheduler( } for (const { job, action, existingJob } of plannedActions) { + if ((action === 'updated' || action === 'adopted') + && Number(existingJob?.handoff_version) === 4) { + assertValidSchedulerHandoffV4Job(existingJob); + } if ((action === 'updated' || action === 'adopted') && Number(existingJob?.handoff_version) === 4 && Number(job.handoff_version) !== 4) { diff --git a/src/authorization-proof/key-identity.js b/src/authorization-proof/key-identity.js index c34c592..1f7af6e 100644 --- a/src/authorization-proof/key-identity.js +++ b/src/authorization-proof/key-identity.js @@ -1,9 +1,14 @@ import { createHash, createPublicKey } from 'node:crypto'; export function publicKeyId(publicKey) { + if (publicKey && typeof publicKey === 'object' && 'kty' in publicKey && 'd' in publicKey) { + throw new Error('private JWK material is forbidden when deriving a public key ID'); + } const normalized = publicKey?.type === 'public' && typeof publicKey.export === 'function' ? publicKey - : createPublicKey(publicKey); + : publicKey && typeof publicKey === 'object' && !Array.isArray(publicKey) && 'kty' in publicKey + ? createPublicKey({ key: publicKey, format: 'jwk' }) + : createPublicKey(publicKey); const spki = normalized.export({ type: 'spki', format: 'der' }); const digest = createHash('sha256').update(spki).digest('hex'); return `spki-sha256:${digest}`; diff --git a/src/handoff/schema-v4.js b/src/handoff/schema-v4.js index e6b341e..df3ea87 100644 --- a/src/handoff/schema-v4.js +++ b/src/handoff/schema-v4.js @@ -206,6 +206,8 @@ export const HANDOFF_V4_JSON_SCHEMA = Object.freeze({ 'provider', 'methods', 'payload_bind', + 'payload_hash', + 'provider_config_hash', 'verify_required', 'retention', 'signed_or_provider_verified_required', diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 3320f6d..5af6b72 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -412,6 +412,8 @@ function evidenceBinding(binding) { provider: null, methods: [], payload_bind: [], + payload_hash: null, + provider_config_hash: null, verify_required: false, retention: null, signed_or_provider_verified_required: false, @@ -615,26 +617,13 @@ export function buildSchedulerHandoffV4Artifact({ return { payload, digest, effectiveTaskHash, canonical }; } -export function rebindSchedulerHandoffV4Job(job, overrides = {}) { +export function assertValidSchedulerHandoffV4Job(job) { if (!job || Number(job.handoff_version) !== HANDOFF_V4_VERSION) { - throw new TypeError('rebindSchedulerHandoffV4Job requires a handoff v4 job'); - } - if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { - throw new TypeError('rebindSchedulerHandoffV4Job overrides must be an object'); - } - const invalidFields = Object.keys(overrides) - .filter(field => !SCHEDULER_JOB_REBINDABLE_FIELDS.has(field)); - if (invalidFields.length > 0) { - throw Object.assign( - new TypeError( - `rebindSchedulerHandoffV4Job cannot override artifact-bound field(s): ${invalidFields.join(', ')}`, - ), - { code: 'HANDOFF_REBIND_OVERRIDE_INVALID', fields: invalidFields }, - ); + throw new TypeError('assertValidSchedulerHandoffV4Job requires a handoff v4 job'); } if (!SHA256_PATTERN.test(job.handoff_artifact_digest)) { throw Object.assign( - new Error('Cannot rebind handoff v4 job without a valid original artifact digest'), + new Error('Handoff v4 job does not have a valid original artifact digest'), { code: 'HANDOFF_ARTIFACT_INVALID', errors: ['handoff_artifact_digest must be a sha256 digest'], @@ -648,7 +637,7 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { if (!originalValidation.ok) { throw Object.assign( new Error( - `Cannot rebind invalid handoff v4 artifact: ${originalValidation.errors.join('; ')}`, + `Handoff v4 job has an invalid artifact: ${originalValidation.errors.join('; ')}`, ), { code: 'HANDOFF_ARTIFACT_INVALID', errors: originalValidation.errors }, ); @@ -665,7 +654,7 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { if (compiledIdentityErrors.length > 0) { throw Object.assign( new Error( - `Cannot rebind handoff v4 job whose compiled identity no longer matches its artifact: ${compiledIdentityErrors.join('; ')}`, + `Handoff v4 job compiled identity no longer matches its artifact: ${compiledIdentityErrors.join('; ')}`, ), { code: 'HANDOFF_ARTIFACT_INVALID', errors: compiledIdentityErrors }, ); @@ -673,15 +662,36 @@ export function rebindSchedulerHandoffV4Job(job, overrides = {}) { const currentBindingDigest = canonicalDigest(schedulerJobExecutionProjection(job)); if (originalValidation.payload.scheduler_job_binding.digest !== currentBindingDigest) { throw Object.assign( - new Error('Cannot rebind handoff v4 job whose execution projection no longer matches its artifact'), + new Error('Handoff v4 job execution projection no longer matches its artifact'), { code: 'HANDOFF_ARTIFACT_INVALID', errors: ['scheduler_job_binding.digest does not match the current job projection'], }, ); } + return originalValidation.payload; +} + +export function rebindSchedulerHandoffV4Job(job, overrides = {}) { + if (!job || Number(job.handoff_version) !== HANDOFF_V4_VERSION) { + throw new TypeError('rebindSchedulerHandoffV4Job requires a handoff v4 job'); + } + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { + throw new TypeError('rebindSchedulerHandoffV4Job overrides must be an object'); + } + const invalidFields = Object.keys(overrides) + .filter(field => !SCHEDULER_JOB_REBINDABLE_FIELDS.has(field)); + if (invalidFields.length > 0) { + throw Object.assign( + new TypeError( + `rebindSchedulerHandoffV4Job cannot override artifact-bound field(s): ${invalidFields.join(', ')}`, + ), + { code: 'HANDOFF_REBIND_OVERRIDE_INVALID', fields: invalidFields }, + ); + } + const originalPayload = assertValidSchedulerHandoffV4Job(job); const reboundJob = { ...job, ...overrides }; - const payload = structuredClone(originalValidation.payload); + const payload = structuredClone(originalPayload); if (!payload?.scheduler_job_binding || typeof payload.scheduler_job_binding !== 'object') { throw Object.assign(new Error('handoff v4 artifact is missing scheduler_job_binding'), { code: 'HANDOFF_ARTIFACT_INVALID', diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 7f0b885..2478627 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { generateKeyPairSync, createSign } from 'node:crypto'; +import { createPublicKey, generateKeyPairSync, createSign } from 'node:crypto'; import { closeSync, constants as fsConstants, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -21,7 +21,7 @@ import { import { validateManifest } from '../src/validate.js'; import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; import { compileManifestToStandalone } from '../src/compiler/standalone.js'; -import { applyManifestToScheduler, resolveSchedulerInvocation, shellCommandInvocation, SCHEDULER_FIELD_VERSIONS, SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02 } from '../src/apply.js'; +import { applyManifestToScheduler, createSchedulerCliRunner, resolveSchedulerInvocation, shellCommandInvocation, SCHEDULER_FIELD_VERSIONS, SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02 } from '../src/apply.js'; import { HANDOFF_V4_REQUIRED_FEATURES, HANDOFF_V4_RUNTIME_CONTRACT, @@ -91,8 +91,8 @@ const testKeyPair = generateKeyPairSync('rsa', { privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); -function signedJwt(payload) { - const header = encodeBase64UrlJson({ alg: 'RS256', typ: 'JWT' }); +function signedJwt(payload, headerFields = {}) { + const header = encodeBase64UrlJson({ alg: 'RS256', typ: 'JWT', ...headerFields }); const body = encodeBase64UrlJson(payload); const signingInput = `${header}.${body}`; const signer = createSign('RSA-SHA256'); @@ -977,6 +977,22 @@ test('resolveSchedulerInvocation prefers npm prefix when present', () => { assert.deepEqual(invocation.prefixArgs, ['exec', '--prefix', '/tmp/scheduler-prefix', 'openclaw-scheduler', '--']); }); +test('scheduler CLI runner requests persisted v4 artifacts when listing jobs', () => { + const calls = []; + const scheduler = createSchedulerCliRunner({ + schedulerBin: 'openclaw-scheduler', + runner(command, args) { + calls.push({ command, args }); + return { status: 0, stdout: '[]', stderr: '' }; + }, + }); + assert.deepEqual(scheduler.listJobs(), []); + assert.deepEqual(calls, [{ + command: 'openclaw-scheduler', + args: ['--json', 'jobs', 'list', '--include-handoff-artifacts'], + }]); +}); + test('applyManifestToScheduler plans and executes scheduler upserts', async () => { const compiled = compileManifestToScheduler(exampleManifest); const existing = [compiled.jobs[0]]; @@ -6713,6 +6729,48 @@ test('jwt verifier validateProfile rejects empty string jwks_uri', async () => { assert.ok(fieldNames.includes('jwks_uri')); }); +test('JWT verification resolves raw JWKS keys and derives their stable public key IDs', async (t) => { + const { getVerifier } = await import('../src/authorization-proof/index.js'); + const { resolveJwtVerificationContext } = await import('../src/authorization-proof/jwt.js'); + const verifier = getVerifier('jwt'); + const publicJwk = { + ...createPublicKey(testKeyPair.publicKey).export({ format: 'jwk' }), + alg: 'RS256', + kid: 'agentcli-jwks-key', + use: 'sig', + }; + const manifest = { version: '0.2', workflows: [] }; + const token = signedJwt({ + sub: 'jwks-subject', + exp: Math.floor(Date.now() / 1000) + 300, + manifest_digest: canonicalDigest(manifest), + }, { kid: publicJwk.kid }); + const profile = { + jwks_uri: 'https://issuer.example.com/.well-known/agentcli-final-review-jwks.json', + verify: { required: true }, + }; + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = async () => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'max-age=0' }, + json: async () => ({ keys: [publicJwk] }), + }); + + const context = await resolveJwtVerificationContext(token, profile, { manifest }); + assert.equal(context.trustedKeySource, 'jwks_uri'); + assert.equal(context.trustedKeyError, null); + assert.match(context.trustedKeyId, /^spki-sha256:[0-9a-f]{64}$/); + const result = verifier.verifyProof(token, profile, context); + assert.equal(result.verified, true, result.reason); + assert.equal(result.signature_verified, true); + assert.equal(result.key_id, context.trustedKeyId); +}); + // -- Signed JWT verification through the verifier -- test('jwt verifier verifies signed token when trustedKey is provided in context', async () => { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 4c3bcfb..3e32188 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -18,11 +18,12 @@ import { computeEffectiveTaskHash, } from '../src/compiler/shared.js'; import { + assertValidSchedulerHandoffV4Job, HANDOFF_V4_EXECUTION_BINDING_VERSION, rebindSchedulerHandoffV4Job, validateSchedulerHandoffV4Artifact, } from '../src/handoff/v4.js'; -import { canonicalStringify } from '../src/canonical.js'; +import { canonicalDigest, canonicalStringify } from '../src/canonical.js'; import { expandManifestShorthands } from '../src/shorthand.js'; import { jwtVerifier } from '../src/authorization-proof/jwt.js'; import { @@ -318,6 +319,26 @@ test('handoff v4 schema accepts every valid cleanup policy and rejects unknown p } }); +test('handoff v4 schema requires explicit nullable evidence hashes', () => { + const payload = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload; + + assert.equal(payload.evidence.payload_hash, null); + assert.equal(payload.evidence.provider_config_hash, null); + for (const field of ['payload_hash', 'provider_config_hash']) { + const missing = structuredClone(payload); + delete missing.evidence[field]; + const validation = validateSchedulerHandoffV4Artifact(missing, { + expectedDigest: canonicalDigest(missing), + }); + assert.equal(validation.ok, false, field); + assert.match(validation.errors.join('; '), new RegExp(`evidence\\.${field} is required`)); + } +}); + test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { const fixture = sharedConformanceFixture; const [job] = compileManifestToScheduler(fixture.manifest, { @@ -950,7 +971,10 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad assert.notEqual(legacyV4.id, compiled.id); assert.equal(legacyV4.name, compiled.name); const downgradedAdopter = statefulRunner([ - schedulerCreateSpec(legacyV4, { originOverride: 'legacy-origin', fieldVersion: '4' }), + schedulerCreateSpec( + rebindSchedulerHandoffV4Job(legacyV4, { origin: 'legacy-origin' }), + { fieldVersion: '4' }, + ), ], { handoffVersion: '3', features: { ...V4_FEATURES, immutable_runtime_events: false }, @@ -1007,6 +1031,43 @@ test('v4 updates preserve the stored origin and reject runtime-contract downgrad ); }); +test('v4 apply rejects tampered stored jobs before preserving runtime overrides', async () => { + const compiled = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const validStored = schedulerCreateSpec( + rebindSchedulerHandoffV4Job(compiled, { + resource_pool: 'trusted-pool', + watchdog_check_cmd: '/usr/bin/trusted-health-check', + }), + { fieldVersion: '4' }, + ); + assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(validStored)); + + for (const { field, value, dryRun } of [ + { field: 'resource_pool', value: 'tampered-pool', dryRun: false }, + { field: 'watchdog_check_cmd', value: '/usr/bin/tampered-health-check', dryRun: true }, + ]) { + const tampered = structuredClone(validStored); + tampered[field] = value; + const scheduler = statefulRunner([tampered]); + await assert.rejects( + applyManifestToScheduler(manifest(), { + runner: scheduler, + dryRun, + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && /execution projection no longer matches/.test(error.message), + `${field} must be checked before the scheduler row is rebound`, + ); + assert.deepEqual(scheduler.history, []); + } +}); + test('handoff v4 JWT requires artifact binding, replay claim, and revocation check', () => { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const artifactDigest = `sha256:${'a'.repeat(64)}`; From a7e81942c751bc7846a3ca194f10650939e8c39a Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 03:20:47 -0400 Subject: [PATCH 23/29] Pin final handoff v4 Scheduler revision --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee9dd13..295927c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d + OPENCLAW_SCHEDULER_REF: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d + ref: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5256c31..62abf67 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d + OPENCLAW_SCHEDULER_REF: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: 1c62d5a4a1d43a0f47d3949c0af30c2fdbfd732d + ref: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" From 4c9a7fd35e9a5072cfdfe1909c54f97dbe35628f Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 03:48:14 -0400 Subject: [PATCH 24/29] Bind persisted v4 evidence hashes --- CHANGELOG.md | 4 +-- docs/spec.md | 8 +++++ src/compiler/openclaw-scheduler.js | 16 +++++++-- test/handoff-v4.test.js | 53 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaab99e..3aca12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,8 +35,8 @@ are available to the scheduler runtime for signed or provider-verified terminal evidence - evidence: payload and provider-configuration hashes are explicit required - nullable fields, preventing structurally incomplete artifacts from becoming - valid after digest recalculation + nullable fields and are retained in the sanitized scheduler declaration for + exact artifact parity while raw provider configuration remains absent - inspect: added immutable artifacts, runtime events, provider sessions, and credential presentations to the scheduler inspection surface - conformance: added shared positive and negative v4 fixtures and exact digest diff --git a/docs/spec.md b/docs/spec.md index f5fde6b..de894aa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -831,6 +831,14 @@ Credential and proof sources are represented by declarative locations and hashes. A consumer MUST recompute and compare the artifact, scheduler binding, and effective task digests before execution. +When evidence is declared, the persisted scheduler evidence declaration MUST +retain canonical `payload_hash` and `provider_config_hash` values that exactly +match the artifact evidence binding. Raw `provider_config` remains null. An +absent evidence declaration requires both artifact hashes to be null, while a +declared payload or provider configuration requires its corresponding hash to +be non-null. A consumer MUST reject any semantic or hash mismatch between the +persisted declaration and the artifact. + ### Persistence and replacement Artifacts are immutable and content-addressed. Create, update, adoption, diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 7e30ee0..2159d34 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -199,10 +199,20 @@ function sanitizeAuthorizationProfile(profile) { }; } -function sanitizeEvidenceDeclaration(evidence) { +function sanitizeEvidenceDeclaration(evidence, { includeHashes = false } = {}) { if (!evidence) return null; return { ...evidence, + ...(includeHashes + ? { + payload_hash: evidence.payload == null + ? null + : canonicalDigest(evidence.payload), + provider_config_hash: evidence.provider_config == null + ? null + : canonicalDigest(evidence.provider_config), + } + : {}), provider_config: null, }; } @@ -329,7 +339,9 @@ export function compileManifestToScheduler( const persistedIdentity = sanitizeIdentityDeclaration(resolvedIdentity); const persistedAuthorizationProof = sanitizeAuthorizationProofDeclaration(resolvedAuthorizationProof); const persistedAuthorization = sanitizeAuthorizationDeclaration(resolvedAuthorization); - const persistedEvidence = sanitizeEvidenceDeclaration(resolvedEvidence); + const persistedEvidence = sanitizeEvidenceDeclaration(resolvedEvidence, { + includeHashes: String(schedulerHandoffVersion) === '4', + }); const deliveryOptOutReason = schedulerDeliveryOptOutReason(plan); const job = { id: plan.id, diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 3e32188..f2ab43f 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -339,6 +339,59 @@ test('handoff v4 schema requires explicit nullable evidence hashes', () => { } }); +test('handoff v4 persists audit-safe evidence hashes with the scheduler declaration', () => { + const evidenceManifest = manifest(); + evidenceManifest.evidence_profiles = [{ + id: 'signed-evidence', + provider: 'ssh', + provider_config: { + key_path: '/private/evidence-key', + principal: 'agentcli', + allowed_signers_path: '/private/allowed-signers', + }, + payload: { + bind: ['execution_id', 'command', 'result'], + context: { policy_version: true }, + format: 'canonical-json', + }, + verify: { required: true }, + }]; + evidenceManifest.workflows[0].tasks[0].evidence = { ref: 'signed-evidence' }; + + const job = compileManifestToScheduler(evidenceManifest, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + + assert.equal(job.evidence.provider_config, null); + assert.equal(job.evidence.payload_hash, canonicalDigest(job.evidence.payload)); + assert.equal( + job.evidence.provider_config_hash, + canonicalDigest(evidenceManifest.evidence_profiles[0].provider_config), + ); + assert.equal( + job.evidence.payload_hash, + job.handoff_artifact_payload.evidence.payload_hash, + ); + assert.equal( + job.evidence.provider_config_hash, + job.handoff_artifact_payload.evidence.provider_config_hash, + ); + assert.equal(JSON.stringify(job.evidence).includes('/private/evidence-key'), false); + assert.equal(JSON.stringify(job.evidence).includes('/private/allowed-signers'), false); + + assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(job)); + + const legacyJob = compileManifestToScheduler(evidenceManifest, { + schedulerHandoffVersion: '3', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + assert.equal(Object.hasOwn(legacyJob.evidence, 'payload_hash'), false); + assert.equal(Object.hasOwn(legacyJob.evidence, 'provider_config_hash'), false); +}); + test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { const fixture = sharedConformanceFixture; const [job] = compileManifestToScheduler(fixture.manifest, { From ec52b607db054e2910dfabc9c2140746f9caf4d3 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 04:11:26 -0400 Subject: [PATCH 25/29] Gate v4 scheduler artifact discovery --- CHANGELOG.md | 6 ++++-- docs/spec.md | 7 ++++--- src/apply.js | 20 +++++++++++++------- src/cli.js | 2 +- src/describe.js | 7 ++++++- test/agentcli.test.js | 17 ++++++++++++----- test/cli-discovery-v4.test.js | 23 +++++++++++++++++++++++ test/handoff-v4.test.js | 34 +++++++++++++++++++++++++++++++--- 8 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 test/cli-discovery-v4.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aca12b..c5a08a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,8 @@ environment while host variables are merged only for scheduler process spawn - scheduler: update and adoption validate the persisted v4 artifact, compiled identity, and current execution projection before preserving runtime - overrides; the CLI adapter requests opt-in artifact hydration for this check + overrides; the CLI adapter requests opt-in artifact hydration only after + exact v4 negotiation, while legacy-safe listing remains flag-free - security: JWT, detached-signature, and certificate proofs bind the exact v4 artifact, replay identifier, validity, key, and revocation result - security: v4 proofs reject inverted validity intervals, bind asserted key IDs @@ -38,7 +39,8 @@ nullable fields and are retained in the sanitized scheduler declaration for exact artifact parity while raw provider configuration remains absent - inspect: added immutable artifacts, runtime events, provider sessions, and - credential presentations to the scheduler inspection surface + credential presentations to the scheduler inspection surface, with every + entity enumerated in JSON help and structured command discovery - conformance: added shared positive and negative v4 fixtures and exact digest parity tests with OpenClaw Scheduler - examples: added a public scheduler manifest for exercising negotiated v4 diff --git a/docs/spec.md b/docs/spec.md index de894aa..d9827f3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -859,9 +859,10 @@ v4 job, the control plane MUST retrieve that job's persisted artifact and validate its digest, compiled job ID, effective task hash, and current scheduler execution projection. Missing artifacts and projection drift fail closed, including during dry-run planning. The OpenClaw Scheduler CLI adapter requests -this data with `jobs list --include-handoff-artifacts`; a v4-capable runtime MUST -return the canonical `handoff_artifact_payload` for each v4 row or reject the -request. +this data with `jobs list --include-handoff-artifacts` only after the exact v4 +runtime contract is negotiated. Default and legacy-safe job listing MUST use +`jobs list` without the v4-only flag. A v4-capable runtime MUST return the +canonical `handoff_artifact_payload` for each v4 row or reject the request. ### Runtime gates diff --git a/src/apply.js b/src/apply.js index bd0f82a..ff4150b 100644 --- a/src/apply.js +++ b/src/apply.js @@ -200,8 +200,12 @@ export function createSchedulerCliRunner(options = {}) { try { return invoke(['capabilities']); } catch { return null; } }, - listJobs() { - const payload = invoke(['jobs', 'list', '--include-handoff-artifacts']); + listJobs({ includeHandoffArtifacts = false } = {}) { + const args = ['jobs', 'list']; + if (includeHandoffArtifacts === true) { + args.push('--include-handoff-artifacts'); + } + const payload = invoke(args); return Array.isArray(payload) ? payload : []; }, addJob(spec) { @@ -379,7 +383,9 @@ export async function applyManifestToScheduler( } } - const existingJobs = schedulerRunner.listJobs(); + const existingJobs = schedulerRunner.listJobs({ + includeHandoffArtifacts: handoffVersion === '4', + }); const existingById = new Map(existingJobs.map(job => [job.id, job])); const existingByName = new Map(); for (const job of existingJobs) { @@ -450,10 +456,6 @@ export async function applyManifestToScheduler( } for (const { job, action, existingJob } of plannedActions) { - if ((action === 'updated' || action === 'adopted') - && Number(existingJob?.handoff_version) === 4) { - assertValidSchedulerHandoffV4Job(existingJob); - } if ((action === 'updated' || action === 'adopted') && Number(existingJob?.handoff_version) === 4 && Number(job.handoff_version) !== 4) { @@ -466,6 +468,10 @@ export async function applyManifestToScheduler( { code: 'unsupported_capability' } ); } + if ((action === 'updated' || action === 'adopted') + && Number(existingJob?.handoff_version) === 4) { + assertValidSchedulerHandoffV4Job(existingJob); + } if (!dryRun && action === 'adopted' && typeof schedulerRunner.deleteJob !== 'function') { throw Object.assign( new Error('Scheduler runner does not support deleteJob(); cannot adopt legacy rows by name'), diff --git a/src/cli.js b/src/cli.js index 58ddbe2..56d650a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -58,7 +58,7 @@ Commands: [--signer ssh|none] [--signing-key path] [--evidence-provider name] [--instance-id id] [--require-evidence] [--require-authorization] [--identity-debug] [--presentation-debug] - inspect [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson] + inspect <${listInspectableEntities().join('|')}> [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson] audit [--limit n] approve [--workflow id] [--by principal] [--reason text] [--ttl-s seconds] [--timeout ms] [--instance-id id] diff --git a/src/describe.js b/src/describe.js index 954b806..9476342 100644 --- a/src/describe.js +++ b/src/describe.js @@ -1,5 +1,8 @@ import { listTargets } from './targets.js'; import { MANIFEST_VERSION } from './schema.js'; +import { listInspectableEntities } from './inspect.js'; + +const INSPECTABLE_ENTITIES = Object.freeze(listInspectableEntities()); export const COMMAND_DESCRIPTIONS = [ { @@ -32,7 +35,9 @@ export const COMMAND_DESCRIPTIONS = [ }, { command: 'inspect', - summary: 'Read scheduler runtime state with field masks, sanitization, and NDJSON output.' + summary: 'Read scheduler runtime state with field masks, sanitization, and NDJSON output.', + usage: `inspect <${INSPECTABLE_ENTITIES.join('|')}> [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson]`, + entities: INSPECTABLE_ENTITIES, }, { command: 'targets', diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 2478627..487f3ec 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -977,7 +977,7 @@ test('resolveSchedulerInvocation prefers npm prefix when present', () => { assert.deepEqual(invocation.prefixArgs, ['exec', '--prefix', '/tmp/scheduler-prefix', 'openclaw-scheduler', '--']); }); -test('scheduler CLI runner requests persisted v4 artifacts when listing jobs', () => { +test('scheduler CLI runner requests persisted v4 artifacts only when explicitly enabled', () => { const calls = []; const scheduler = createSchedulerCliRunner({ schedulerBin: 'openclaw-scheduler', @@ -987,10 +987,17 @@ test('scheduler CLI runner requests persisted v4 artifacts when listing jobs', ( }, }); assert.deepEqual(scheduler.listJobs(), []); - assert.deepEqual(calls, [{ - command: 'openclaw-scheduler', - args: ['--json', 'jobs', 'list', '--include-handoff-artifacts'], - }]); + assert.deepEqual(scheduler.listJobs({ includeHandoffArtifacts: true }), []); + assert.deepEqual(calls, [ + { + command: 'openclaw-scheduler', + args: ['--json', 'jobs', 'list'], + }, + { + command: 'openclaw-scheduler', + args: ['--json', 'jobs', 'list', '--include-handoff-artifacts'], + }, + ]); }); test('applyManifestToScheduler plans and executes scheduler upserts', async () => { diff --git a/test/cli-discovery-v4.test.js b/test/cli-discovery-v4.test.js new file mode 100644 index 0000000..12a603c --- /dev/null +++ b/test/cli-discovery-v4.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { runCli } from '../src/cli.js'; +import { listInspectableEntities } from '../src/inspect.js'; + +test('JSON help advertises every inspect entity from the runtime registry', async () => { + const entities = listInspectableEntities(); + const result = JSON.parse(await runCli(['--json'])); + + assert.equal(result.ok, true); + assert.equal(result.usage.includes(`inspect <${entities.join('|')}>`), true); +}); + +test('command discovery exposes every inspect entity as structured metadata', async () => { + const entities = listInspectableEntities(); + const result = JSON.parse(await runCli(['describe', 'commands', '--json'])); + const inspect = result.description.items.find(item => item.command === 'inspect'); + + assert.ok(inspect); + assert.deepEqual(inspect.entities, entities); + assert.equal(inspect.usage.startsWith(`inspect <${entities.join('|')}>`), true); +}); diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index f2ab43f..9d5ffe4 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -105,9 +105,11 @@ function runner({ features = V4_FEATURES, } = {}) { const added = []; + const listOptions = []; return { invocation: { label: 'mock-scheduler' }, added, + listOptions, queryCapabilities() { return { scheduler_version: 'test', @@ -117,7 +119,10 @@ function runner({ features, }; }, - listJobs() { return []; }, + listJobs(options = {}) { + listOptions.push(structuredClone(options)); + return []; + }, addJob(job) { added.push(job); return { ok: true, job }; @@ -147,8 +152,14 @@ function statefulRunner(initialJobs = [], { features, }; }, - listJobs() { - return [...jobs.values()].map(job => structuredClone(job)); + listJobs({ includeHandoffArtifacts = false } = {}) { + return [...jobs.values()].map(job => { + const listed = structuredClone(job); + if (includeHandoffArtifacts !== true) { + delete listed.handoff_artifact_payload; + } + return listed; + }); }, addJob(spec) { assert.equal(typeof spec.id, 'string'); @@ -781,6 +792,7 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { }); assert.equal(v4.handoff.field_version, '4'); assert.equal(v4Runner.added[0].handoff_version, 4); + assert.deepEqual(v4Runner.listOptions, [{ includeHandoffArtifacts: true }]); const missingGateRunner = runner({ features: { ...V4_FEATURES, immutable_runtime_events: false }, @@ -791,6 +803,7 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { }); assert.equal(fallback.handoff.field_version, '3'); assert.equal('handoff_version' in missingGateRunner.added[0], false); + assert.deepEqual(missingGateRunner.listOptions, [{ includeHandoffArtifacts: false }]); const omittedGateFeatures = { ...V4_FEATURES }; delete omittedGateFeatures.immutable_runtime_events; @@ -818,6 +831,21 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { env: { PATH: '/usr/bin' }, }); assert.equal(oldRuntime.handoff.field_version, '3'); + assert.deepEqual(oldRunner.listOptions, [{ includeHandoffArtifacts: false }]); + + const unavailableCapabilitiesRunner = runner({ handoffVersion: '3' }); + unavailableCapabilitiesRunner.queryCapabilities = () => null; + const unavailableCapabilitiesManifest = manifest(); + delete unavailableCapabilitiesManifest.workflows[0].tasks[0].output; + const unavailableCapabilities = await applyManifestToScheduler(unavailableCapabilitiesManifest, { + runner: unavailableCapabilitiesRunner, + env: { PATH: '/usr/bin' }, + }); + assert.equal(unavailableCapabilities.handoff.field_version, '1'); + assert.deepEqual( + unavailableCapabilitiesRunner.listOptions, + [{ includeHandoffArtifacts: false }], + ); for (const [name, options] of [ ['missing contract', { handoffContract: null }], From 3d710f31dff2c27986958a036d2d18f974c16f66 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 04:37:52 -0400 Subject: [PATCH 26/29] Harden remaining handoff v4 bindings --- CHANGELOG.md | 10 ++- docs/spec.md | 9 ++- src/apply.js | 22 +++-- src/authorization-proof/jwt.js | 21 +++-- src/capabilities.js | 12 ++- src/compiler/openclaw-scheduler.js | 6 +- src/evidence/payload.js | 38 +++++++++ src/handoff/v4.js | 80 ++++++++++++++++++ src/runtime/openclaw-scheduler.js | 50 +++++++----- test/agentcli.test.js | 66 +++++++++++++++ test/handoff-v4.test.js | 125 +++++++++++++++++++++++++++- test/proof-evidence.test.js | 126 +++++++++++++++++++++-------- 12 files changed, 490 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a08a8..3b11d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ invalid nested types, and unknown properties before semantic verification - negotiation: v4 now requires scheduler schema 29 or newer plus an exact live artifact, canonicalization, digest, and binding contract; partial or - mismatched runtimes fall back to v3 + mismatched runtimes, including contracts with extra keys, fall back to v3 - negotiation: every apply, including basic v0.1 manifests, probes the live runtime so v4 coverage is complete; static fallback enforcement features are disabled until explicitly advertised @@ -19,6 +19,9 @@ - scheduler: adoption rebinds the artifact after the final origin is selected, and apply preserves direct-compile hashes for the caller's explicit compile environment while host variables are merged only for scheduler process spawn +- scheduler: direct main and isolated delegation negotiates v4 before compiling + and binds its one-off lifecycle before artifact construction; v4 JSON columns + use the same undefined-to-null canonicalization as their artifact binding - scheduler: update and adoption validate the persisted v4 artifact, compiled identity, and current execution projection before preserving runtime overrides; the CLI adapter requests opt-in artifact hydration only after @@ -28,6 +31,8 @@ - security: v4 proofs reject inverted validity intervals, bind asserted key IDs to the key or certificate that actually verified, and require an explicit not-revoked result from the runtime checker +- security: JWT artifact claims and trusted context now require the exact + lowercase `sha256:<64 hex>` representation before v4 binding can succeed - security: JWKS-provided raw JWK objects are normalized before stable public key identity derivation, while private JWK material remains forbidden - security: credential handoff compiles to exactly one runtime medium without @@ -38,6 +43,9 @@ - evidence: payload and provider-configuration hashes are explicit required nullable fields and are retained in the sanitized scheduler declaration for exact artifact parity while raw provider configuration remains absent +- evidence: persisted declarations must match artifact evidence semantics and + hashes; explicitly v4 evidence records require a canonical artifact digest, + and child records require complete source-run lineage - inspect: added immutable artifacts, runtime events, provider sessions, and credential presentations to the scheduler inspection surface, with every entity enumerated in JSON help and structured command discovery diff --git a/docs/spec.md b/docs/spec.md index d9827f3..7286724 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -883,8 +883,9 @@ exact `handoff_contract` before AgentCLI emits v4: ``` Every field is required and an exact match. Missing, older, or newer contract -metadata is incompatible with v4 and MUST fall back to handoff v3 or reject -apply. A handoff version number alone is not sufficient negotiation. +metadata, including any additional contract key, is incompatible with v4 and +MUST fall back to handoff v3 or reject apply. A handoff version number alone is +not sufficient negotiation. The runtime MUST also advertise all of these boolean features before AgentCLI emits v4: `authorization_proof_verification`, `handoff_v4_artifact`, @@ -940,6 +941,10 @@ properties before digest or semantic verification. AgentCLI probes live scheduler capabilities for every apply, including a basic v0.1 manifest, so immutable artifacts cover all jobs when v4 is available. An unavailable capability command leaves security-relevant runtime features false. +Direct `main` and `isolated` delegation also probes the live runtime before +compilation, then includes its one-off `delete_after_run` lifecycle value before +building a v4 artifact. A dry-run remains a static v3 preview and performs no +scheduler capability query or write. During adoption, any final persisted origin override is rebound into the scheduler job binding before create. The caller's compile environment is used unchanged for execution hashes so direct compile and apply remain deterministic. diff --git a/src/apply.js b/src/apply.js index ff4150b..8e32ebf 100644 --- a/src/apply.js +++ b/src/apply.js @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { compileManifestToScheduler } from './compiler/openclaw-scheduler.js'; import { resolveValueFrom } from './command.js'; -import { canonicalDigest } from './canonical.js'; +import { canonicalDigest, canonicalStringify } from './canonical.js'; import { mergeAuthorizationProofProfile, normalizedTaskPlan, @@ -114,7 +114,10 @@ const JSON_BLOB_FIELDS = new Set([ 'handoff_artifact_payload', ]); -function projectSchedulerSpec(job, fields, { includeNulls = false } = {}) { +function projectSchedulerSpec(job, fields, { + includeNulls = false, + canonicalJsonBlobs = false, +} = {}) { const spec = {}; for (const field of fields) { if (!(field in job)) continue; @@ -123,7 +126,7 @@ function projectSchedulerSpec(job, fields, { includeNulls = false } = {}) { if (value === null && !includeNulls) continue; // Scheduler expects JSON blob fields as stringified JSON, not raw objects if (value !== null && typeof value === 'object' && JSON_BLOB_FIELDS.has(field)) { - value = JSON.stringify(value); + value = canonicalJsonBlobs ? canonicalStringify(value) : JSON.stringify(value); } spec[field] = value; } @@ -133,7 +136,10 @@ function projectSchedulerSpec(job, fields, { includeNulls = false } = {}) { export function schedulerCreateSpec(job, { originOverride, fieldVersion = '1' } = {}) { const fields = SCHEDULER_FIELD_VERSIONS[fieldVersion] || SCHEDULER_FIELDS_V1; const { source, ...spec } = job; - const projected = projectSchedulerSpec(spec, fields, { includeNulls: false }); + const projected = projectSchedulerSpec(spec, fields, { + includeNulls: false, + canonicalJsonBlobs: String(fieldVersion) === '4', + }); if (originOverride != null) { projected.origin = originOverride; } @@ -169,7 +175,10 @@ function schedulerUpdateSpec(job, { fieldVersion = '1' } = {}) { const fields = (SCHEDULER_FIELD_VERSIONS[fieldVersion] || SCHEDULER_FIELDS_V1) .filter(f => f !== 'id' && f !== 'origin'); const { source, ...spec } = job; - return projectSchedulerSpec(spec, fields, { includeNulls: true }); + return projectSchedulerSpec(spec, fields, { + includeNulls: true, + canonicalJsonBlobs: String(fieldVersion) === '4', + }); } function duplicateNames(items) { @@ -200,7 +209,8 @@ export function createSchedulerCliRunner(options = {}) { try { return invoke(['capabilities']); } catch { return null; } }, - listJobs({ includeHandoffArtifacts = false } = {}) { + listJobs(options = {}) { + const includeHandoffArtifacts = options?.includeHandoffArtifacts === true; const args = ['jobs', 'list']; if (includeHandoffArtifacts === true) { args.push('--include-handoff-artifacts'); diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index 8cb53a9..a69aebf 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -32,6 +32,7 @@ const AUDIT_SAFE_CLAIMS = [ ]; const jwksCache = new Map(); const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; +const CANONICAL_SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; // -- JWT Helpers -- @@ -86,10 +87,6 @@ function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } -function normalizeDigest(value) { - return typeof value === 'string' ? value.replace(/^sha256:/, '') : null; -} - function normalizeAudience(value) { if (Array.isArray(value)) return value; if (value === undefined || value === null) return []; @@ -780,11 +777,17 @@ const jwtVerifier = { if (manifestBindingRequired) { if (typeof context.manifestDigest !== 'string' || context.manifestDigest.length === 0) { manifestBindingReason = 'trusted manifest digest is required for JWT authorization proof verification'; + } else if (v4Required && !CANONICAL_SHA256_PATTERN.test(context.manifestDigest)) { + manifestBindingReason = 'trusted manifest digest must be a lowercase sha256 digest'; } else if (typeof payload.manifest_digest !== 'string') { manifestBindingReason = 'JWT is missing required manifest_digest claim'; + } else if (v4Required && !CANONICAL_SHA256_PATTERN.test(payload.manifest_digest)) { + manifestBindingReason = 'JWT manifest_digest claim must be a lowercase sha256 digest'; } else if ( - payload.manifest_digest.replace(/^sha256:/, '') !== - context.manifestDigest.replace(/^sha256:/, '') + v4Required + ? payload.manifest_digest !== context.manifestDigest + : payload.manifest_digest.replace(/^sha256:/, '') !== + context.manifestDigest.replace(/^sha256:/, '') ) { manifestBindingReason = 'JWT manifest_digest claim does not match the canonical manifest'; } else { @@ -798,9 +801,13 @@ const jwtVerifier = { const claimedArtifact = payload.handoff_artifact_digest ?? payload.artifact_digest; if (typeof artifactDigest !== 'string' || artifactDigest.length === 0) { artifactBindingReason = 'trusted handoff artifact digest is required'; + } else if (!CANONICAL_SHA256_PATTERN.test(artifactDigest)) { + artifactBindingReason = 'trusted handoff artifact digest must be a lowercase sha256 digest'; } else if (typeof claimedArtifact !== 'string') { artifactBindingReason = 'JWT is missing required handoff artifact digest claim'; - } else if (normalizeDigest(claimedArtifact) !== normalizeDigest(artifactDigest)) { + } else if (!CANONICAL_SHA256_PATTERN.test(claimedArtifact)) { + artifactBindingReason = 'JWT handoff artifact digest claim must be a lowercase sha256 digest'; + } else if (claimedArtifact !== artifactDigest) { artifactBindingReason = 'JWT handoff artifact digest does not match the compiled artifact'; } else { artifactBound = true; diff --git a/src/capabilities.js b/src/capabilities.js index c40a80a..6dfe0d8 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -32,10 +32,14 @@ export const HANDOFF_V4_RUNTIME_CONTRACT = Object.freeze({ }); function supportsExactV4Contract(contract) { - return contract != null - && typeof contract === 'object' - && Object.entries(HANDOFF_V4_RUNTIME_CONTRACT) - .every(([key, expected]) => contract[key] === expected); + if (contract == null || typeof contract !== 'object' || Array.isArray(contract)) { + return false; + } + const expectedKeys = Object.keys(HANDOFF_V4_RUNTIME_CONTRACT).sort(); + const actualKeys = Object.keys(contract).sort(); + return actualKeys.length === expectedKeys.length + && actualKeys.every((key, index) => key === expectedKeys[index]) + && expectedKeys.every(key => contract[key] === HANDOFF_V4_RUNTIME_CONTRACT[key]); } export function supportsSchedulerHandoffV4(effectiveCapabilities = {}) { diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 2159d34..7900b42 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -278,6 +278,7 @@ export function compileManifestToScheduler( schedulerHandoffVersion = '3', cwd = process.cwd(), env = process.env, + oneOffSource = null, } = {}, ) { if (!['1', '2', '3', '4'].includes(String(schedulerHandoffVersion))) { @@ -343,6 +344,9 @@ export function compileManifestToScheduler( includeHashes: String(schedulerHandoffVersion) === '4', }); const deliveryOptOutReason = schedulerDeliveryOptOutReason(plan); + const dispatchesOneOff = oneOffSource != null + && oneOffSource.workflow_id === workflow.id + && oneOffSource.task_id === task.id; const job = { id: plan.id, source: plan.source, @@ -425,7 +429,7 @@ export function compileManifestToScheduler( verify_timeout_s: plan.verify?.timeout_seconds ?? null, verify_on_failure: plan.verify?.on_failure ?? null, - delete_after_run: plan.delete_after_run ? 1 : 0 + delete_after_run: dispatchesOneOff || plan.delete_after_run ? 1 : 0 }; if (String(schedulerHandoffVersion) === '4') { diff --git a/src/evidence/payload.js b/src/evidence/payload.js index 41a6721..12288a6 100644 --- a/src/evidence/payload.js +++ b/src/evidence/payload.js @@ -15,6 +15,7 @@ import { export const EVIDENCE_PAYLOAD_SCHEMA = 'agentcli.evidence.payload'; export const EVIDENCE_PAYLOAD_VERSION = 1; const SENSITIVE_FIELD = /(?:^|_)(?:access_token|refresh_token|id_token|token|secret|password|private_key|credentials?|cookie|client_assertion|api_key|authorization_header)(?:_|$)/i; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; function redactSensitiveEvidence(value, key = '') { if (SENSITIVE_FIELD.test(key)) { @@ -318,10 +319,47 @@ export function validateEvidenceRecordBinding(payload, record) { if (payload.bindings?.effective_task_hash !== record.effective_task_hash) { errors.push('effective task hash does not match the audit record'); } + const payloadArtifactDigest = payload.bindings?.handoff_artifact_digest ?? null; + const recordArtifactDigest = record.handoff_artifact_digest ?? null; + const v4BindingRequired = Number(record.handoff_version ?? record.handoff?.version) === 4 + || payloadArtifactDigest != null + || recordArtifactDigest != null; + if (v4BindingRequired) { + if (!SHA256_PATTERN.test(recordArtifactDigest)) { + errors.push('audit record handoff artifact digest must be a lowercase SHA-256 digest'); + } + if (!SHA256_PATTERN.test(payloadArtifactDigest)) { + errors.push('signed evidence handoff artifact digest must be a lowercase SHA-256 digest'); + } + } if ((payload.bindings?.handoff_artifact_digest ?? null) !== (record.handoff_artifact_digest ?? null)) { errors.push('handoff artifact digest does not match the audit record'); } + const sourceRunRequired = record.source_run_required === true + || record.child_run === true + || record.is_child_run === true + || record.parent_id != null + || record.parent_run_id != null + || payload.bindings?.source_run_id != null + || payload.bindings?.source_run_handoff_artifact_digest != null + || record.source_run_id != null + || record.source_run_handoff_artifact_digest != null; + if (sourceRunRequired) { + if (typeof record.source_run_id !== 'string' || record.source_run_id.length === 0) { + errors.push('audit record source run id must be a non-empty string'); + } + if (typeof payload.bindings?.source_run_id !== 'string' + || payload.bindings.source_run_id.length === 0) { + errors.push('signed evidence source run id must be a non-empty string'); + } + if (!SHA256_PATTERN.test(record.source_run_handoff_artifact_digest)) { + errors.push('audit record source run artifact digest must be a lowercase SHA-256 digest'); + } + if (!SHA256_PATTERN.test(payload.bindings?.source_run_handoff_artifact_digest)) { + errors.push('signed evidence source run artifact digest must be a lowercase SHA-256 digest'); + } + } if ((payload.bindings?.source_run_id ?? null) !== (record.source_run_id ?? null)) { errors.push('source run id does not match the audit record'); } diff --git a/src/handoff/v4.js b/src/handoff/v4.js index 5af6b72..f19f7a7 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -436,6 +436,59 @@ function evidenceBinding(binding) { }; } +function validatePersistedEvidenceBinding(job, artifactEvidence) { + const errors = []; + const declaration = normalizeJsonValue(job.evidence); + let expected; + + if (declaration == null) { + if (job.evidence_ref != null) { + errors.push('evidence_ref is set but the persisted evidence declaration is missing'); + } + expected = evidenceBinding({ evidence: null }); + } else if (typeof declaration !== 'object' || Array.isArray(declaration)) { + errors.push('persisted evidence declaration must be an object or null'); + return errors; + } else { + for (const field of ['payload_hash', 'provider_config_hash']) { + if (!Object.hasOwn(declaration, field)) { + errors.push(`persisted evidence declaration is missing ${field}`); + } else { + validateHash(declaration[field], `persisted evidence.${field}`, errors); + } + } + if (declaration.provider_config != null) { + errors.push('persisted evidence declaration must not contain raw provider_config'); + } + const expectedPayloadHash = hashObject(declaration.payload); + if ((declaration.payload_hash ?? null) !== expectedPayloadHash) { + errors.push('persisted evidence payload_hash does not match its payload'); + } + if ((declaration.ref ?? null) !== (job.evidence_ref ?? null)) { + errors.push('persisted evidence ref does not match evidence_ref'); + } + expected = evidenceBinding({ evidence: declaration }); + } + + for (const field of [ + 'ref', + 'provider', + 'methods', + 'payload_bind', + 'payload_hash', + 'provider_config_hash', + 'verify_required', + 'retention', + 'signed_or_provider_verified_required', + ]) { + if (canonicalStringify(artifactEvidence?.[field] ?? null) + !== canonicalStringify(expected[field] ?? null)) { + errors.push(`artifact evidence.${field} does not match the persisted evidence declaration`); + } + } + return errors; +} + function commandBinding(binding, task, job) { const command = binding.command; const payloadKind = job.payload_kind ?? null; @@ -669,6 +722,18 @@ export function assertValidSchedulerHandoffV4Job(job) { }, ); } + const evidenceErrors = validatePersistedEvidenceBinding( + job, + originalValidation.payload.evidence, + ); + if (evidenceErrors.length > 0) { + throw Object.assign( + new Error( + `Handoff v4 job evidence no longer matches its artifact: ${evidenceErrors.join('; ')}`, + ), + { code: 'HANDOFF_ARTIFACT_INVALID', errors: evidenceErrors }, + ); + } return originalValidation.payload; } @@ -846,6 +911,21 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { if (payload.evidence?.provider && !payload.evidence.signed_or_provider_verified_required) { errors.push('declared evidence providers require signed or provider-verified evidence'); } + const artifactEvidence = payload.evidence ?? {}; + const hasEvidenceDeclaration = artifactEvidence.ref != null + || artifactEvidence.provider != null + || (Array.isArray(artifactEvidence.methods) && artifactEvidence.methods.length > 0) + || (Array.isArray(artifactEvidence.payload_bind) && artifactEvidence.payload_bind.length > 0) + || artifactEvidence.verify_required === true + || artifactEvidence.retention != null; + if (!hasEvidenceDeclaration) { + if (artifactEvidence.payload_hash != null) { + errors.push('evidence.payload_hash must be null when evidence is not declared'); + } + if (artifactEvidence.provider_config_hash != null) { + errors.push('evidence.provider_config_hash must be null when evidence is not declared'); + } + } if (payload.delegation?.mode && payload.delegation.mode !== 'none' && payload.delegation.source_binding !== 'source_run_id') { errors.push('delegation must bind to source_run_id'); diff --git a/src/runtime/openclaw-scheduler.js b/src/runtime/openclaw-scheduler.js index 234682e..6b60db4 100644 --- a/src/runtime/openclaw-scheduler.js +++ b/src/runtime/openclaw-scheduler.js @@ -14,13 +14,14 @@ import { import { querySchedulerCapabilities, resolveEffectiveFeatures, + supportsSchedulerHandoffV4, validateManifestCapabilities, } from '../capabilities.js'; -export function compileManifestForDispatch(manifest) { +export function compileManifestForDispatch(manifest, options = {}) { // Manifests are ordinary mutable JavaScript objects. Recompile on every // dispatch so a caller cannot receive a stale job after an in-place edit. - return compileManifestToScheduler(manifest); + return compileManifestToScheduler(manifest, options); } export const schedulerAdapter = { @@ -60,11 +61,35 @@ export const schedulerAdapter = { */ dispatch(manifest, task, workflow, options) { const { schedulerPrefix, schedulerBin, dbPath, dryRun, cwd, env } = options; - - // Compile the full manifest to get job specs for every task - const compiled = compileManifestForDispatch(manifest); const taskId = task.id || task.name; const workflowId = workflow.id; + let runner = null; + let effectiveResult = null; + let schedulerHandoffVersion = '3'; + + if (!dryRun) { + runner = createSchedulerCliRunner({ + schedulerPrefix, + schedulerBin, + dbPath, + cwd, + env, + }); + const runtimeCaps = querySchedulerCapabilities(runner); + effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); + if (supportsSchedulerHandoffV4(runtimeCaps)) { + schedulerHandoffVersion = '4'; + } + } + + // The one-off lifecycle value is set before v4 artifact construction so + // the artifact and persisted scheduler projection bind the same job. + const compiled = compileManifestForDispatch(manifest, { + schedulerHandoffVersion, + cwd, + env, + oneOffSource: { workflow_id: workflowId, task_id: taskId }, + }); // Match the compiled job to the requested workflow/task pair. const job = compiled.jobs.find( @@ -78,9 +103,7 @@ export const schedulerAdapter = { ); } - // Mark as one-off so the scheduler deletes the job after a single run. - // Spread to avoid mutating the compiler output returned to other callers. - const jobSpec = { ...job, delete_after_run: 1 }; + const jobSpec = job; if (dryRun) { return { @@ -95,17 +118,6 @@ export const schedulerAdapter = { }; } - // Build a runner and negotiate capabilities - const runner = createSchedulerCliRunner({ - schedulerPrefix, - schedulerBin, - dbPath, - cwd, - env, - }); - - const runtimeCaps = querySchedulerCapabilities(runner); - const effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); const { errors: capabilityErrors, warnings: capabilityWarnings, diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 487f3ec..c7043b2 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -72,6 +72,7 @@ import { } from '../src/identity/index.js'; import { buildActorContext, buildStepUpContext } from '../src/actor-context.js'; import { canonicalDigest } from '../src/canonical.js'; +import { assertValidSchedulerHandoffV4Job } from '../src/handoff/v4.js'; function readExample(name) { return JSON.parse(readFileSync(new URL(`../examples/${name}`, import.meta.url), 'utf8')); @@ -987,12 +988,17 @@ test('scheduler CLI runner requests persisted v4 artifacts only when explicitly }, }); assert.deepEqual(scheduler.listJobs(), []); + assert.deepEqual(scheduler.listJobs(null), []); assert.deepEqual(scheduler.listJobs({ includeHandoffArtifacts: true }), []); assert.deepEqual(calls, [ { command: 'openclaw-scheduler', args: ['--json', 'jobs', 'list'], }, + { + command: 'openclaw-scheduler', + args: ['--json', 'jobs', 'list'], + }, { command: 'openclaw-scheduler', args: ['--json', 'jobs', 'list', '--include-handoff-artifacts'], @@ -9966,6 +9972,8 @@ test('exec delegates non-shell task with dry-run and returns delegation receipt' assert.strictEqual(result.session_target, 'isolated'); assert.ok(result.job_spec, 'dry-run receipt should include the job spec'); assert.ok(result.job_id, 'dry-run receipt should include a job_id'); + assert.strictEqual(result.job_spec.delete_after_run, 1); + assert.strictEqual('handoff_version' in result.job_spec, false); }); test('exec shell task still works unchanged after delegation plumbing', () => { @@ -10043,6 +10051,64 @@ test('exec delegates prompt task to mock scheduler runner', () => { } }); +test('exec delegated task negotiates v4 before binding one-off lifecycle', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'v4-delegation', + name: 'V4 Delegation', + tasks: [{ + id: 'prompt-task', + name: 'Prompt Task', + prompt: 'Summarize the logs', + target: { session_target: 'isolated', agent_id: 'main' }, + schedule: { cron: '0 * * * *' }, + }], + }], + }; + const capabilities = JSON.stringify({ + features: Object.fromEntries(HANDOFF_V4_REQUIRED_FEATURES.map(feature => [feature, true])), + scheduler_version: '0.5.0-test', + schema_version: 29, + handoff_version: '4', + handoff_contract: HANDOFF_V4_RUNTIME_CONTRACT, + }); + const tmpDir = mkdtempSync(join(tmpdir(), 'agentcli-v4-delegation-')); + const fakeBin = join(tmpDir, 'fake-scheduler.sh'); + const captureFile = join(tmpDir, 'job-spec.json'); + writeFileSync(fakeBin, [ + '#!/bin/sh', + 'if [ "$2" = "capabilities" ]; then', + ` echo '${capabilities}'`, + 'elif [ "$2" = "jobs" ] && [ "$3" = "add" ]; then', + ` printf '%s' "$4" > '${captureFile}'`, + ' echo \'{"ok":true}\'', + 'else', + ' echo \'{}\'', + 'fi', + ].join('\n'), { mode: 0o755 }); + + try { + const result = executeTask(manifest, { + taskId: 'prompt-task', + schedulerBin: fakeBin, + signer: 'none', + env: { PATH: process.env.PATH }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.handoff_version, '4'); + + const captured = JSON.parse(readFileSync(captureFile, 'utf8')); + const artifact = JSON.parse(captured.handoff_artifact_payload); + assert.strictEqual(captured.handoff_version, 4); + assert.strictEqual(captured.delete_after_run, 1); + assert.strictEqual(artifact.lifecycle.delete_after_run, true); + assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(captured)); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + test('exec delegated task surfaces scheduler capability warnings', () => { const manifest = { version: '0.2', diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 9d5ffe4..8b54606 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -348,6 +348,19 @@ test('handoff v4 schema requires explicit nullable evidence hashes', () => { assert.equal(validation.ok, false, field); assert.match(validation.errors.join('; '), new RegExp(`evidence\\.${field} is required`)); } + + for (const field of ['payload_hash', 'provider_config_hash']) { + const inconsistent = structuredClone(payload); + inconsistent.evidence[field] = `sha256:${'a'.repeat(64)}`; + const validation = validateSchedulerHandoffV4Artifact(inconsistent, { + expectedDigest: canonicalDigest(inconsistent), + }); + assert.equal(validation.ok, false, field); + assert.match( + validation.errors.join('; '), + new RegExp(`evidence\\.${field} must be null when evidence is not declared`), + ); + } }); test('handoff v4 persists audit-safe evidence hashes with the scheduler declaration', () => { @@ -362,7 +375,7 @@ test('handoff v4 persists audit-safe evidence hashes with the scheduler declarat }, payload: { bind: ['execution_id', 'command', 'result'], - context: { policy_version: true }, + context: { policy_version: undefined }, format: 'canonical-json', }, verify: { required: true }, @@ -394,6 +407,22 @@ test('handoff v4 persists audit-safe evidence hashes with the scheduler declarat assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(job)); + const storedV4 = schedulerCreateSpec(job, { fieldVersion: '4' }); + assert.equal(JSON.parse(storedV4.evidence).payload.context.policy_version, null); + assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(storedV4)); + + for (const field of ['payload_hash', 'provider_config_hash']) { + const tampered = structuredClone(job); + tampered.handoff_artifact_payload.evidence[field] = `sha256:${'f'.repeat(64)}`; + tampered.handoff_artifact_digest = canonicalDigest(tampered.handoff_artifact_payload); + assert.throws( + () => assertValidSchedulerHandoffV4Job(tampered), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && new RegExp(`evidence\\.${field} does not match`).test(error.message), + field, + ); + } + const legacyJob = compileManifestToScheduler(evidenceManifest, { schedulerHandoffVersion: '3', cwd: '/tmp', @@ -401,6 +430,11 @@ test('handoff v4 persists audit-safe evidence hashes with the scheduler declarat }).jobs[0]; assert.equal(Object.hasOwn(legacyJob.evidence, 'payload_hash'), false); assert.equal(Object.hasOwn(legacyJob.evidence, 'provider_config_hash'), false); + const storedV3 = schedulerCreateSpec(legacyJob, { fieldVersion: '3' }); + assert.equal( + Object.hasOwn(JSON.parse(storedV3.evidence).payload.context, 'policy_version'), + false, + ); }); test('shared handoff v4 conformance fixtures have exact digest parity and fail closed', () => { @@ -855,6 +889,9 @@ test('apply uses v4 only after every runtime gate is advertised', async () => { ['future canonicalization', { handoffContract: { ...HANDOFF_V4_RUNTIME_CONTRACT, canonicalization_version: 2 }, }], + ['extended contract', { + handoffContract: { ...HANDOFF_V4_RUNTIME_CONTRACT, future_semantics: true }, + }], ['old scheduler schema', { schemaVersion: 28 }], ]) { const incompatibleRunner = runner(options); @@ -1206,6 +1243,92 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(unbound.verified, false); assert.match(unbound.reason, /artifact digest claim/); + for (const { name, trustedDigest, claimedDigest, expected } of [ + { + name: 'bare trusted digest', + trustedDigest: 'a'.repeat(64), + claimedDigest: 'a'.repeat(64), + expected: /trusted handoff artifact digest must be a lowercase sha256 digest/, + }, + { + name: 'uppercase trusted digest', + trustedDigest: `sha256:${'A'.repeat(64)}`, + claimedDigest: `sha256:${'A'.repeat(64)}`, + expected: /trusted handoff artifact digest must be a lowercase sha256 digest/, + }, + { + name: 'bare claimed digest', + trustedDigest: artifactDigest, + claimedDigest: 'a'.repeat(64), + expected: /artifact digest claim must be a lowercase sha256 digest/, + }, + { + name: 'uppercase claimed digest', + trustedDigest: artifactDigest, + claimedDigest: `sha256:${'A'.repeat(64)}`, + expected: /artifact digest claim must be a lowercase sha256 digest/, + }, + ]) { + const malformed = jwtVerifier.verifyProof( + signJwt({ + ...payload, + jti: `proof-${name.replaceAll(' ', '-')}`, + handoff_artifact_digest: claimedDigest, + }, privateKey), + profile, + { + ...context, + artifactDigest: trustedDigest, + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(malformed.verified, false, name); + assert.match(malformed.reason, expected, name); + } + + for (const { name, trustedDigest, claimedDigest, expected } of [ + { + name: 'bare trusted manifest digest', + trustedDigest: 'b'.repeat(64), + claimedDigest: 'b'.repeat(64), + expected: /trusted manifest digest must be a lowercase sha256 digest/, + }, + { + name: 'uppercase trusted manifest digest', + trustedDigest: `sha256:${'B'.repeat(64)}`, + claimedDigest: `sha256:${'B'.repeat(64)}`, + expected: /trusted manifest digest must be a lowercase sha256 digest/, + }, + { + name: 'bare claimed manifest digest', + trustedDigest: payload.manifest_digest, + claimedDigest: 'b'.repeat(64), + expected: /manifest_digest claim must be a lowercase sha256 digest/, + }, + { + name: 'uppercase claimed manifest digest', + trustedDigest: payload.manifest_digest, + claimedDigest: `sha256:${'B'.repeat(64)}`, + expected: /manifest_digest claim must be a lowercase sha256 digest/, + }, + ]) { + const malformed = jwtVerifier.verifyProof( + signJwt({ + ...payload, + jti: `proof-${name.replaceAll(' ', '-')}`, + manifest_digest: claimedDigest, + }, privateKey), + profile, + { + ...context, + manifestDigest: trustedDigest, + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(malformed.verified, false, name); + assert.match(malformed.reason, expected, name); + } + const stringVersionUnbound = jwtVerifier.verifyProof( signJwt({ ...payload, jti: 'proof-string-version' }, privateKey), profile, diff --git a/test/proof-evidence.test.js b/test/proof-evidence.test.js index cdb8ae7..a0db9e0 100644 --- a/test/proof-evidence.test.js +++ b/test/proof-evidence.test.js @@ -113,6 +113,48 @@ function evidencePayload(overrides = {}) { }); } +function evidenceRecordForPayload(payload, overrides = {}) { + return { + execution_id: payload.execution_id, + timestamp: payload.timestamp, + source: payload.source, + manifest_digest: payload.bindings.manifest_digest, + effective_task_hash: payload.bindings.effective_task_hash, + handoff_artifact_digest: payload.bindings.handoff_artifact_digest ?? null, + source_run_id: payload.bindings.source_run_id ?? null, + source_run_handoff_artifact_digest: + payload.bindings.source_run_handoff_artifact_digest ?? null, + declared_identity: { provider: 'none' }, + resolved_identity: { + principal: 'agent://test', + credentials: { access_token: 'secret-identity-token' }, + }, + authorization_proof: { method: 'jwt', verified: true }, + authorization: { decision: 'permit', provider_data: { api_key: 'secret-auth-key' } }, + actor_context: { principal: 'agent://test' }, + contract: { audit: 'always' }, + command: Object.fromEntries([ + 'program', 'cwd', 'args_count', 'args_hashes', 'env_keys', 'env_hashes', + 'stdin_present', 'stdin_hash', + ].map(field => [ + field, + field === 'stdin_present' + ? payload.command.stdin_hash != null + : payload.command[field] ?? null, + ])), + result: Object.fromEntries([ + 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', + 'stderr_bytes', 'output_hash', + ].map(field => [field, payload.result[field] ?? null])), + verify: { + passed: true, + stdout: 'verify-output', + stderr: '', + }, + ...overrides, + }; +} + test('resolveValueFrom disables command execution until explicitly allowed', () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-value-from-')); const marker = join(workdir, 'marker'); @@ -457,40 +499,7 @@ test('complete evidence rejects caller-supplied binding digest mismatches', () = test('verified evidence cannot be transplanted onto another audit record', () => { const payload = evidencePayload(); - const record = { - execution_id: payload.execution_id, - timestamp: payload.timestamp, - source: payload.source, - manifest_digest: payload.bindings.manifest_digest, - effective_task_hash: payload.bindings.effective_task_hash, - declared_identity: { provider: 'none' }, - resolved_identity: { - principal: 'agent://test', - credentials: { access_token: 'secret-identity-token' }, - }, - authorization_proof: { method: 'jwt', verified: true }, - authorization: { decision: 'permit', provider_data: { api_key: 'secret-auth-key' } }, - actor_context: { principal: 'agent://test' }, - contract: { audit: 'always' }, - command: Object.fromEntries([ - 'program', 'cwd', 'args_count', 'args_hashes', 'env_keys', 'env_hashes', - 'stdin_present', 'stdin_hash', - ].map(field => [ - field, - field === 'stdin_present' - ? payload.command.stdin_hash != null - : payload.command[field] ?? null, - ])), - result: Object.fromEntries([ - 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', - 'stderr_bytes', 'output_hash', - ].map(field => [field, payload.result[field] ?? null])), - verify: { - passed: true, - stdout: 'verify-output', - stderr: '', - }, - }; + const record = evidenceRecordForPayload(payload); assert.deepEqual( validateEvidenceRecordBinding(payload, record), { valid: true, errors: [] } @@ -510,6 +519,55 @@ test('verified evidence cannot be transplanted onto another audit record', () => assert.ok(rewrittenIdentity.errors.some(error => /resolved_identity/.test(error))); }); +test('v4 evidence records require canonical artifact and child lineage bindings', () => { + const artifactDigest = `sha256:${'c'.repeat(64)}`; + const sourceArtifactDigest = `sha256:${'d'.repeat(64)}`; + const rootPayload = evidencePayload({ handoffArtifactDigest: artifactDigest }); + const rootRecord = evidenceRecordForPayload(rootPayload, { handoff_version: 4 }); + assert.deepEqual( + validateEvidenceRecordBinding(rootPayload, rootRecord), + { valid: true, errors: [] }, + ); + + const missingPayload = evidencePayload(); + const missingRecord = evidenceRecordForPayload(missingPayload, { handoff_version: 4 }); + const missingArtifact = validateEvidenceRecordBinding(missingPayload, missingRecord); + assert.equal(missingArtifact.valid, false); + assert.ok(missingArtifact.errors.some(error => /handoff artifact digest/.test(error))); + + const malformedPayload = structuredClone(rootPayload); + malformedPayload.bindings.handoff_artifact_digest = 'not-a-digest'; + const malformedRecord = evidenceRecordForPayload(malformedPayload, { + handoff_version: 4, + handoff_artifact_digest: 'not-a-digest', + }); + const malformedArtifact = validateEvidenceRecordBinding(malformedPayload, malformedRecord); + assert.equal(malformedArtifact.valid, false); + assert.ok(malformedArtifact.errors.some(error => /lowercase SHA-256 digest/.test(error))); + + const childPayload = evidencePayload({ + handoffArtifactDigest: artifactDigest, + sourceRunId: 'source-run-1', + sourceRunHandoffArtifactDigest: sourceArtifactDigest, + }); + const childRecord = evidenceRecordForPayload(childPayload, { + handoff_version: 4, + source_run_required: true, + }); + assert.deepEqual( + validateEvidenceRecordBinding(childPayload, childRecord), + { valid: true, errors: [] }, + ); + + const missingLineage = validateEvidenceRecordBinding(rootPayload, { + ...rootRecord, + source_run_required: true, + }); + assert.equal(missingLineage.valid, false); + assert.ok(missingLineage.errors.some(error => /source run id/.test(error))); + assert.ok(missingLineage.errors.some(error => /source run artifact digest/.test(error))); +}); + test('SSH evidence profiles reject non-canonical payload serialization', () => { const validation = sshEvidenceProvider.validateProfile({ payload: { format: 'json' } }); assert.equal(validation.valid, false); From 3c859782daacc599183d44ceca83a0dfd78cd6b9 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 05:22:16 -0400 Subject: [PATCH 27/29] Harden final handoff v4 verification bindings --- CHANGELOG.md | 9 +- docs/spec.md | 20 +- src/authorization-proof/certificate.js | 25 +- src/authorization-proof/detached-signature.js | 27 ++- src/authorization-proof/jwt.js | 4 +- src/authorization-proof/replay.js | 7 + src/compiler/openclaw-scheduler.js | 4 +- src/evidence/payload.js | 49 +++- src/handoff/v4.js | 34 ++- test/handoff-v4.test.js | 228 ++++++++++++++++++ test/proof-evidence.test.js | 69 +++++- 11 files changed, 444 insertions(+), 32 deletions(-) create mode 100644 src/authorization-proof/replay.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b11d0b..f65e635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ not-revoked result from the runtime checker - security: JWT artifact claims and trusted context now require the exact lowercase `sha256:<64 hex>` representation before v4 binding can succeed +- security: detached-signature and certificate artifact bindings now require + the same canonical digest representation, and every v4 proof method accepts + replay claims only from an explicit, non-conflicting claimed result - security: JWKS-provided raw JWK objects are normalized before stable public key identity derivation, while private JWK material remains forbidden - security: credential handoff compiles to exactly one runtime medium without @@ -45,7 +48,11 @@ exact artifact parity while raw provider configuration remains absent - evidence: persisted declarations must match artifact evidence semantics and hashes; explicitly v4 evidence records require a canonical artifact digest, - and child records require complete source-run lineage + child records require complete source-run lineage, and terminal status and + structured-output hashes must exactly match the surrounding audit record +- validation: v4 command argument counts and aggregate argument digests are + recomputed, and every declared evidence block requires a canonical payload + hash even when the authored profile omits an explicit payload object - inspect: added immutable artifacts, runtime events, provider sessions, and credential presentations to the scheduler inspection surface, with every entity enumerated in JSON help and structured command discovery diff --git a/docs/spec.md b/docs/spec.md index 7286724..db6e9d8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -829,15 +829,19 @@ The artifact MUST NOT contain a raw credential, proof value, stdin value, environment value, private key, token, password, or provider session secret. Credential and proof sources are represented by declarative locations and hashes. A consumer MUST recompute and compare the artifact, scheduler binding, -and effective task digests before execution. +and effective task digests before execution. The command argument count MUST +equal the number of argument hashes, and `argv_sha256` MUST equal the canonical +digest of the emitted program followed by those argument hashes. When evidence is declared, the persisted scheduler evidence declaration MUST retain canonical `payload_hash` and `provider_config_hash` values that exactly match the artifact evidence binding. Raw `provider_config` remains null. An absent evidence declaration requires both artifact hashes to be null, while a -declared payload or provider configuration requires its corresponding hash to -be non-null. A consumer MUST reject any semantic or hash mismatch between the -persisted declaration and the artifact. +declared evidence block always requires a non-null canonical `payload_hash`, +including when its normalized payload is empty. A declared provider +configuration also requires a non-null `provider_config_hash`. A consumer MUST +reject any semantic or hash mismatch between the persisted declaration and the +artifact. ### Persistence and replacement @@ -901,6 +905,11 @@ single-use replay identifier. Required revocation checks MUST run before user code. Missing, tampered, replayed, transplanted, expired, prematurely valid, revoked, or unbound proofs fail closed. +Trusted and claimed artifact digests MUST use the exact lowercase +`sha256:<64 hex>` representation. A replay store succeeds only by returning +literal `true` or an object with `claimed` set to `true` and no conflicting +`ok` value. Ambiguous or contradictory replay results fail closed. + Proof expiration MUST be later than issuance regardless of clock skew. A detached-signature or certificate envelope key ID MUST match the key or certificate that actually verified. JWT revocation uses the trusted JWKS key ID @@ -923,7 +932,8 @@ Evidence MUST bind the artifact, runtime instance, lineage, identity, proof, authorization, command result, structured output, postcondition, and terminal status. A required evidence provider MUST sign or externally verify the canonical payload. Verification failure MUST remain terminal and MUST NOT be -downgraded to checksum-only evidence. +downgraded to checksum-only evidence. A v4 verifier MUST require exact parity +between signed and persisted terminal status and structured-output hash fields. ### Compatibility and conformance diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 3b9c4a9..2397dcc 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -16,9 +16,11 @@ import { import { canonicalStringify, hashString } from '../canonical.js'; import { resolveValueFrom } from '../command.js'; import { registerVerifier } from './index.js'; +import { replayClaimAccepted } from './replay.js'; import { normalizeProofTimeContext } from './time.js'; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; +const CANONICAL_SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; export function certificateProofKeyId(certificate) { const parsed = certificate instanceof X509Certificate @@ -78,7 +80,13 @@ function resolveCanonicalManifest(ctx = {}) { const expectedDigest = artifactMode ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) : ctx.manifestDigest; - if (expectedDigest && normalizeDigest(expectedDigest) !== normalizeDigest(digest)) { + if (artifactMode && !CANONICAL_SHA256_PATTERN.test(expectedDigest)) { + return { error: 'provided artifact digest must be a canonical lowercase SHA-256 digest' }; + } + const digestMatches = artifactMode + ? expectedDigest === digest + : normalizeDigest(expectedDigest) === normalizeDigest(digest); + if (expectedDigest && !digestMatches) { return { error: artifactMode ? 'provided artifact digest does not match canonical artifact payload' @@ -136,7 +144,13 @@ function validateV4CertificateEnvelope(parsed, context) { return { ok: false, v4: true, reason: `handoff v4 certificate proof is missing ${field}` }; } } - if (normalizeDigest(parsed.artifact_digest) !== normalizeDigest(context.artifactDigest)) { + if (!CANONICAL_SHA256_PATTERN.test(context.trustedArtifactDigest)) { + return { ok: false, v4: true, reason: 'trusted handoff artifact digest must be a canonical lowercase SHA-256 digest' }; + } + if (!CANONICAL_SHA256_PATTERN.test(parsed.artifact_digest)) { + return { ok: false, v4: true, reason: 'certificate proof artifact digest must be a canonical lowercase SHA-256 digest' }; + } + if (parsed.artifact_digest !== context.trustedArtifactDigest) { return { ok: false, v4: true, reason: 'certificate proof artifact digest does not match' }; } const proofTime = normalizeProofTimeContext(context); @@ -217,7 +231,7 @@ function enforceV4CertificateGuards(parsed, context, profile, cert) { if (replay && typeof replay.then === 'function') { return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; } - const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + const replayProtected = replayClaimAccepted(replay); if (!replayProtected) { return { ok: false, reason: replay?.reason || 'certificate proof nonce was already used' }; } @@ -296,6 +310,9 @@ export function resolveCertificateVerificationContext(profile = {}, ctx = {}) { artifactDigest: manifest.artifactMode ? manifest.digest : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), + trustedArtifactDigest: manifest.artifactMode + ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) + : null, handoffVersion: manifest.artifactMode ? 4 : (ctx.handoffVersion ?? ctx.handoff_version ?? null), @@ -712,7 +729,7 @@ const certificateVerifier = { manifest_digest: context.manifestDigest || null, artifact_digest: context.artifactDigest ?? null, artifact_bound: !envelope.v4 - || normalizeDigest(parsedProof.artifact_digest) === normalizeDigest(context.artifactDigest), + || parsedProof.artifact_digest === context.trustedArtifactDigest, replay_protected: guards.replayProtected === true, revocation_checked: guards.revocationChecked === true, verified_at: verifiedAt, diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index 2839e92..2a50a9c 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -16,10 +16,12 @@ import process from 'node:process'; import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; import { publicKeyId } from './key-identity.js'; +import { replayClaimAccepted } from './replay.js'; import { normalizeProofTimeContext } from './time.js'; const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; const V4_PROOF_SCHEMA = 'openclaw.scheduler.authorization-proof'; +const CANONICAL_SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; export function detachedSignatureKeyId(publicKey) { return publicKeyId(publicKey); @@ -114,7 +116,13 @@ function canonicalManifestContext(ctx = {}) { const expectedDigest = artifactMode ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) : ctx.manifestDigest; - if (expectedDigest && normalizeDigest(expectedDigest) !== normalizeDigest(digest)) { + if (artifactMode && !CANONICAL_SHA256_PATTERN.test(expectedDigest)) { + return { error: 'provided artifact digest must be a canonical lowercase SHA-256 digest' }; + } + const digestMatches = artifactMode + ? expectedDigest === digest + : normalizeDigest(expectedDigest) === normalizeDigest(digest); + if (expectedDigest && !digestMatches) { return { error: artifactMode ? 'provided artifact digest does not match canonical artifact payload' @@ -139,6 +147,9 @@ export function resolveDetachedSignatureVerificationContext(profile = {}, ctx = artifactDigest: canonical.artifactMode ? canonical.digest : (ctx.artifactDigest ?? ctx.handoffArtifactDigest ?? null), + trustedArtifactDigest: canonical.artifactMode + ? (ctx.artifactDigest ?? ctx.handoffArtifactDigest) + : null, handoffVersion: canonical.artifactMode ? 4 : (ctx.handoffVersion ?? ctx.handoff_version ?? null), @@ -185,7 +196,13 @@ function parseV4Envelope(proof, context) { return { error: `handoff v4 detached proof is missing ${field}`, v4: true }; } } - if (normalizeDigest(envelope.artifact_digest) !== normalizeDigest(context.artifactDigest)) { + if (!CANONICAL_SHA256_PATTERN.test(context.trustedArtifactDigest)) { + return { error: 'trusted handoff artifact digest must be a canonical lowercase SHA-256 digest', v4: true }; + } + if (!CANONICAL_SHA256_PATTERN.test(envelope.artifact_digest)) { + return { error: 'detached proof artifact digest must be a canonical lowercase SHA-256 digest', v4: true }; + } + if (envelope.artifact_digest !== context.trustedArtifactDigest) { return { error: 'detached proof artifact digest does not match', v4: true }; } @@ -262,7 +279,7 @@ function enforceV4RuntimeGuards(parsed, context, profile, verifiedKeyId) { if (replay && typeof replay.then === 'function') { return { ok: false, reason: 'handoff v4 replay store must complete synchronously' }; } - const replayProtected = replay === true || replay?.claimed === true || replay?.ok === true; + const replayProtected = replayClaimAccepted(replay); if (!replayProtected) { return { ok: false, reason: replay?.reason || 'detached proof nonce was already used' }; } @@ -507,7 +524,7 @@ const detachedSignatureVerifier = { manifest_digest: digest, artifact_digest: context.artifactDigest ?? null, artifact_bound: !parsed.v4 - || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), + || parsed.envelope?.artifact_digest === context.trustedArtifactDigest, replay_protected: guards.replayProtected === true, revocation_checked: guards.revocationChecked === true, key_id: sshResult.keyId ?? null, @@ -575,7 +592,7 @@ const detachedSignatureVerifier = { manifest_digest: digest, artifact_digest: context.artifactDigest ?? null, artifact_bound: !parsed.v4 - || normalizeDigest(parsed.envelope?.artifact_digest) === normalizeDigest(context.artifactDigest), + || parsed.envelope?.artifact_digest === context.trustedArtifactDigest, replay_protected: guards.replayProtected === true, revocation_checked: guards.revocationChecked === true, key_id: verifiedKeyId, diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index a69aebf..35257f7 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -13,6 +13,7 @@ import { createPublicKey, createVerify } from 'node:crypto'; import { canonicalDigest } from '../canonical.js'; import { registerVerifier } from './index.js'; import { publicKeyId } from './key-identity.js'; +import { replayClaimAccepted } from './replay.js'; import { normalizeProofTimeContext } from './time.js'; const DEFAULT_JWKS_CACHE_TTL_MS = 5 * 60 * 1000; @@ -889,8 +890,7 @@ const jwtVerifier = { if (replayResult && typeof replayResult.then === 'function') { runtimeGuardReason = 'handoff v4 replay store must complete synchronously'; } else { - replayProtected = replayResult === true || replayResult?.claimed === true - || replayResult?.ok === true; + replayProtected = replayClaimAccepted(replayResult); if (!replayProtected) runtimeGuardReason = replayResult?.reason || 'JWT jti was already used'; } } diff --git a/src/authorization-proof/replay.js b/src/authorization-proof/replay.js new file mode 100644 index 0000000..6bcb761 --- /dev/null +++ b/src/authorization-proof/replay.js @@ -0,0 +1,7 @@ +export function replayClaimAccepted(result) { + if (result === true) return true; + if (!result || typeof result !== 'object' || Array.isArray(result)) return false; + if (!Object.hasOwn(result, 'claimed') || result.claimed !== true) return false; + if (Object.hasOwn(result, 'ok') && result.ok !== true) return false; + return true; +} diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 7900b42..66262ad 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -205,9 +205,7 @@ function sanitizeEvidenceDeclaration(evidence, { includeHashes = false } = {}) { ...evidence, ...(includeHashes ? { - payload_hash: evidence.payload == null - ? null - : canonicalDigest(evidence.payload), + payload_hash: canonicalDigest(evidence.payload ?? {}), provider_config_hash: evidence.provider_config == null ? null : canonicalDigest(evidence.provider_config), diff --git a/src/evidence/payload.js b/src/evidence/payload.js index 12288a6..6b866c3 100644 --- a/src/evidence/payload.js +++ b/src/evidence/payload.js @@ -321,7 +321,11 @@ export function validateEvidenceRecordBinding(payload, record) { } const payloadArtifactDigest = payload.bindings?.handoff_artifact_digest ?? null; const recordArtifactDigest = record.handoff_artifact_digest ?? null; - const v4BindingRequired = Number(record.handoff_version ?? record.handoff?.version) === 4 + const v4BindingRequired = [ + record.handoff_version, + record.handoff?.version, + record.handoff?.handoff_version, + ].some(value => Number(value) === 4) || payloadArtifactDigest != null || recordArtifactDigest != null; if (v4BindingRequired) { @@ -336,15 +340,16 @@ export function validateEvidenceRecordBinding(payload, record) { !== (record.handoff_artifact_digest ?? null)) { errors.push('handoff artifact digest does not match the audit record'); } - const sourceRunRequired = record.source_run_required === true + const sourceRunMarker = record.source_run_required === true || record.child_run === true || record.is_child_run === true || record.parent_id != null - || record.parent_run_id != null - || payload.bindings?.source_run_id != null + || record.parent_run_id != null; + const sourceRunFieldsPresent = payload.bindings?.source_run_id != null || payload.bindings?.source_run_handoff_artifact_digest != null || record.source_run_id != null || record.source_run_handoff_artifact_digest != null; + const sourceRunRequired = sourceRunFieldsPresent || (v4BindingRequired && sourceRunMarker); if (sourceRunRequired) { if (typeof record.source_run_id !== 'string' || record.source_run_id.length === 0) { errors.push('audit record source run id must be a non-empty string'); @@ -371,6 +376,42 @@ export function validateEvidenceRecordBinding(payload, record) { if (payload.result?.output_hash !== recordOutputHash) { errors.push('result output hash does not match the audit record'); } + if (v4BindingRequired) { + const payloadResult = payload.result; + const recordResult = record.result; + if (!payloadResult || typeof payloadResult !== 'object' || Array.isArray(payloadResult)) { + errors.push('signed evidence result must be an object'); + } + if (!recordResult || typeof recordResult !== 'object' || Array.isArray(recordResult)) { + errors.push('audit record result must be an object'); + } + if (!Object.hasOwn(payloadResult ?? {}, 'status') + || typeof payloadResult?.status !== 'string' + || payloadResult.status.length === 0) { + errors.push('signed evidence result.status must be a non-empty string'); + } + if (!Object.hasOwn(recordResult ?? {}, 'status') + || typeof recordResult?.status !== 'string' + || recordResult.status.length === 0) { + errors.push('audit record result.status must be a non-empty string'); + } + if ((payloadResult?.status ?? null) !== (recordResult?.status ?? null)) { + errors.push('result.status does not match the signed evidence'); + } + for (const [label, result] of [ + ['signed evidence', payloadResult], + ['audit record', recordResult], + ]) { + if (!Object.hasOwn(result ?? {}, 'structured_hash')) { + errors.push(`${label} result.structured_hash is required`); + } else if (result.structured_hash !== null && !SHA256_PATTERN.test(result.structured_hash)) { + errors.push(`${label} result.structured_hash must be null or a lowercase SHA-256 digest`); + } + } + if ((payloadResult?.structured_hash ?? null) !== (recordResult?.structured_hash ?? null)) { + errors.push('result.structured_hash does not match the signed evidence'); + } + } const signedFieldMappings = [ ['declared_identity', 'declared_identity'], diff --git a/src/handoff/v4.js b/src/handoff/v4.js index f19f7a7..ef363bf 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -428,7 +428,7 @@ function evidenceBinding(binding) { : Array.isArray(evidence.payload?.bind_targets) ? evidence.payload.bind_targets : [], - payload_hash: hashObject(evidence.payload), + payload_hash: canonicalDigest(evidence.payload ?? {}), provider_config_hash: evidence.provider_config_hash ?? null, verify_required: evidence.verify?.required === true, retention: evidence.payload?.retention ?? null, @@ -460,7 +460,7 @@ function validatePersistedEvidenceBinding(job, artifactEvidence) { if (declaration.provider_config != null) { errors.push('persisted evidence declaration must not contain raw provider_config'); } - const expectedPayloadHash = hashObject(declaration.payload); + const expectedPayloadHash = canonicalDigest(declaration.payload ?? {}); if ((declaration.payload_hash ?? null) !== expectedPayloadHash) { errors.push('persisted evidence payload_hash does not match its payload'); } @@ -498,12 +498,13 @@ function commandBinding(binding, task, job) { ? 'prompt' : 'system'; const argsHashes = command?.args_hashes ?? []; + const program = command?.program ?? null; return { kind, - program: command?.program ?? null, + program, args_count: command?.args_count ?? 0, args_sha256: argsHashes, - argv_sha256: command ? canonicalDigest([command.program, ...argsHashes]) : null, + argv_sha256: canonicalDigest([program, ...argsHashes]), cwd: command?.cwd ?? null, stdin_sha256: command?.stdin_hash ?? null, prompt_sha256: hashNullableString(task.prompt), @@ -825,7 +826,16 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { validateHash(payload.manifest?.digest, 'manifest.digest', errors, { required: true }); validateHash(payload.compiled?.effective_task_hash, 'compiled.effective_task_hash', errors, { required: true }); validateHash(payload.command?.payload_message_sha256, 'command.payload_message_sha256', errors, { required: true }); - validateHashCollection(payload.command?.args_sha256, 'command.args_sha256', errors); + const commandArgumentHashes = payload.command?.args_sha256; + if (!Array.isArray(commandArgumentHashes)) { + errors.push('command.args_sha256 must be an array'); + } else { + validateHashCollection(commandArgumentHashes, 'command.args_sha256', errors); + if (Number.isInteger(payload.command?.args_count) + && payload.command.args_count !== commandArgumentHashes.length) { + errors.push('command.args_count does not match command.args_sha256 length'); + } + } validateHashCollection( payload.command?.env?.effective_env_value_sha256, 'command.env.effective_env_value_sha256', @@ -853,6 +863,15 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { ]) { validateHash(value, path, errors); } + if (payload.command && Array.isArray(commandArgumentHashes)) { + const expectedArgvHash = canonicalDigest([ + payload.command.program ?? null, + ...commandArgumentHashes, + ]); + if (payload.command.argv_sha256 !== expectedArgvHash) { + errors.push('command.argv_sha256 does not match command program and argument hashes'); + } + } requiredBoolean(payload.lifecycle?.enabled, 'lifecycle.enabled', errors); requiredBoolean(payload.lifecycle?.delete_after_run, 'lifecycle.delete_after_run', errors); @@ -912,7 +931,8 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { errors.push('declared evidence providers require signed or provider-verified evidence'); } const artifactEvidence = payload.evidence ?? {}; - const hasEvidenceDeclaration = artifactEvidence.ref != null + const hasEvidenceDeclaration = artifactEvidence.signed_or_provider_verified_required === true + || artifactEvidence.ref != null || artifactEvidence.provider != null || (Array.isArray(artifactEvidence.methods) && artifactEvidence.methods.length > 0) || (Array.isArray(artifactEvidence.payload_bind) && artifactEvidence.payload_bind.length > 0) @@ -925,6 +945,8 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { if (artifactEvidence.provider_config_hash != null) { errors.push('evidence.provider_config_hash must be null when evidence is not declared'); } + } else if (!SHA256_PATTERN.test(artifactEvidence.payload_hash)) { + errors.push('evidence.payload_hash must be a lowercase SHA-256 digest when evidence is declared'); } if (payload.delegation?.mode && payload.delegation.mode !== 'none' && payload.delegation.source_binding !== 'source_run_id') { diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 8b54606..84f03a1 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -216,6 +216,17 @@ test('handoff v4 artifact is canonical, deterministic, and tamper evident', () = }); assert.equal(validation.ok, true, validation.errors.join('; ')); assert.equal(job.effective_task_hash, job.handoff_artifact_payload.compiled.effective_task_hash); + assert.equal( + job.handoff_artifact_payload.command.args_count, + job.handoff_artifact_payload.command.args_sha256.length, + ); + assert.equal( + job.handoff_artifact_payload.command.argv_sha256, + canonicalDigest([ + job.handoff_artifact_payload.command.program, + ...job.handoff_artifact_payload.command.args_sha256, + ]), + ); const reordered = JSON.parse(canonicalStringify(job.handoff_artifact_payload)); assert.equal( @@ -233,6 +244,56 @@ test('handoff v4 artifact is canonical, deterministic, and tamper evident', () = assert.equal(tamperedValidation.ok, false); assert.match(tamperedValidation.errors.join('; '), /digest does not match/); + const wrongCount = structuredClone(job.handoff_artifact_payload); + wrongCount.command.args_count += 1; + const wrongCountValidation = validateSchedulerHandoffV4Artifact(wrongCount, { + expectedDigest: canonicalDigest(wrongCount), + }); + assert.equal(wrongCountValidation.ok, false); + assert.match(wrongCountValidation.errors.join('; '), /args_count does not match/); + + const wrongArgv = structuredClone(job.handoff_artifact_payload); + wrongArgv.command.argv_sha256 = `sha256:${'f'.repeat(64)}`; + const wrongArgvValidation = validateSchedulerHandoffV4Artifact(wrongArgv, { + expectedDigest: canonicalDigest(wrongArgv), + }); + assert.equal(wrongArgvValidation.ok, false); + assert.match(wrongArgvValidation.errors.join('; '), /argv_sha256 does not match/); + + const objectArguments = structuredClone(job.handoff_artifact_payload); + objectArguments.command.args_sha256 = { + first: objectArguments.command.args_sha256[0], + }; + const objectArgumentsValidation = validateSchedulerHandoffV4Artifact(objectArguments, { + expectedDigest: canonicalDigest(objectArguments), + }); + assert.equal(objectArgumentsValidation.ok, false); + assert.match(objectArgumentsValidation.errors.join('; '), /command\.args_sha256 must be an array/); + + const promptManifest = manifest(); + promptManifest.workflows[0].tasks[0] = { + id: 'prompt', + name: 'Prompt task', + prompt: 'Review the current state.', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + delivery: { mode: 'none' }, + }; + const promptPayload = compileManifestToScheduler(promptManifest, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload; + assert.equal(promptPayload.command.program, null); + assert.equal( + promptPayload.command.argv_sha256, + canonicalDigest([null, ...promptPayload.command.args_sha256]), + ); + assert.equal( + validateSchedulerHandoffV4Artifact(promptPayload).ok, + true, + ); + const future = structuredClone(job.handoff_artifact_payload); future.scheduler_schema_min = 30; const futureValidation = validateSchedulerHandoffV4Artifact(future); @@ -411,6 +472,49 @@ test('handoff v4 persists audit-safe evidence hashes with the scheduler declarat assert.equal(JSON.parse(storedV4.evidence).payload.context.policy_version, null); assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(storedV4)); + const payloadlessManifest = structuredClone(evidenceManifest); + delete payloadlessManifest.evidence_profiles[0].payload; + const payloadlessJob = compileManifestToScheduler(payloadlessManifest, { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0]; + const payloadlessExpectedHash = canonicalDigest(payloadlessJob.evidence.payload ?? {}); + assert.equal(payloadlessJob.evidence.payload_hash, payloadlessExpectedHash); + assert.equal( + payloadlessJob.handoff_artifact_payload.evidence.payload_hash, + payloadlessExpectedHash, + ); + assert.doesNotThrow(() => assertValidSchedulerHandoffV4Job(payloadlessJob)); + + const declarationMarkerArtifact = compileManifestToScheduler(manifest(), { + schedulerHandoffVersion: '4', + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }).jobs[0].handoff_artifact_payload; + declarationMarkerArtifact.evidence.payload_hash = canonicalDigest({}); + declarationMarkerArtifact.evidence.signed_or_provider_verified_required = true; + const declarationMarkerValidation = validateSchedulerHandoffV4Artifact( + declarationMarkerArtifact, + { expectedDigest: canonicalDigest(declarationMarkerArtifact) }, + ); + assert.equal( + declarationMarkerValidation.ok, + true, + declarationMarkerValidation.errors.join('; '), + ); + + const nullPayloadHash = structuredClone(payloadlessJob.handoff_artifact_payload); + nullPayloadHash.evidence.payload_hash = null; + const nullPayloadHashValidation = validateSchedulerHandoffV4Artifact(nullPayloadHash, { + expectedDigest: canonicalDigest(nullPayloadHash), + }); + assert.equal(nullPayloadHashValidation.ok, false); + assert.match( + nullPayloadHashValidation.errors.join('; '), + /evidence\.payload_hash must be a lowercase SHA-256 digest when evidence is declared/, + ); + for (const field of ['payload_hash', 'provider_config_hash']) { const tampered = structuredClone(job); tampered.handoff_artifact_payload.evidence[field] = `sha256:${'f'.repeat(64)}`; @@ -1454,6 +1558,36 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(indeterminateResult.verified, false); assert.match(indeterminateResult.reason, /did not explicitly confirm/); assert.equal(indeterminateReplayClaims, 0); + + for (const [name, replayResult] of [ + ['ambiguous-ok', { ok: true }], + ['conflicting-false-claim', { ok: true, claimed: false }], + ['conflicting-false-ok', { ok: false, claimed: true }], + ['null-result', null], + ['array-result', [{ claimed: true }]], + ['inherited-claim', Object.create({ claimed: true })], + ]) { + const rejectedReplay = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: `proof-${name}` }, privateKey), + profile, + { ...context, claimProofReplay: () => replayResult }, + ); + assert.equal(rejectedReplay.verified, false, name); + assert.equal(rejectedReplay.replay_protected, false, name); + assert.match(rejectedReplay.reason, /already used/, name); + } + + for (const [name, replayResult] of [ + ['literal-true', true], + ['explicit-claim', { claimed: true, ok: true }], + ]) { + const acceptedReplay = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: `proof-${name}` }, privateKey), + profile, + { ...context, claimProofReplay: () => replayResult }, + ); + assert.equal(acceptedReplay.verified, true, name); + } }); test('handoff v4 detached signatures cover nonce, validity, key, and artifact metadata', () => { @@ -1507,6 +1641,29 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me assert.equal(verified.revocation_checked, true); assert.equal(verified.verified_at, new Date(now).toISOString()); + for (const [name, proofDigest, trustedDigest] of [ + ['bare trusted digest', fields.artifactDigest, fields.artifactDigest.slice('sha256:'.length)], + ['uppercase trusted digest', fields.artifactDigest, fields.artifactDigest.toUpperCase()], + ['bare proof digest', fields.artifactDigest.slice('sha256:'.length), fields.artifactDigest], + ['uppercase proof digest', fields.artifactDigest.toUpperCase(), fields.artifactDigest], + ]) { + const malformedDigest = detachedSignatureVerifier.verifyProof( + { ...envelope, artifact_digest: proofDigest }, + profile, + { + ...context, + artifactDigest: trustedDigest, + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(malformedDigest.verified, false, name); + assert.match( + malformedDigest.signature_verification_reason ?? malformedDigest.reason, + /canonical lowercase SHA-256 digest/, + name, + ); + } + const invalidSkew = detachedSignatureVerifier.verifyProof(envelope, profile, { ...context, clockSkewSeconds: 'not-a-number', @@ -1597,6 +1754,30 @@ test('handoff v4 detached signatures cover nonce, validity, key, and artifact me assert.match(unchecked.signature_verification_reason, /did not explicitly confirm/); assert.equal(uncheckedReplayClaims, 0); + for (const [name, replayResult] of [ + ['ambiguous-ok', { ok: true }], + ['conflicting-claim', { ok: true, claimed: false }], + ['conflicting-false-ok', { ok: false, claimed: true }], + ['null-result', null], + ['array-result', [{ claimed: true }]], + ['inherited-claim', Object.create({ claimed: true })], + ]) { + const replayFields = { ...fields, nonce: `detached-proof-${name}` }; + const replaySigner = createSign('RSA-SHA256'); + replaySigner.update(buildDetachedSignatureV4SigningContent(replayFields)); + const rejectedReplay = detachedSignatureVerifier.verifyProof({ + ...envelope, + signature: replaySigner.sign(privateKey).toString('base64'), + nonce: replayFields.nonce, + }, profile, { + ...context, + claimProofReplay: () => replayResult, + }); + assert.equal(rejectedReplay.verified, false, name); + assert.equal(rejectedReplay.replay_protected, false, name); + assert.match(rejectedReplay.signature_verification_reason, /already used/, name); + } + const snakeCaseContext = detachedSignatureVerifier.verifyProof(envelope, profile, { handoff_version: 4, manifest: manifest(), @@ -1682,6 +1863,29 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => assert.equal(verified.verified, true, verified.signature_verification_reason); assert.equal(verified.verified_at, new Date(now).toISOString()); + for (const [name, proofDigest, trustedDigest] of [ + ['bare trusted digest', fields.artifactDigest, fields.artifactDigest.slice('sha256:'.length)], + ['uppercase trusted digest', fields.artifactDigest, fields.artifactDigest.toUpperCase()], + ['bare proof digest', fields.artifactDigest.slice('sha256:'.length), fields.artifactDigest], + ['uppercase proof digest', fields.artifactDigest.toUpperCase(), fields.artifactDigest], + ]) { + const malformedDigest = certificateVerifier.verifyProof( + { ...envelope, artifact_digest: proofDigest }, + profile, + { + ...context, + artifactDigest: trustedDigest, + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(malformedDigest.verified, false, name); + assert.match( + malformedDigest.signature_verification_reason ?? malformedDigest.reason, + /canonical lowercase SHA-256 digest/, + name, + ); + } + const invalidSkew = certificateVerifier.verifyProof(envelope, profile, { ...context, clockSkewSeconds: 'not-a-number', @@ -1779,6 +1983,30 @@ test('handoff v4 certificate proof signs its replay and validity controls', t => assert.match(unchecked.signature_verification_reason, /revocation backend unavailable/); assert.equal(uncheckedReplayClaims, 0); + for (const [name, replayResult] of [ + ['ambiguous-ok', { ok: true }], + ['conflicting-claim', { ok: true, claimed: false }], + ['conflicting-false-ok', { ok: false, claimed: true }], + ['null-result', null], + ['array-result', [{ claimed: true }]], + ['inherited-claim', Object.create({ claimed: true })], + ]) { + const replayFields = { ...fields, nonce: `certificate-proof-${name}` }; + const replaySigner = createSign('SHA256'); + replaySigner.update(buildCertificateV4SigningContent(replayFields)); + const rejectedReplay = certificateVerifier.verifyProof({ + ...envelope, + signature: replaySigner.sign(readFileSync(keyPath, 'utf8')).toString('base64'), + nonce: replayFields.nonce, + }, profile, { + ...context, + claimProofReplay: () => replayResult, + }); + assert.equal(rejectedReplay.verified, false, name); + assert.equal(rejectedReplay.replay_protected, false, name); + assert.match(rejectedReplay.signature_verification_reason, /already used/, name); + } + const snakeCaseContext = certificateVerifier.verifyProof(envelope, profile, { handoff_version: 4, manifest: manifest(), diff --git a/test/proof-evidence.test.js b/test/proof-evidence.test.js index a0db9e0..1d76983 100644 --- a/test/proof-evidence.test.js +++ b/test/proof-evidence.test.js @@ -95,6 +95,7 @@ function evidencePayload(overrides = {}) { stdin: 'secret-stdin', }, result: { + status: 'succeeded', exit_code: 0, timed_out: false, duration_ms: 10, @@ -143,8 +144,8 @@ function evidenceRecordForPayload(payload, overrides = {}) { : payload.command[field] ?? null, ])), result: Object.fromEntries([ - 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', - 'stderr_bytes', 'output_hash', + 'status', 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', + 'stderr_bytes', 'output_hash', 'structured_hash', ].map(field => [field, payload.result[field] ?? null])), verify: { passed: true, @@ -529,12 +530,76 @@ test('v4 evidence records require canonical artifact and child lineage bindings' { valid: true, errors: [] }, ); + for (const [field, value, expected] of [ + ['status', 'cancelled', /result\.status does not match/], + ['structured_hash', `sha256:${'e'.repeat(64)}`, /result\.structured_hash does not match/], + ]) { + const changedResult = validateEvidenceRecordBinding(rootPayload, { + ...rootRecord, + result: { ...rootRecord.result, [field]: value }, + }); + assert.equal(changedResult.valid, false, field); + assert.ok(changedResult.errors.some(error => expected.test(error)), field); + } + + for (const field of ['status', 'structured_hash']) { + const missingPayloadField = structuredClone(rootPayload); + delete missingPayloadField.result[field]; + const missingPayloadResult = validateEvidenceRecordBinding(missingPayloadField, rootRecord); + assert.equal(missingPayloadResult.valid, false, `payload ${field}`); + assert.ok(missingPayloadResult.errors.some(error => error.includes(`result.${field}`))); + + const missingRecordField = structuredClone(rootRecord); + delete missingRecordField.result[field]; + const missingRecordResult = validateEvidenceRecordBinding(rootPayload, missingRecordField); + assert.equal(missingRecordResult.valid, false, `record ${field}`); + assert.ok(missingRecordResult.errors.some(error => error.includes(`result.${field}`))); + } + + const nonCanonicalStructuredHashPayload = structuredClone(rootPayload); + const nonCanonicalStructuredHashRecord = structuredClone(rootRecord); + const uppercaseStructuredHash = `sha256:${'E'.repeat(64)}`; + nonCanonicalStructuredHashPayload.result.structured_hash = uppercaseStructuredHash; + nonCanonicalStructuredHashRecord.result.structured_hash = uppercaseStructuredHash; + const nonCanonicalStructuredHash = validateEvidenceRecordBinding( + nonCanonicalStructuredHashPayload, + nonCanonicalStructuredHashRecord, + ); + assert.equal(nonCanonicalStructuredHash.valid, false); + assert.ok(nonCanonicalStructuredHash.errors.some(error => /lowercase SHA-256 digest/.test(error))); + + const legacyPayload = evidencePayload(); + const legacyRecord = evidenceRecordForPayload(legacyPayload); + delete legacyPayload.result.status; + delete legacyPayload.result.structured_hash; + delete legacyRecord.result.status; + delete legacyRecord.result.structured_hash; + assert.deepEqual( + validateEvidenceRecordBinding(legacyPayload, legacyRecord), + { valid: true, errors: [] }, + ); + assert.deepEqual( + validateEvidenceRecordBinding(legacyPayload, { + ...legacyRecord, + parent_id: 'legacy-parent', + }), + { valid: true, errors: [] }, + ); + const missingPayload = evidencePayload(); const missingRecord = evidenceRecordForPayload(missingPayload, { handoff_version: 4 }); const missingArtifact = validateEvidenceRecordBinding(missingPayload, missingRecord); assert.equal(missingArtifact.valid, false); assert.ok(missingArtifact.errors.some(error => /handoff artifact digest/.test(error))); + const nestedV4Marker = validateEvidenceRecordBinding(missingPayload, { + ...missingRecord, + handoff_version: undefined, + handoff: { handoff_version: 4 }, + }); + assert.equal(nestedV4Marker.valid, false); + assert.ok(nestedV4Marker.errors.some(error => /handoff artifact digest/.test(error))); + const malformedPayload = structuredClone(rootPayload); malformedPayload.bindings.handoff_artifact_digest = 'not-a-digest'; const malformedRecord = evidenceRecordForPayload(malformedPayload, { From 6fe2790340ff2294359675f8b3c2d8a9a4aa8fa7 Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 05:45:31 -0400 Subject: [PATCH 28/29] Harden final v4 validation compatibility --- src/authorization-proof/jwt.js | 11 ++++- src/handoff/v4.js | 33 +++++++------ test/handoff-v4.test.js | 88 +++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index 35257f7..c9e85d0 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -831,7 +831,7 @@ const jwtVerifier = { if (signatureVerified) { try { verifiedKeyId = publicKeyId(context.trustedKey); - if (context.trustedKeyId && context.trustedKeyId !== verifiedKeyId) { + if (v4Required && context.trustedKeyId && context.trustedKeyId !== verifiedKeyId) { signatureVerified = false; signatureReason = 'trusted JWT key ID does not match the verified signing key'; } @@ -919,6 +919,7 @@ const jwtVerifier = { && artifactBound && replayProtected && revocationChecked; + const trustedKeyId = context.trustedKeyId || null; const result = { verified, @@ -936,7 +937,11 @@ const jwtVerifier = { replay_protected: replayProtected, revocation_checked: revocationChecked, decoded_claims: decodedClaims, - key_id: verifiedKeyId, + key_id: v4Required + ? verifiedKeyId + : (trustedKeyId || verifiedKeyId), + trusted_key_id: trustedKeyId, + verified_key_id: signatureVerified ? verifiedKeyId : null, key_source: context.trustedKeySource || null, manifest_digest: context.manifestDigest || null, verified_at: new Date(verificationNowMs).toISOString(), @@ -987,6 +992,8 @@ const jwtVerifier = { revocation_checked: result.revocation_checked === true, decoded_claims: result.decoded_claims || null, key_id: result.key_id || null, + trusted_key_id: result.trusted_key_id || null, + verified_key_id: result.verified_key_id || null, key_source: result.key_source || null, reason: result.reason || result.signature_verification_reason || null, }; diff --git a/src/handoff/v4.js b/src/handoff/v4.js index ef363bf..33ae762 100644 --- a/src/handoff/v4.js +++ b/src/handoff/v4.js @@ -896,22 +896,25 @@ export function validateSchedulerHandoffV4Artifact(input, { expectedDigest } = { if (!PRESENTATION_MEDIA.has(medium)) { errors.push('identity.presentation.handoff is unsupported'); } - for (const [index, binding] of (payload.identity?.presentation?.bindings ?? []).entries()) { - if (!binding || typeof binding !== 'object') { - errors.push(`identity.presentation.bindings[${index}] must be an object`); - continue; - } - if ('value' in binding || 'credential' in binding || 'secret' in binding || 'token' in binding) { - errors.push(`identity.presentation.bindings[${index}] contains raw credential material`); - } - if (!PRESENTATION_MEDIA.has(binding.medium)) { - errors.push(`identity.presentation.bindings[${index}].medium is unsupported`); - } else if (binding.medium !== 'none' && binding.medium !== medium) { - errors.push(`identity.presentation.bindings[${index}].medium does not match presentation handoff`); + const presentationBindings = payload.identity?.presentation?.bindings; + if (Array.isArray(presentationBindings)) { + for (const [index, binding] of presentationBindings.entries()) { + if (!binding || typeof binding !== 'object') { + errors.push(`identity.presentation.bindings[${index}] must be an object`); + continue; + } + if ('value' in binding || 'credential' in binding || 'secret' in binding || 'token' in binding) { + errors.push(`identity.presentation.bindings[${index}] contains raw credential material`); + } + if (!PRESENTATION_MEDIA.has(binding.medium)) { + errors.push(`identity.presentation.bindings[${index}].medium is unsupported`); + } else if (binding.medium !== 'none' && binding.medium !== medium) { + errors.push(`identity.presentation.bindings[${index}].medium does not match presentation handoff`); + } + validateHash(binding.source_hash, `identity.presentation.bindings[${index}].source_hash`, errors); + requiredBoolean(binding.required, `identity.presentation.bindings[${index}].required`, errors); + requiredBoolean(binding.redact, `identity.presentation.bindings[${index}].redact`, errors); } - validateHash(binding.source_hash, `identity.presentation.bindings[${index}].source_hash`, errors); - requiredBoolean(binding.required, `identity.presentation.bindings[${index}].required`, errors); - requiredBoolean(binding.redact, `identity.presentation.bindings[${index}].redact`, errors); } const proof = payload.authorization_proof ?? {}; diff --git a/test/handoff-v4.test.js b/test/handoff-v4.test.js index 84f03a1..6efdc6b 100644 --- a/test/handoff-v4.test.js +++ b/test/handoff-v4.test.js @@ -302,11 +302,12 @@ test('handoff v4 artifact is canonical, deterministic, and tamper evident', () = }); test('handoff v4 validation rejects missing identity fields and unknown properties', () => { - const payload = compileManifestToScheduler(manifest(), { + const compiled = compileManifestToScheduler(manifest(), { schedulerHandoffVersion: '4', cwd: '/tmp', env: { PATH: '/usr/bin' }, - }).jobs[0].handoff_artifact_payload; + }).jobs[0]; + const payload = compiled.handoff_artifact_payload; for (const path of [ ['manifest', 'workflow_id'], @@ -345,6 +346,33 @@ test('handoff v4 validation rejects missing identity fields and unknown properti assert.equal(credentialValidation.ok, false, field); assert.match(credentialValidation.errors.join('; '), new RegExp(`${field} is not allowed`)); } + + for (const malformedBindings of [ + {}, + null, + 'not-an-array', + { entries: [] }, + ]) { + const malformed = structuredClone(payload); + malformed.identity.presentation.bindings = malformedBindings; + const malformedValidation = validateSchedulerHandoffV4Artifact(malformed, { + expectedDigest: canonicalDigest(malformed), + }); + assert.equal(malformedValidation.ok, false); + assert.match( + malformedValidation.errors.join('; '), + /artifact\.identity\.presentation\.bindings must be array/, + ); + } + + const malformedJob = structuredClone(compiled); + malformedJob.handoff_artifact_payload.identity.presentation.bindings = {}; + malformedJob.handoff_artifact_digest = canonicalDigest(malformedJob.handoff_artifact_payload); + assert.throws( + () => assertValidSchedulerHandoffV4Job(malformedJob), + error => error.code === 'HANDOFF_ARTIFACT_INVALID' + && error.errors.some(message => /identity\.presentation\.bindings must be array/.test(message)), + ); }); test('handoff v4 schema accepts every valid cleanup policy and rejects unknown proof methods', () => { @@ -1336,8 +1364,24 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(verified.artifact_bound, true); assert.equal(verified.replay_protected, true); assert.equal(verified.revocation_checked, true); + assert.equal(verified.trusted_key_id, null); + assert.equal(verified.verified_key_id, verified.key_id); assert.equal(verified.verified_at, new Date(now * 1000).toISOString()); + const matchingKeyId = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'proof-matching-key-id' }, privateKey), + profile, + { + ...context, + trustedKeyId: verified.key_id, + claimProofReplay: () => ({ claimed: true }), + }, + ); + assert.equal(matchingKeyId.verified, true, matchingKeyId.reason); + assert.equal(matchingKeyId.key_id, verified.key_id); + assert.equal(matchingKeyId.trusted_key_id, verified.key_id); + assert.equal(matchingKeyId.verified_key_id, verified.key_id); + const replay = jwtVerifier.verifyProof(token, profile, context); assert.equal(replay.verified, false); assert.match(replay.reason, /replay/); @@ -1545,6 +1589,46 @@ test('handoff v4 JWT requires artifact binding, replay claim, and revocation che assert.equal(mismatchedKeyId.verified, false); assert.match(mismatchedKeyId.reason, /key ID does not match/); + const legacyLogicalKeyId = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'legacy-logical-key-id' }, privateKey), + profile, + { + requireSignature: true, + requireManifestBinding: true, + trustedKey: profile.public_key, + trustedKeyId: 'provider-jwks-key-id', + manifestDigest: payload.manifest_digest, + now: new Date(now * 1000), + }, + ); + assert.equal(legacyLogicalKeyId.verified, true, legacyLogicalKeyId.reason); + assert.equal(legacyLogicalKeyId.signature_verified, true); + assert.equal(legacyLogicalKeyId.key_id, 'provider-jwks-key-id'); + assert.equal(legacyLogicalKeyId.trusted_key_id, 'provider-jwks-key-id'); + assert.match(legacyLogicalKeyId.verified_key_id, /^spki-sha256:[a-f0-9]{64}$/); + assert.notEqual(legacyLogicalKeyId.verified_key_id, legacyLogicalKeyId.key_id); + const legacyDescription = jwtVerifier.describeVerification(legacyLogicalKeyId); + assert.equal(legacyDescription.key_id, 'provider-jwks-key-id'); + assert.equal(legacyDescription.trusted_key_id, 'provider-jwks-key-id'); + assert.equal(legacyDescription.verified_key_id, legacyLogicalKeyId.verified_key_id); + + const legacyDerivedKeyId = jwtVerifier.verifyProof( + signJwt({ ...payload, jti: 'legacy-derived-key-id' }, privateKey), + profile, + { + requireSignature: true, + requireManifestBinding: true, + trustedKey: profile.public_key, + manifestDigest: payload.manifest_digest, + now: new Date(now * 1000), + }, + ); + assert.equal(legacyDerivedKeyId.verified, true, legacyDerivedKeyId.reason); + assert.match(legacyDerivedKeyId.key_id, /^spki-sha256:[a-f0-9]{64}$/); + assert.equal(legacyDerivedKeyId.trusted_key_id, null); + assert.equal(legacyDerivedKeyId.verified_key_id, legacyDerivedKeyId.key_id); + assert.notEqual(legacyDerivedKeyId.key_id, 'test-key'); + const indeterminateRevocation = signJwt({ ...payload, jti: 'proof-indeterminate' }, privateKey); let indeterminateReplayClaims = 0; const indeterminateResult = jwtVerifier.verifyProof(indeterminateRevocation, profile, { From 24e25ddb97412d950106403508ecfa85cbbda73e Mon Sep 17 00:00:00 2001 From: kebabman9001 Date: Sun, 19 Jul 2026 07:34:23 -0400 Subject: [PATCH 29/29] Pin final v4 scheduler release candidate --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 295927c..bf038d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: matrix: node-version: ['22.13.0', '24.x'] env: - OPENCLAW_SCHEDULER_REF: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 + OPENCLAW_SCHEDULER_REF: dbd353e9eef4342fe4b5cdeb63e2ffa2006a5119 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check out pinned openclaw-scheduler uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 + ref: dbd353e9eef4342fe4b5cdeb63e2ffa2006a5119 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62abf67..e576cf6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest env: - OPENCLAW_SCHEDULER_REF: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 + OPENCLAW_SCHEDULER_REF: dbd353e9eef4342fe4b5cdeb63e2ffa2006a5119 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: amittell/openclaw-scheduler - ref: b6dd91180244b39e0c3c93e14a12d7c55d2ea0a6 + ref: dbd353e9eef4342fe4b5cdeb63e2ffa2006a5119 path: openclaw-scheduler - name: Configure scheduler fixture path run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV"