From a3f8a1d2845133eafb8f8db13083849785af6dfd Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 13 Jul 2026 01:18:27 -0400 Subject: [PATCH 1/2] fix: preserve scheduler verification context --- CHANGELOG.md | 5 +++ docs/execution-identity.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/compiler/openclaw-scheduler.js | 20 ++++++++- test/integration-scheduler.test.js | 8 +++- test/scheduler-conformance.test.js | 67 ++++++++++++++++++++++++++++++ 7 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724d67b..3d90c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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 +- tests: added quoted-path, runtime-environment, expanded-length, and cross-version integration regressions for scheduler handoff + ## 0.4.0 (2026-07-11) - security: manual approvals now bind the canonical manifest and complete effective execution configuration, enforce `approver_scope` and `timeout_s`, reject unexpected unsigned records, and fail without writing a grant when signing fails diff --git a/docs/execution-identity.md b/docs/execution-identity.md index 613debd..b928259 100644 --- a/docs/execution-identity.md +++ b/docs/execution-identity.md @@ -1726,6 +1726,7 @@ This is a known limitation for local exec with long-running tasks. Mitigations: - only enter this phase when the main command exited successfully and a workflow/task `verify` block resolves - run the declared verify shell in the task's effective execution context +- for `openclaw-scheduler` shell handoff, bind verification to the task's `shell.cwd` and let the runtime supply the same sanitized, materialized execution environment used by the primary command; inline `shell.env` values remain ineligible for persistent scheduler compilation - record the postcondition outcome for the subsequent evidence binding - when `verify.on_failure` is `error`, return a non-zero status after cleanup and audit - when `verify.on_failure` is `warn`, record the verify failure as a warning without changing the exit code diff --git a/package-lock.json b/package-lock.json index 26ac88c..16c42d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amittell/agentcli", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amittell/agentcli", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "bin": { "agentcli": "bin/agentcli.js" diff --git a/package.json b/package.json index 4ddecf6..a6e191e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amittell/agentcli", - "version": "0.4.0", + "version": "0.4.1", "description": "Control plane for governed agent and CLI workflows with portable manifests, identity, approvals, and evidence.", "type": "module", "main": "./src/index.js", diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index af9af59..30d703c 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -10,6 +10,7 @@ import { } from './shared.js'; 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'; const TRIGGERED_SENTINEL_CRON = '0 0 31 2 *'; @@ -23,6 +24,7 @@ const DELIVERY_OPT_OUT_REASON = const SCHEDULER_STRING_LIMITS = { name: 200, payload_message: 100000, + verify_shell: 100000, agent_id: 128, schedule_cron: 128, schedule_tz: 128, @@ -61,6 +63,22 @@ function schedulerDeliveryOptOutReason(plan) { return DELIVERY_OPT_OUT_REASON; } +function schedulerVerificationShell(plan) { + const verifyShell = plan.verify?.shell ?? null; + if (!verifyShell) return null; + + const taskCwd = plan.execution.payload_kind === 'shellCommand' + ? plan.execution.payload?.cwd ?? null + : null; + if (!taskCwd) return verifyShell; + + return renderShellExecution({ + program: 'sh', + args: ['-c', verifyShell], + cwd: taskCwd, + }); +} + function isV2IdentityDeclaration(identity) { if (!identity || typeof identity !== 'object') return false; return ( @@ -380,7 +398,7 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } child_credential_policy: plan.child_credential_policy ?? null, // verify fields - verify_shell: plan.verify?.shell ?? null, + verify_shell: schedulerVerificationShell(plan), verify_timeout_s: plan.verify?.timeout_seconds ?? null, verify_on_failure: plan.verify?.on_failure ?? null, diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index 49734af..2a5b2eb 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -259,7 +259,7 @@ if (!schedulerRuntime.ok) { ); }); - it('apply sends v0.2 fields when scheduler supports handoff v2', { skip: v02RuntimeSkipReason || false }, async () => { + it('apply sends v0.2 fields at the negotiated scheduler handoff version', { skip: v02RuntimeSkipReason || false }, async () => { const v02Manifest = { version: '0.2', identity_profiles: [{ @@ -294,7 +294,11 @@ 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'); - assert.equal(result.handoff.field_version, '2', 'field_version should be 2'); + const expectedVersion = String(Math.min( + Number.parseInt(schedulerRuntime.capabilities.handoff_version, 10), + 3, + )); + assert.equal(result.handoff.field_version, expectedVersion, 'field_version should match the negotiated runtime version'); }); it('v0.2 identity fields are stored in scheduler', { skip: v02RuntimeSkipReason || false }, async () => { diff --git a/test/scheduler-conformance.test.js b/test/scheduler-conformance.test.js index a87dac2..bbc8e93 100644 --- a/test/scheduler-conformance.test.js +++ b/test/scheduler-conformance.test.js @@ -1,5 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { applyManifestToScheduler, @@ -113,6 +117,69 @@ test('scheduler compiler refuses inline shell environment and stdin persistence' ); }); +test('scheduler verification runs in the shell task cwd with the runtime environment', { + skip: process.platform === 'win32' ? 'scheduler shell handoff uses POSIX command rendering' : false, +}, () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-scheduler-verify-')); + const taskCwd = join(root, "work dir's"); + const dispatcherCwd = join(root, 'dispatcher'); + mkdirSync(taskCwd); + mkdirSync(dispatcherCwd); + + try { + const manifest = governedManifest(); + const task = manifest.workflows[0].tasks[0]; + task.approval = undefined; + task.shell = { + program: 'sh', + args: ['-c', 'printf ready > marker.txt'], + cwd: taskCwd, + }; + task.verify = { + shell: 'test "$VERIFY_RUNTIME_VALUE" = runtime && test -f marker.txt', + }; + + const job = compileManifestToScheduler(manifest).jobs[0]; + const env = { ...process.env, VERIFY_RUNTIME_VALUE: 'runtime' }; + const primary = spawnSync('/bin/sh', ['-c', job.payload_message], { + cwd: dispatcherCwd, + env, + encoding: 'utf8', + }); + const verify = spawnSync('/bin/sh', ['-c', job.verify_shell], { + cwd: dispatcherCwd, + env, + encoding: 'utf8', + }); + + assert.equal(primary.status, 0, primary.stderr); + assert.equal(verify.status, 0, verify.stderr); + assert.match(job.verify_shell, /^cd /); + assert.equal(job.verify_shell.includes('VERIFY_RUNTIME_VALUE=runtime'), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('scheduler compiler rejects a verifier whose cwd wrapper exceeds the runtime limit', () => { + const manifest = governedManifest(); + const task = manifest.workflows[0].tasks[0]; + task.approval = undefined; + task.shell = { + program: 'true', + cwd: '/tmp/a scheduler verification working directory', + }; + task.verify = { shell: 'x'.repeat(99_999) }; + + assert.throws( + () => compileManifestToScheduler(manifest), + error => error.validation?.errors?.some(item => ( + item.path.endsWith('.verify_shell') + && item.message.includes('exceeds max length of 100000') + )), + ); +}); + test('scheduler capability validation rejects unenforceable root gates, scopes, and output formats', () => { const compiled = compileManifestToScheduler(governedManifest()); const result = validateManifestCapabilities(compiled, { From 672920d36285b4cd7ad93948bbb29accec539f5c Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 13 Jul 2026 01:22:12 -0400 Subject: [PATCH 2/2] fix: preserve empty verifier semantics --- src/compiler/openclaw-scheduler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 30d703c..ee5a650 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -65,7 +65,7 @@ function schedulerDeliveryOptOutReason(plan) { function schedulerVerificationShell(plan) { const verifyShell = plan.verify?.shell ?? null; - if (!verifyShell) return null; + if (verifyShell === null) return null; const taskCwd = plan.execution.payload_kind === 'shellCommand' ? plan.execution.payload?.cwd ?? null