Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/execution-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 19 additions & 1 deletion src/compiler/openclaw-scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 *';
Expand All @@ -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,
Expand Down Expand Up @@ -61,6 +63,22 @@ function schedulerDeliveryOptOutReason(plan) {
return DELIVERY_OPT_OUT_REASON;
}

function schedulerVerificationShell(plan) {
const verifyShell = plan.verify?.shell ?? null;
if (verifyShell === null) 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 (
Expand Down Expand Up @@ -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,

Expand Down
8 changes: 6 additions & 2 deletions test/integration-scheduler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down Expand Up @@ -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 () => {
Expand Down
67 changes: 67 additions & 0 deletions test/scheduler-conformance.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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, {
Expand Down