fix(openclaw): capture authenticated native task completion (#1589)

Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
Brad Groux 2026-09-09 23:40:58 -05:00 • committed by GitHub
parent 61c120352a
commit 8d5445ebd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1477 additions and 172 deletions

View file

@ -6,7 +6,7 @@
> Harness-specific supplements (for example `CLAUDE.md`) extend, never duplicate or contradict,
> these rules. See `docs/AGENTS-TEMPLATE.md` for the managed-run and external-agent protocols.
>
> **Version:** 6.1.3
> **Version:** 6.1.4
> **Freshness policy:** update within two working days of any toolchain or architecture change.
> Stale fields (package manager, Node version, provider list, test commands) are caught by
> `pnpm check:pnpm-settings` and the smoke-test CI job.
@ -330,13 +330,17 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
## Agent provider notes
### OpenClaw (v2026.6.11)
### OpenClaw (native tasks: v2026.9.2 or later)
- Task dispatch uses the gateway `/tools/invoke` endpoint with `sessions_spawn`.
- Native completion is server-owned: probe authenticated `agent.wait` before launch, persist the run ID
and immutable attempt bindings, and capture only the gateway terminal reply. Never give a child
a Veritas callback credential or infer completion from session history.
- Restart recovery resumes observation of the exact run; changed or missing identity requires recovery.
- **Required gateway policy:** `sessions_spawn` and `sessions_send` must be explicitly allowed
on the operator-level gateway; they are blocked by default on fresh OpenClaw installs.
- Set `OPENCLAW_GATEWAY_URL` (default `http://127.0.0.1:18789`) and optionally
`OPENCLAW_GATEWAY_TOKEN`.
- Set `OPENCLAW_GATEWAY_URL` (default `http://127.0.0.1:18789`) and
`OPENCLAW_GATEWAY_TOKEN` (required for native task completion).
- A pre-flight check is run before a task is marked active; policy denial returns an actionable
configuration error.
- See `docs/AGENT-PROVIDERS.md` § OpenClaw for full setup instructions.
@ -549,4 +553,4 @@ it.
| Codex / GPT | `AGENTS.md` plus Veritas task envelope | Canonical repository rules and managed-run contract |
| Claude Code | `AGENTS.md`, `CLAUDE.md`, and Veritas task envelope | Canonical rules plus Claude-specific lessons |
| Hermes | `AGENTS.md` plus Veritas task envelope | Hermes reads `AGENTS.md` from the worktree |
| OpenClaw | `AGENTS.md` plus the gateway task request | Canonical rules and callback completion contract |
| OpenClaw | `AGENTS.md` plus the gateway task request | Canonical rules and server-owned gateway completion |

View file

@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Clarified that native OpenClaw dispatch does not provision completion credentials. Generated instructions now require separately authenticated tooling and explain the localhost callback limitation; automatic authenticated delivery remains tracked in #1587.
- Added server-owned native OpenClaw completion through authenticated gateway terminal replies. Launch readiness, persisted run bindings, restart observation, and idempotent completion replace unauthenticated child callbacks; native tasks require OpenClaw v2026.9.2 or later (#1587).
- Restored Docker builds by moving browser/desktop command contracts into the shared package. The web build no longer depends on desktop source or test-only Node type declarations (#1578).

View file

@ -695,7 +695,7 @@ VK also documents the Codex and Hermes operating model:
### Any Platform (REST API)
These examples assume an already authenticated REST client. Native OpenClaw dispatch does not provision callback credentials for its child; see [OpenClaw completion authentication](docs/AGENT-PROVIDERS.md#completion-authentication-in-620) before relying on automatic completion.
These examples assume an already authenticated REST client. Native OpenClaw tasks use [server-owned gateway completion](docs/AGENT-PROVIDERS.md#server-owned-completion) and need no child callback credentials.
> 💡 **Using the CLI?** Skip the curl commands — `vk begin <id>` and `vk done <id> "summary"` handle the full lifecycle in one shot. See the [CLI Guide](docs/CLI-GUIDE.md) for details.

View file

@ -1279,87 +1279,101 @@ filtered out.
---
## OpenClaw (v2026.6.11)
## OpenClaw (native tasks: v2026.9.2 or later)
**Provider ID:** `openclaw`
**Tested version:** OpenClaw v2026.6.11
**Tested terminal contract:** OpenClaw v2026.9.2, build `3928bad`
### Overview
### Server-owned completion
OpenClaw task and workflow runs are dispatched through the OpenClaw gateway HTTP API using
`POST /tools/invoke` with the `sessions_spawn` tool. The spawn acknowledgement is the reachability
and policy check: if it fails, Veritas returns an actionable configuration error and rolls the
attempt back to `todo` rather than leaving it in a stuck `running` state. Veritas does not issue a
separate probe because OpenClaw v2026.6.11 ignores the endpoint's reserved `dryRun` field.
Starting with 6.2.1, native task completion is captured by Veritas through the authenticated
OpenClaw gateway. The child returns a small JSON completion report as its final reply; it
receives no Veritas API credential or callback URL. This resolves [#1587](https://github.com/BradGroux/veritas-kanban/issues/1587).
### Completion authentication in 6.2.0
Before creating an active attempt, Veritas authenticates to the configured gateway and probes
`agent.wait`. Native tasks require v2026.9.2 or later because the adapter consumes terminal
reply snapshots. The gateway's reported version becomes verified runtime-manifest evidence;
`OPENCLAW_GATEWAY_VERSION` cannot substitute for this check.
Native OpenClaw dispatch does **not** provision callback credentials or an authenticated completion tool for the spawned child. This remains an integration gap, tracked in [#1587](https://github.com/BradGroux/veritas-kanban/issues/1587), rather than a missing gateway setting. Successful `sessions_spawn` proves dispatch, not authenticated end-to-end completion.
Veritas dispatches with `POST /tools/invoke` and `sessions_spawn`, then persists the returned
run ID and child session key with the workspace, task, attempt, runtime manifest, task envelope,
and launch manifest identities. The server observes only that run through `agent.wait` and
passes a validated terminal claim to the existing completion authority. Session history,
ordinary chat messages, and a spawn acknowledgement are not completion evidence.
Both `/api/agents/:taskId/complete` and `/api/v1/agents/:taskId/complete` require Veritas API authentication and `task:write` permission. `attemptId` and `providerRuntimeManifestDigest` bind completion to the attempt; they are not credentials. `OPENCLAW_GATEWAY_TOKEN` authenticates requests to OpenClaw and does not authorize a callback to Veritas. The credential-broker exclusion described above does not provide another callback-authentication path.
The final reply must be a JSON object with `schemaVersion: "veritas-openclaw-completion/v1"`,
`status` (`success`, `failed`, or `blocked`), a `summary`, and an optional `error`. The generated
task instructions include the exact format. Runtime errors become failed results; missing or
invalid final reports become blocked results. Veritas still checks the task's completion
requirements before accepting success.
A separately trusted worker can use existing, operator-provisioned Veritas API authentication through its own completion tooling. That is a manually integrated REST client, not automatic native child provisioning. Configure its credentials outside the task prompt, use the narrowest available API access, and verify callback delivery from that worker with authentication enabled before relying on it. Existing general API credentials do not become task/attempt-scoped or single-use merely because an environment variable holds them. Veritas does not implement a native per-spawn credential/environment handoff here.
A wait timeout is not a failed run. Observation resumes after a Veritas restart from the saved
run binding, through the durable supervisor ownership checks. Lost connectivity, changed gateway
version/origin, missing identity, or an unresolved 20-minute observation deadline requires
operator recovery. Restore the bound gateway and restart Veritas to retry observation. The server
never relaunches a child to recover its result. Duplicate terminal
results use the existing idempotent completion path; mismatched or conflicting results cannot
replace the active attempt.
If that trusted completion path is unavailable, use a provider with harness-owned terminal capture, such as Codex CLI/SDK, rather than relying on a native OpenClaw task to complete automatically. Do not disable API authentication or distribute an administrator key to make the example work. For remote/container workers, the generated localhost:3001 callback address also requires an independently verified reachable origin; changing the origin does not solve authentication.
### Required gateway policy and setup
A completion-only capability is a possible implementation direction for #1587, not a configuration option in 6.2.0. It needs server-enforced scope, expiry, terminal revocation, replay/idempotency semantics, and a verified delivery channel before it can be documented as supported.
1. Run OpenClaw v2026.9.2 or later for native tasks. Workflow sessions retain their separate
v2026.6.11 transport contract.
2. Allow `sessions_spawn` in `gateway.tools.allow` and the active agent/tool profile. Add
`sessions_send` if using workflow session reuse. Apply the change through your gateway's
normal configuration procedure.
3. Set `OPENCLAW_GATEWAY_URL` in the **Veritas server** environment (default:
`http://127.0.0.1:18789`) and set `OPENCLAW_GATEWAY_TOKEN` to the configured gateway credential.
Native tasks require authentication even if the gateway otherwise permits anonymous access.
4. The server connection must be authorized for `agent.wait` (`operator.write`). Direct loopback
uses OpenClaw's documented backend helper authentication. Remote gateways require HTTPS and
must authorize this connection; a device-pairing requirement fails closed. The adapter does
not create device identities, bypass pairing, or copy a user's device credentials. A configured
secure tunnel to loopback is another deployment option.
5. Enable the OpenClaw provider profile in **Settings → Agents**. Use an agent ID configured in
the gateway. Check runtime readiness before dispatch.
### Required gateway tool policy
`sessions_spawn` and `sessions_send` are **blocked by default** in a fresh OpenClaw v2026.6.11
install at the operator-level endpoint. You must explicitly allow them:
1. Add `sessions_spawn` to `gateway.tools.allow` in the OpenClaw configuration.
2. Add `sessions_send` too if workflow session reuse is enabled.
3. Confirm the active agent/tool profile also permits these tools.
4. Save the configuration and restart the gateway.
### Setup
1. Run an OpenClaw v2026.6.11 instance locally or on a reachable host.
2. Configure the gateway tool policy (see above).
3. Set `OPENCLAW_GATEWAY_URL` to the gateway base URL (default: `http://127.0.0.1:18789`).
4. Optionally set `OPENCLAW_GATEWAY_TOKEN` for bearer-authenticated gateways.
5. Establish and verify the separately trusted completion path described above before enabling native task runs. Gateway policy alone does not provision it.
6. Enable the OpenClaw provider profile in **Settings → Agents**.
Only Veritas needs to reach the gateway. Remote/container children need no route back to Veritas.
Existing manual REST completion endpoints still require Veritas authentication and `task:write`;
the gateway credential does not authorize those endpoints.
### Environment variables
| Variable | Default | Purpose |
| -------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------- |
| `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | Gateway base URL |
| `OPENCLAW_GATEWAY_TOKEN` | _(none)_ | Bearer token for the gateway |
| `OPENCLAW_GATEWAY_SESSION_KEY` | `main` | Parent session key |
| `OPENCLAW_GATEWAY_ALLOW_PRIVATE` | `false` | Allow private IP gateway URLs |
| `OPENCLAW_GATEWAY_VERSION` | _(none)_ | Operator-declared version hint; the manifest remains degraded until runtime verification |
| Variable | Default | Purpose |
| -------------------------------- | ------------------------ | -------------------------------------------------------------------------------- |
| `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | Bound dispatch and completion gateway base URL; no embedded credentials or query |
| `OPENCLAW_GATEWAY_TOKEN` | _(none)_ | Server-owned gateway credential; required for native task completion |
| `OPENCLAW_GATEWAY_SESSION_KEY` | `main` | Parent session key |
| `OPENCLAW_GATEWAY_ALLOW_PRIVATE` | `false` | Permit private IP gateway addresses; remote completion still requires HTTPS |
| `OPENCLAW_GATEWAY_VERSION` | _(none)_ | Legacy workflow version hint; native tasks verify the gateway version directly |
### Dispatch flow
### Local integration check
1. Veritas calls `sessions_spawn` with the OpenClaw-owned task-envelope
transport, including the callback URL and required `attemptId` plus
`providerRuntimeManifestDigest` completion provenance.
2. A policy or connection failure rolls the task attempt back to `todo` with an error message.
3. OpenClaw returns a `childSessionKey` which Veritas stores in the attempt record.
4. The OpenClaw sub-session can report completion only through separately provisioned authenticated tooling. Without that operator-managed path, native dispatch does not provide automatic completion.
With the exact OpenClaw v2026.9.2 executable on `PATH`:
Late or replayed callbacks are rejected when either provenance value differs
from the active attempt.
```bash
pnpm --filter @veritas-kanban/shared build
VK_OPENCLAW_SMOKE=1 pnpm --filter @veritas-kanban/server exec vitest run src/__tests__/openclaw-completion.smoke.test.ts src/__tests__/codex-provider-service.test.ts -t @smoke
```
### Limitations
This starts a temporary loopback gateway with isolated configuration/state and a deterministic
local model endpoint. It launches actual OpenClaw child runs for success and failure and verifies
terminal replay through a new connection and completion through Veritas's normal attempt
lifecycle against an isolated task store. It uses no real model credential and does not alter the
operator's gateway. It tests the native transport contract, not hosted-model behavior or an
operator's production configuration.
- Callback authentication is not provisioned by native dispatch; see [Completion authentication in 6.2.0](#completion-authentication-in-620).
- Stop/cancel is not supported for individual sub-sessions in OpenClaw v2026.6.11. A stop request
logs a warning but cannot forcibly terminate the sub-session.
- Session resume is driven by the callback flow; no explicit `--resume` flag is used.
- OpenClaw v2026.6.11 does not accept per-spawn run timeouts. Configure
`agents.defaults.subagents.runTimeoutSeconds` in OpenClaw instead.
### Limitations and recovery
### Troubleshooting
- Veritas does not yet stop, resume, or send follow-ups to native task children. Terminal
observation after restart is not conversation resume.
- Gateway restarts can discard retained run results. If the exact run cannot be reconciled,
inspect it in OpenClaw before resolving the blocked Veritas attempt or starting more work.
- A crash after gateway acceptance but before the run binding is persisted requires manual
reconciliation; Veritas cannot safely guess which remote run belongs to the attempt.
- The observation deadline does not terminate the child. Configure child execution limits in
OpenClaw; no per-spawn timeout flag is assumed by the adapter.
- Older 6.2.0 native attempts have no run binding and retain their existing manual recovery path.
| Symptom | Fix |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| Callback returns `401` or `403` | Verify the separately provisioned Veritas authentication and `task:write` permission; the gateway token is not a callback credential |
| `sessions_spawn is not allowed` on start | Add `sessions_spawn` to `gateway.tools.allow`; add `sessions_send` for workflow reuse |
| `OpenClaw gateway did not respond` | Check `OPENCLAW_GATEWAY_URL` and gateway process is running |
| Task stuck in `running` after old request files appear | Old request-file artifacts can be safely deleted from `.veritas-kanban/agent-requests/` |
| `OpenClaw sessions_spawn did not return a child session key` | Verify the gateway is running OpenClaw v2026.6.11 or later |
Protocol references: [gateway authentication](https://docs.openclaw.ai/gateway/protocol/auth)
and [agent RPC methods](https://docs.openclaw.ai/gateway/protocol/rpc-talk-config-and-agents).

View file

@ -65,15 +65,15 @@ When Veritas Kanban launches this work:
## How each managed harness receives VK context
| Harness | VK transport | Agent-facing behavior |
| ------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Buzz Agent | ACP v1 stdio | Receives the immutable task envelope and selected run tools through the ACP session. Session load/resume is unavailable. |
| Grok Build | ACP v1 stdio | Receives the immutable task envelope and selected catalog in a dedicated `grok agent --no-leader ... stdio` process. |
| GitHub Copilot CLI | ACP v1 stdio | Receives the immutable task envelope and selected catalog with remote, plugins, custom instructions, and experimental features disabled. |
| OpenAI Codex CLI/SDK/app-server | Native process, SDK, or app-server stream | Receives the task envelope plus supported run-scoped MCP configuration. The adapter owns terminal capture. |
| Claude Code | Supervised bare-mode stream | Receives the task envelope and an explicit run-scoped MCP configuration. It does not inherit arbitrary local plugins, hooks, or MCP servers. |
| Hermes | Supervised one-shot process | Reads `AGENTS.md` from the assigned worktree and returns scripted stdout. Resume is unavailable. |
| OpenClaw | Gateway tool invocation | Receives the task request through the configured gateway and reports through the attempt-bound callback. |
| Harness | VK transport | Agent-facing behavior |
| ------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Buzz Agent | ACP v1 stdio | Receives the immutable task envelope and selected run tools through the ACP session. Session load/resume is unavailable. |
| Grok Build | ACP v1 stdio | Receives the immutable task envelope and selected catalog in a dedicated `grok agent --no-leader ... stdio` process. |
| GitHub Copilot CLI | ACP v1 stdio | Receives the immutable task envelope and selected catalog with remote, plugins, custom instructions, and experimental features disabled. |
| OpenAI Codex CLI/SDK/app-server | Native process, SDK, or app-server stream | Receives the task envelope plus supported run-scoped MCP configuration. The adapter owns terminal capture. |
| Claude Code | Supervised bare-mode stream | Receives the task envelope and an explicit run-scoped MCP configuration. It does not inherit arbitrary local plugins, hooks, or MCP servers. |
| Hermes | Supervised one-shot process | Reads `AGENTS.md` from the assigned worktree and returns scripted stdout. Resume is unavailable. |
| OpenClaw | Gateway tool invocation | Receives the task request through the configured gateway; Veritas captures its final report through authenticated gateway terminal observation. |
The current support tier is determined by runtime evidence, not this table.
Before enabling a profile, the operator must run:

View file

@ -4,7 +4,7 @@ Veritas Kanban 6.2.1 restores Docker builds for centrally hosted browser/server
The web build now reads command contracts from the shared package instead of importing desktop source. Docker builds no longer require the desktop workspace or a Node type declaration supplied only by test files. This addresses #1578.
The OpenClaw callback instructions now state the authentication prerequisite and localhost address limitation. Native child credential provisioning remains an open integration issue (#1587); this clarification does not add a completion token or change API authentication.
Native OpenClaw tasks now complete through the server's authenticated gateway connection. The child returns a final report without receiving a Veritas credential or callback URL. Persisted run bindings support restart observation and reject stale results; unavailable completion authentication blocks launch. Native tasks require OpenClaw v2026.9.2 or later (#1587).
## Updated

View file

@ -6,10 +6,10 @@ exports[`provider task-envelope renderers > renders a callback-free Claude Code
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:8e7a438fdc0475439622eee6dc2e00879466d807b6f1eddd7fe8da4160b3bc64\`
- Digest: \`sha256:68c6046d6263138cf372acbc26f3eb3452a89faa1ebc89e9ec350d1d754cf0eb\`
- Provider: \`claude-code\`
- Adapter: \`claude-code\`
- Runtime manifest: \`sha256:88e24bd4e81c1188c99ba752fa246b15e2cbe1610d8c7ebeb435a983e66dc348\`
- Runtime manifest: \`sha256:b0783b93f5dd5d7c8a7dc6fe71d977d61deb9a62d1c7b8c8c1aba96a88d9e8f4\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -97,10 +97,10 @@ exports[`provider task-envelope renderers > renders a callback-free Codex CLI tr
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:e12e89c8ebe506051d179c44fd53893ea8624b7ea44f3a11de888adf35b0145e\`
- Digest: \`sha256:6ce6a65225d891d12c4f6cbe78f4653c0b4ff7f988ae0993ec649d2954ebefe2\`
- Provider: \`codex-cli\`
- Adapter: \`codex-cli\`
- Runtime manifest: \`sha256:d8ad6b2c4913ff3b21f6ce902b77cb362401ef86f32aa4f3f28cbbd9d88082c9\`
- Runtime manifest: \`sha256:b9bfb7442bb3d31be56ed1a50e5a46b3a11edb153b5586030108bb2fa1c2896f\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -185,10 +185,10 @@ exports[`provider task-envelope renderers > renders a callback-free Codex SDK tr
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:a9aa614dee26324078c21a97e8821292b9b7fc1f42ec80db3320e3d440c20f8f\`
- Digest: \`sha256:47c956ce8c8faf76f5392dbc07b7a58bfd8a540d8fccf4fba06efcce6c9c1a95\`
- Provider: \`codex-sdk\`
- Adapter: \`codex-sdk\`
- Runtime manifest: \`sha256:a0d6c0f07b4c24419c37b4526f183951db10d5c6ec9bf3451f2daa1ef7cf368a\`
- Runtime manifest: \`sha256:44bb4a5be9a524568ba9175218a680b565c2f1b8f4b0aaeef3321e09862496b0\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -274,10 +274,10 @@ exports[`provider task-envelope renderers > renders a callback-free Codex app-se
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:752997c167a25a9ec8781076a8e8c093d6b9f1fdf269c18797f346bfdb1de341\`
- Digest: \`sha256:ad4711e35d3880327fdd2c6c6f4f9ad1f26905c88df6ec336be5b9c5708673fb\`
- Provider: \`codex-app-server\`
- Adapter: \`codex-app-server\`
- Runtime manifest: \`sha256:d5a32f42bfe25fc1a2d4e93a35b31f1cded947bfbf24041b8fd96ebd86c299a3\`
- Runtime manifest: \`sha256:1ffd607a48e4863b3ff2a3c2ba07a63a3257e8e6fc224bd4569be6f5b85e9a52\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -365,10 +365,10 @@ exports[`provider task-envelope renderers > renders a callback-free Hermes scrip
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:61f6a557435b2ec3fabc10cf9ef6a190b8bb882910fe6ada1855597b8b119215\`
- Digest: \`sha256:e5b16872911eb3db1518b3336d057d9971558a835d4ee86a4096c03bba5eaf6b\`
- Provider: \`hermes-cli\`
- Adapter: \`hermes-cli\`
- Runtime manifest: \`sha256:105fc11b1236086a7d0181f73ac7201e519ef667c27ad7f7b71d44a28ee4360c\`
- Runtime manifest: \`sha256:52d653db2a011818561aeaa75341663d2f7a225b7ea997f7296e19bca97b1480\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -450,16 +450,16 @@ Do not call the Veritas completion callback. The harness owns terminal-state cap
No native structured-output support is assumed; Veritas validates and normalizes scripted process output."
`;
exports[`provider task-envelope renderers > renders an OpenClaw callback transport from a required-commit envelope 1`] = `
exports[`provider task-envelope renderers > renders an OpenClaw gateway completion transport from a required-commit envelope 1`] = `
"# OpenClaw Task Envelope
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:6fb7e306ba5d86aa966cc32814ac2ae33915b7c55c68a8455171ff6a7a795c0f\`
- Digest: \`sha256:aa1cec7fb29b6b0fb40c7c9f80309ed4dfa93f4116864695d91dff0178e6262c\`
- Provider: \`openclaw\`
- Adapter: \`openclaw\`
- Runtime manifest: \`sha256:be3844c45a6aef384fb2c6d8dd0e15365b9cd06170272607396278beb052db68\`
- Runtime manifest: \`sha256:029694eea4481d65f9c35ff846b84e6ab3e0d2cc2c403ec4964fc056fd2472da\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -549,23 +549,15 @@ Follow the release checklist.
Continue from this checkpoint. Do not repeat work already represented in the saved state.
## Completion (OpenClaw callback)
## Completion (OpenClaw gateway)
When the work reaches a terminal state, report it through an operator-provisioned authenticated completion tool.
Native OpenClaw dispatch does not provision callback credentials. Before starting work, confirm that the operator has separately configured a trusted completion tool with Veritas API authentication and \`task:write\` permission. The gateway token authenticates Veritas to OpenClaw; it is not a Veritas callback credential. The attempt and manifest identifiers below are provenance, not authentication.
If authenticated completion tooling is unavailable, report that blocker in the OpenClaw session for the operator to resolve. Do not send an unauthenticated callback, obtain an administrator key, disable authentication, or claim that Veritas recorded completion. Do not place credential values in the task prompt or completion payload.
- Endpoint: \`POST http://localhost:3001/api/agents/task_transport/complete\`
- This generated address assumes Veritas is reachable at localhost:3001 from the worker. A remote or container worker requires an operator-verified callback address as well as authentication.
- Use the existing completion tool's authenticated request mechanism with this JSON payload:
End the run with a single JSON object describing the actual outcome:
\`\`\`json
{"attemptId":"attempt_transport","providerRuntimeManifestDigest":"sha256:be3844c45a6aef384fb2c6d8dd0e15365b9cd06170272607396278beb052db68","success":true,"summary":"Brief description of what was done"}
{"schemaVersion":"veritas-openclaw-completion/v1","status":"success","summary":"What changed and how it was verified"}
\`\`\`
For failure, send \`success: false\` and include an \`error\` message.
Use \`status: "failed"\` when the work failed, or \`status: "blocked"\` when it cannot proceed, and include an \`error\` string explaining why. Do not report success unless the task's completion requirements are satisfied.
No native structured-output support is assumed; Veritas validates and normalizes the callback."
Veritas reads this final reply through its authenticated OpenClaw gateway connection and validates the persisted run identity before recording completion. No Veritas credential or callback request is needed in the child session. Do not call the Veritas completion callback or request an API key. A normal chat message is not a recorded Veritas completion; the server owns terminal-state capture."
`;

View file

@ -80,7 +80,7 @@ describe('ACP v1 stdio provider adapter', () => {
protocolVersion: 'acp/v1',
providerVersion: 'VK ACP fixture 1.3.0',
providerBuild: expect.stringMatching(/^acp-v1:sha256:[a-f0-9]{64}$/),
probeRevision: 16,
probeRevision: 17,
});
expect(manifest.capabilities.find((capability) => capability.id === 'run.resume')?.state).toBe(
'supported'
@ -133,7 +133,7 @@ describe('ACP v1 stdio provider adapter', () => {
adapter: 'acp-stdio',
providerVersion: 'buzz-agent 0.1.0',
providerBuild: expect.stringMatching(/profile:buzz-agent@1:sha256:/),
probeRevision: 16,
probeRevision: 17,
probe: {
diagnostics: expect.arrayContaining([
expect.stringContaining(BUZZ_AGENT_TESTED_RELEASE),
@ -356,7 +356,7 @@ describe('ACP v1 stdio provider adapter', () => {
adapter: 'acp-stdio',
providerVersion: 'Copilot 1.0.74',
providerBuild: expect.stringMatching(/profile:github-copilot-cli@1:sha256:/),
probeRevision: 16,
probeRevision: 17,
probe: {
diagnostics: expect.arrayContaining([
expect.stringContaining(COPILOT_ACP_TESTED_RELEASE),
@ -515,7 +515,7 @@ describe('ACP v1 stdio provider adapter', () => {
adapter: 'acp-stdio',
providerVersion: `Grok Build ${GROK_BUILD_ACP_VERSION}`,
providerBuild: expect.stringMatching(/profile:grok-build@1:sha256:/),
probeRevision: 16,
probeRevision: 17,
probe: {
diagnostics: expect.arrayContaining([
expect.stringContaining(GROK_BUILD_TESTED_RELEASE),

View file

@ -228,6 +228,8 @@ import {
type CompletionEvidenceSource,
} from '../services/task-envelope-service.js';
import { ProviderCompletionService } from '../services/provider-completion-service.js';
import { HttpOpenClawTaskAdapter } from '../services/openclaw-workflow-adapter.js';
import { startOpenClawCompletionFixture } from './fixtures/openclaw-completion-gateway.js';
import type { ReflectionExtractionJobService } from '../services/reflection-extraction-job-service.js';
import type {
CreateRunApprovalRequestInput,
@ -878,6 +880,177 @@ describe('ClawdbotAgentService Codex providers', () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it.each(['success', 'failed'] as const)(
'records native OpenClaw %s through the normal attempt lifecycle',
async (outcome) => {
task = {
...task,
agent: 'openclaw',
subtasks: task.subtasks?.map((item) => ({ ...item, completed: true })),
verificationSteps: task.verificationSteps?.map((item) => ({ ...item, checked: true })),
};
mockGetConfig.mockResolvedValue({
agents: [
{
type: 'openclaw',
name: 'OpenClaw',
command: 'openclaw',
args: [],
enabled: true,
provider: 'openclaw',
},
],
});
const readiness = vi
.spyOn(HttpOpenClawTaskAdapter.prototype, 'probeCompletion')
.mockResolvedValue({ gatewayUrl: 'http://127.0.0.1:18789', version: '2026.9.2' });
const spawnTask = vi.spyOn(HttpOpenClawTaskAdapter.prototype, 'spawnTask').mockResolvedValue({
sessionKey: 'agent:openclaw:subagent:fixture',
runId: 'run-fixture',
status: 'accepted',
});
const wait = vi.spyOn(HttpOpenClawTaskAdapter.prototype, 'waitForRun').mockResolvedValue({
version: '2026.9.2',
result: {
runId: 'run-fixture',
status: 'ok',
endedAt: Date.now(),
terminalReply: {
disposition: 'visible',
text: JSON.stringify({
schemaVersion: 'veritas-openclaw-completion/v1',
status: outcome,
summary: 'Native completion lifecycle verified',
}),
},
},
});
try {
const service = testableService(tmpDir);
const active = await service.startAgent(task.id, 'openclaw', { commitPolicy: 'allowed' });
expect(active.status).toBe('running');
await vi.waitFor(() => expect(task.attempt?.completionResult?.status).toBe(outcome));
expect(task.attempt?.completionResult?.terminalSource).toBe('remote-session');
expect(task.attempt?.openclawRun).toMatchObject({
runId: 'run-fixture',
attemptId: active.attemptId,
providerRuntimeManifestDigest: active.providerRuntimeManifest.digest,
});
expect(spawnTask).toHaveBeenCalledOnce();
expect(spawnTask.mock.calls[0][0].prompt).not.toContain('/api/agents/');
expect(readiness).toHaveBeenCalledTimes(2);
expect(wait).toHaveBeenCalledWith('run-fixture', 30_000, expect.any(AbortSignal));
const updated = mockUpdateTask.mock.calls.length;
await service.completeAgent(
task.id,
{ status: outcome, summary: 'Native completion lifecycle verified' },
{
attemptId: active.attemptId,
providerRuntimeManifestDigest: active.providerRuntimeManifest.digest,
terminalSource: 'remote-session',
}
);
expect(mockUpdateTask.mock.calls.length).toBe(updated);
} finally {
readiness.mockRestore();
spawnTask.mockRestore();
wait.mockRestore();
}
}
);
it('rejects OpenClaw without completion readiness before creating an active attempt', async () => {
mockGetConfig.mockResolvedValue({
agents: [
{
type: 'openclaw',
name: 'OpenClaw',
command: 'openclaw',
args: [],
enabled: true,
provider: 'openclaw',
},
],
});
const readiness = vi
.spyOn(HttpOpenClawTaskAdapter.prototype, 'probeCompletion')
.mockRejectedValue(new Error('Completion authentication unavailable'));
const spawnTask = vi.spyOn(HttpOpenClawTaskAdapter.prototype, 'spawnTask');
try {
await expect(testableService(tmpDir).startAgent(task.id, 'openclaw')).rejects.toThrow(
'Completion authentication unavailable'
);
expect(task.attempt).toBeUndefined();
expect(task.status).toBe('todo');
expect(mockUpdateTask).not.toHaveBeenCalled();
expect(spawnTask).not.toHaveBeenCalled();
} finally {
readiness.mockRestore();
spawnTask.mockRestore();
}
});
it.skipIf(process.env.VK_OPENCLAW_SMOKE !== '1')(
'@smoke native OpenClaw child reaches persisted success and failure',
async () => {
const { spawn: actualSpawn } =
await vi.importActual<typeof import('node:child_process')>('node:child_process');
const fixture = await startOpenClawCompletionFixture(actualSpawn);
vi.stubEnv('OPENCLAW_GATEWAY_URL', fixture.gatewayUrl);
vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', fixture.token);
try {
mockGetConfig.mockResolvedValue({
agents: [
{
type: 'openclaw',
name: 'OpenClaw',
command: 'openclaw',
args: [],
enabled: true,
provider: 'openclaw',
},
],
});
for (const outcome of ['success', 'failed'] as const) {
task = {
...task,
id: `task_native_openclaw_${outcome}`,
attempt: undefined,
attempts: [],
status: 'todo',
agent: 'openclaw',
description: `${task.description} ${outcome === 'failed' ? 'FIXTURE_OUTCOME_FAILED' : 'FIXTURE_OUTCOME_SUCCESS'}`,
subtasks: task.subtasks?.map((item) => ({ ...item, completed: true })),
verificationSteps: task.verificationSteps?.map((item) => ({ ...item, checked: true })),
};
const service = testableService(tmpDir);
const active = await service.startAgent(task.id, 'openclaw', { commitPolicy: 'allowed' });
await vi.waitFor(() => expect(task.attempt?.completionResult?.status).toBe(outcome), {
timeout: 30_000,
interval: 100,
});
expect(task.attempt?.completionResult).toMatchObject({
terminalSource: 'remote-session',
summary: 'Isolated native child completed',
});
expect(task.attempt?.openclawRun).toMatchObject({
taskId: task.id,
attemptId: active.attemptId,
providerRuntimeManifestDigest: active.providerRuntimeManifest.digest,
});
expect(task.status).toBe(outcome === 'success' ? 'done' : 'in-progress');
expect(task.attempt?.status).toBe(outcome === 'success' ? 'complete' : 'failed');
const persisted = JSON.parse(JSON.stringify(task));
expect(persisted.attempt.completionResult.attemptId).toBe(active.attemptId);
expect(fixture.modelCalls).toBeGreaterThan(0);
}
} finally {
await fixture.close();
}
},
90_000
);
it('isolates independent roots and preserves identity across provider handoffs', () => {
const service = testableService(tmpDir);
const buildExecutionTreeIdentity = (

View file

@ -0,0 +1,181 @@
import { expect, vi } from 'vitest';
import { createServer, type Server } from 'node:http';
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { HttpOpenClawTaskAdapter } from '../../services/openclaw-workflow-adapter.js';
export async function startOpenClawCompletionFixture(spawnGateway: typeof spawn = spawn) {
let root: string;
let modelServer: Server;
let gatewayProcess: ChildProcess;
let adapter: HttpOpenClawTaskAdapter;
let gatewayLog = '';
let modelCalls = 0;
async function close() {
if (gatewayProcess && gatewayProcess.exitCode === null && gatewayProcess.signalCode === null) {
const exited = once(gatewayProcess, 'exit');
gatewayProcess.kill('SIGTERM');
const timer = setTimeout(() => gatewayProcess.kill('SIGKILL'), 5_000);
timer.unref();
try {
await exited;
} finally {
clearTimeout(timer);
}
}
if (modelServer) await new Promise<void>((resolve) => modelServer.close(() => resolve()));
if (root) await rm(root, { recursive: true, force: true });
}
root = await mkdtemp(path.join(tmpdir(), 'vk-openclaw-completion-'));
const workspace = path.join(root, 'workspace');
await mkdir(workspace);
// No real provider credential, external channel, or user gateway configuration is loaded.
modelServer = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString();
if (request.url === '/v1/models') {
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ data: [{ id: 'completion-fixture' }] }));
return;
}
modelCalls++;
const report = JSON.stringify({
schemaVersion: 'veritas-openclaw-completion/v1',
status: body.includes('FIXTURE_OUTCOME_FAILED') ? 'failed' : 'success',
summary: 'Isolated native child completed',
...(body.includes('FIXTURE_OUTCOME_FAILED') ? { error: 'Deliberate fixture failure' } : {}),
});
const parsed = JSON.parse(body || '{}');
if (parsed.stream) {
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.write(
`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', model: 'completion-fixture', choices: [{ index: 0, delta: { role: 'assistant', content: report }, finish_reason: null }] })}\n\n`
);
response.write(
`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', model: 'completion-fixture', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } })}\n\n`
);
response.end('data: [DONE]\n\n');
} else {
response.setHeader('content-type', 'application/json');
response.end(
JSON.stringify({
id: 'fixture',
object: 'chat.completion',
model: 'completion-fixture',
choices: [
{ index: 0, message: { role: 'assistant', content: report }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
})
);
}
});
modelServer.listen(0, '127.0.0.1');
await once(modelServer, 'listening');
const modelAddress = modelServer.address();
if (!modelAddress || typeof modelAddress === 'string') throw new Error('model address missing');
const portReservation = createServer();
portReservation.listen(0, '127.0.0.1');
await once(portReservation, 'listening');
const gatewayAddress = portReservation.address();
if (!gatewayAddress || typeof gatewayAddress === 'string')
throw new Error('gateway address missing');
await new Promise<void>((resolve) => portReservation.close(() => resolve()));
const token = randomUUID();
const configPath = path.join(root, 'openclaw.json');
await writeFile(
configPath,
JSON.stringify({
gateway: {
mode: 'local',
bind: 'loopback',
port: gatewayAddress.port,
auth: { mode: 'token', token },
tools: { allow: ['sessions_spawn'] },
},
agents: {
defaults: {
workspace,
model: { primary: 'fixture/completion-fixture' },
heartbeat: { every: '0m' },
},
list: [{ id: 'openclaw', default: true, workspace }],
},
models: {
mode: 'replace',
providers: {
fixture: {
baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`,
api: 'openai-completions',
apiKey: 'fixture-only',
models: [
{
id: 'completion-fixture',
name: 'Completion fixture',
reasoning: false,
input: ['text'],
contextWindow: 131072,
maxTokens: 512,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
tools: { profile: 'coding' },
plugins: { enabled: true },
}),
{ mode: 0o600 }
);
gatewayProcess = spawnGateway(
process.env.OPENCLAW_EXECUTABLE || 'openclaw',
['gateway', 'run', '--port', String(gatewayAddress.port), '--bind', 'loopback'],
{
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: root,
OPENCLAW_NO_RESPAWN: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
}
);
gatewayProcess.stdout?.on('data', (chunk) => {
gatewayLog = (gatewayLog + chunk.toString()).slice(-12_000);
});
gatewayProcess.stderr?.on('data', (chunk) => {
gatewayLog = (gatewayLog + chunk.toString()).slice(-12_000);
});
adapter = new HttpOpenClawTaskAdapter({
gatewayUrl: `http://127.0.0.1:${gatewayAddress.port}`,
token,
});
try {
await vi.waitFor(
async () => expect((await adapter.probeCompletion()).version).toMatch(/^2026\.9\.2/),
{ timeout: 45_000, interval: 500 }
);
} catch (error) {
await close();
throw new Error(`${String(error)}\n${gatewayLog.replaceAll(token, '[REDACTED]')}`, {
cause: error,
});
}
return {
adapter,
gatewayUrl: `http://127.0.0.1:${gatewayAddress.port}`,
token,
get modelCalls() {
return modelCalls;
},
close,
};
}

View file

@ -14,7 +14,7 @@ describe('HarnessCompatibilityMatrixService', () => {
expect(matrix).toMatchObject({
schemaVersion: 'harness-compatibility-matrix/v1',
generatedAt: NOW.toISOString(),
probeRevision: 16,
probeRevision: 17,
});
expect(matrix.records.map((record) => record.agentType)).toEqual([
'buzz-agent',

View file

@ -0,0 +1,196 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Task, TaskAttempt } from '@veritas-kanban/shared';
import {
OpenClawCompletionService,
assertOpenClawRunBinding,
normalizeOpenClawTerminal,
openClawCompletionProbeSource,
type OpenClawRunBinding,
} from '../services/openclaw-completion-service.js';
import type { HttpOpenClawTaskAdapter } from '../services/openclaw-workflow-adapter.js';
const binding: OpenClawRunBinding = {
schemaVersion: 'openclaw-task-run/v1',
gatewayUrl: 'http://127.0.0.1:18789',
gatewayVersion: '2026.9.2',
runId: 'run-1',
sessionKey: 'child-1',
workspaceId: 'workspace-1',
taskId: 'task-1',
attemptId: 'attempt-1',
providerRuntimeManifestDigest: 'runtime-digest',
taskEnvelopeDigest: 'envelope-digest',
runLaunchManifestDigest: 'launch-digest',
observeUntil: '2026-09-10T04:30:00Z',
};
function task(): Task & { attempt: TaskAttempt & { openclawRun: OpenClawRunBinding } } {
return {
id: binding.taskId,
attempt: {
id: binding.attemptId,
status: 'running',
provider: 'openclaw',
sessionKey: binding.sessionKey,
openclawRun: { ...binding },
providerRuntimeManifest: {
digest: binding.providerRuntimeManifestDigest,
providerVersion: binding.gatewayVersion,
probe: { source: openClawCompletionProbeSource(binding.gatewayUrl) },
},
taskEnvelope: {
digest: binding.taskEnvelopeDigest,
workspace: { workspaceId: binding.workspaceId },
},
runLaunchManifest: { digest: binding.runLaunchManifestDigest },
},
} as Task & { attempt: TaskAttempt & { openclawRun: OpenClawRunBinding } };
}
function terminal(status = 'success') {
return {
version: '2026.9.2',
result: {
runId: 'run-1',
status: 'ok' as const,
endedAt: 100,
terminalReply: {
disposition: 'visible',
text: JSON.stringify({
schemaVersion: 'veritas-openclaw-completion/v1',
status,
summary: 'Verified result',
}),
},
},
};
}
function fixture(response = terminal()) {
const current = task();
const host = {
getTask: vi.fn(async () => current),
complete: vi.fn(async () => {}),
requireRecovery: vi.fn(async () => {}),
};
const adapter = { assertGatewayBinding: vi.fn(), waitForRun: vi.fn(async () => response) };
const service = new OpenClawCompletionService(
host,
() => adapter as unknown as HttpOpenClawTaskAdapter,
() => Date.parse('2026-09-10T04:00:00Z')
);
return { service, host, adapter, current };
}
afterEach(() => vi.restoreAllMocks());
describe('OpenClaw terminal normalization', () => {
it.each(['success', 'failed', 'blocked'])(
'preserves an explicit %s report from the terminal reply',
(status) => {
expect(normalizeOpenClawTerminal(terminal(status).result)).toMatchObject({
status,
terminalSource: 'remote-session',
summary: 'Verified result',
});
}
);
it('does not treat gateway wait timeout or pending queue state as completion', () => {
expect(normalizeOpenClawTerminal({ runId: 'run-1', status: 'timeout' })).toBeNull();
expect(normalizeOpenClawTerminal({ runId: 'run-1', status: 'pending' })).toBeNull();
});
it('preserves a runtime failure even if reply text claims success', () => {
expect(
normalizeOpenClawTerminal({ ...terminal().result, status: 'error', error: 'Provider failed' })
).toMatchObject({ status: 'failed', error: 'Provider failed' });
});
it.each([
undefined,
{ disposition: 'visible', text: 'Done!' },
{ disposition: 'hidden', text: terminal().result.terminalReply.text },
])('blocks unverifiable or non-visible final output', (terminalReply) => {
expect(normalizeOpenClawTerminal({ ...terminal().result, terminalReply })).toMatchObject({
status: 'blocked',
});
});
});
describe('OpenClaw completion ownership', () => {
it.each([
['workspaceId', 'other'],
['taskId', 'other'],
['attemptId', 'other'],
['sessionKey', 'other'],
['runId', 'other'],
['providerRuntimeManifestDigest', 'other'],
['taskEnvelopeDigest', 'other'],
['runLaunchManifestDigest', 'other'],
['gatewayUrl', 'https://other.invalid'],
['gatewayVersion', 'other'],
['observeUntil', 'invalid'],
])('rejects a mismatched %s', (field, value) => {
expect(() => assertOpenClawRunBinding(task(), { ...binding, [field]: value })).toThrow(
/does not match/
);
});
it('rejects another provider even with the same identifiers', () => {
const current = task();
current.attempt.provider = 'codex-cli';
expect(() => assertOpenClawRunBinding(current, binding)).toThrow();
});
it('completes once through the attempt authority despite duplicate monitor starts', async () => {
const { service, host, adapter } = fixture();
service.start(binding);
service.start(binding);
await vi.waitFor(() => expect(host.complete).toHaveBeenCalledTimes(1));
expect(host.complete).toHaveBeenCalledWith(
binding,
expect.objectContaining({ status: 'success', terminalSource: 'remote-session' })
);
expect(adapter.waitForRun).toHaveBeenCalledTimes(1);
expect(host.requireRecovery).not.toHaveBeenCalled();
});
it('discards a terminal response after the active attempt changes', async () => {
const { service, host, adapter, current } = fixture();
adapter.waitForRun.mockImplementation(async () => {
current.attempt.id = 'new-attempt';
return terminal();
});
service.start(binding);
await vi.waitFor(() => expect(adapter.waitForRun).toHaveBeenCalled());
expect(host.complete).not.toHaveBeenCalled();
});
it('requires recovery on gateway failure without inventing a terminal result', async () => {
const { service, host, adapter } = fixture();
adapter.waitForRun.mockRejectedValue(new Error('socket gone'));
service.start(binding);
await vi.waitFor(() => expect(host.requireRecovery).toHaveBeenCalledTimes(1));
expect(host.complete).not.toHaveBeenCalled();
});
it('requires recovery on a changed gateway build', async () => {
const { service, host } = fixture({ ...terminal(), version: '2026.9.3' });
service.start(binding);
await vi.waitFor(() => expect(host.requireRecovery).toHaveBeenCalledTimes(1));
expect(host.complete).not.toHaveBeenCalled();
});
it('reconstructs observation from persisted identity without spawning another child', async () => {
const { service, host, adapter } = fixture();
expect(await service.canRecover(task())).toBe(true);
service.start(JSON.parse(JSON.stringify(binding)));
await vi.waitFor(() => expect(host.complete).toHaveBeenCalledOnce());
expect(adapter.waitForRun).toHaveBeenNthCalledWith(1, 'run-1', 0);
});
it('does not recover a mismatched gateway or expired observation', async () => {
const { service, adapter } = fixture();
adapter.assertGatewayBinding.mockImplementation(() => {
throw new Error('changed');
});
expect(await service.canRecover(task())).toBe(false);
expect(adapter.waitForRun).not.toHaveBeenCalled();
const expired = task();
expired.attempt.openclawRun.observeUntil = '2026-09-09T00:00:00Z';
const expiredFixture = fixture();
expiredFixture.adapter.waitForRun.mockResolvedValue({
version: '2026.9.2',
result: { runId: 'run-1', status: 'timeout' },
} as ReturnType<typeof terminal>);
expect(await expiredFixture.service.canRecover(expired)).toBe(false);
expect(await fixture().service.canRecover(expired)).toBe(true);
});
});

View file

@ -0,0 +1,47 @@
/** @smoke OpenClaw 2026.9.2: real child runs, isolated gateway, deterministic local model. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { randomUUID } from 'node:crypto';
import { normalizeOpenClawTerminal } from '../services/openclaw-completion-service.js';
import { startOpenClawCompletionFixture } from './fixtures/openclaw-completion-gateway.js';
describe.skipIf(process.env.VK_OPENCLAW_SMOKE !== '1')(
'native OpenClaw completion @smoke v2026.9.2',
() => {
let fixture: Awaited<ReturnType<typeof startOpenClawCompletionFixture>>;
beforeAll(async () => {
fixture = await startOpenClawCompletionFixture();
}, 60_000);
afterAll(async () => {
await fixture?.close();
});
it.each(['success', 'failed'] as const)(
'captures an actual child %s terminal result without a callback',
async (status) => {
const spawned = await fixture.adapter.spawnTask({
taskId: `task-${status}`,
attemptId: randomUUID(),
agentId: 'openclaw',
prompt: `Reply with the completion JSON only. ${status === 'failed' ? 'FIXTURE_OUTCOME_FAILED' : 'FIXTURE_OUTCOME_SUCCESS'}`,
timeoutSeconds: 60,
});
expect(spawned.runId).toBeTruthy();
expect(spawned.sessionKey).toContain('subagent');
let claim = null;
for (let index = 0; index < 10 && !claim; index++) {
const observed = await fixture.adapter.waitForRun(spawned.runId, 5_000);
claim = normalizeOpenClawTerminal(observed.result);
}
expect(claim, JSON.stringify(claim)).toMatchObject({
terminalSource: 'remote-session',
status,
summary: 'Isolated native child completed',
});
expect(fixture.modelCalls).toBeGreaterThan(0);
// A new server connection recovers the same terminal result using only the saved run ID.
const recovered = await fixture.adapter.waitForRun(spawned.runId, 0);
expect(normalizeOpenClawTerminal(recovered.result)).toEqual(claim);
},
60_000
);
}
);

View file

@ -0,0 +1,144 @@
import { afterEach, describe, expect, it } from 'vitest';
import { WebSocketServer } from 'ws';
import type { AddressInfo } from 'node:net';
import { waitForOpenClawRun } from '../utils/openclaw-gateway-rpc.js';
import { HttpOpenClawTaskAdapter } from '../services/openclaw-workflow-adapter.js';
const servers: WebSocketServer[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
for (const client of server.clients) client.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
async function gateway(
options: {
deny?: boolean;
missingMethod?: boolean;
wrongRun?: boolean;
version?: string;
disconnect?: boolean;
malformed?: boolean;
} = {}
) {
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
server.on('connection', (socket) => {
socket.send(
JSON.stringify({
type: 'event',
event: 'connect.challenge',
payload: { nonce: 'test-nonce', ts: 100 },
})
);
socket.on('message', (data) => {
const frame = JSON.parse(data.toString());
requests.push(frame);
if (frame.method === 'connect')
socket.send(
JSON.stringify({
type: 'res',
id: frame.id,
ok: !options.deny,
error: options.deny ? { message: 'token=do-not-echo' } : undefined,
payload: {
type: 'hello-ok',
protocol: 4,
server: { version: options.version ?? '2026.9.2' },
features: { methods: options.missingMethod ? [] : ['agent.wait'] },
},
})
);
else if (options.malformed) socket.send('invalid token=do-not-echo');
else if (options.disconnect) socket.close();
else
socket.send(
JSON.stringify({
type: 'res',
id: frame.id,
ok: true,
payload: {
runId: options.wrongRun ? 'other-run' : frame.params.runId,
status: 'timeout',
},
})
);
});
});
return {
gatewayUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
token: 'test-only-gateway-secret',
validationOptions: { allowHttp: true, allowLocalhost: true },
requests,
};
}
describe('authenticated OpenClaw gateway RPC', () => {
it('authenticates server-side and requests only the exact bound run', async () => {
const config = await gateway();
await expect(waitForOpenClawRun(config, 'bound-run', 0)).resolves.toEqual({
version: '2026.9.2',
result: { runId: 'bound-run', status: 'timeout' },
});
expect(config.requests[0].params.auth).toEqual({ token: config.token });
expect(config.requests[0].params.scopes).toEqual(['operator.write']);
expect(config.requests[1]).toMatchObject({
method: 'agent.wait',
params: { runId: 'bound-run', timeoutMs: 0 },
});
expect(JSON.stringify(config.requests[1])).not.toContain(config.token);
});
it.each([
[{ deny: true }, /authentication failed/],
[{ missingMethod: true }, /does not advertise/],
[{ wrongRun: true }, /identity does not match/],
[{ disconnect: true }, /disconnected/],
] as const)('fails closed for %j', async (opts, error) => {
await expect(waitForOpenClawRun(await gateway(opts), 'bound-run', 0)).rejects.toThrow(error);
});
it('rejects missing authentication before connecting', async () => {
await expect(
waitForOpenClawRun({ gatewayUrl: 'http://127.0.0.1:1' }, 'bound-run', 0)
).rejects.toThrow(/server-owned/);
});
it('rejects URL-carried credentials without exposing them', async () => {
await expect(
waitForOpenClawRun(
{ ...(await gateway()), gatewayUrl: 'http://user:secret@127.0.0.1:1' },
'bound-run',
0
)
).rejects.toThrow();
});
it('readiness rejects gateways before terminal reply snapshot support', async () => {
await expect(
new HttpOpenClawTaskAdapter(await gateway({ version: '2026.6.11' })).probeCompletion()
).rejects.toThrow(/2026.9.2/);
});
it('does not echo malformed gateway response content', async () => {
await expect(
waitForOpenClawRun(await gateway({ malformed: true }), 'bound-run', 0)
).rejects.toThrow('OpenClaw completion gateway returned an invalid protocol response.');
});
it('rejects cleartext remote completion before sending authentication', async () => {
await expect(
waitForOpenClawRun(
{
gatewayUrl: 'http://10.0.0.1:18789',
token: 'fixture',
validationOptions: { allowHttp: true, allowPrivateIp: true },
},
'bound-run',
0
)
).rejects.toThrow('OpenClaw completion requires HTTPS for a remote gateway.');
});
it('readiness verifies authenticated permission and records gateway version', async () => {
const config = await gateway();
await expect(new HttpOpenClawTaskAdapter(config).probeCompletion()).resolves.toEqual({
gatewayUrl: config.gatewayUrl,
version: '2026.9.2',
});
});
});

View file

@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HttpOpenClawTaskAdapter } from '../services/openclaw-workflow-adapter.js';
import type { AgentConfig } from '@veritas-kanban/shared';
import { ClawdbotAgentService } from '../services/clawdbot-agent-service.js';
import type { AgentHealthChecker } from '../services/agent-health-service.js';
@ -6,8 +7,15 @@ import { normalizeHarnessSupportProfile } from '../services/harness-support-prof
import { resolveExecutableAgentProvider } from '../services/provider-runtime-resolution.js';
const originalOpenClawVersion = process.env.OPENCLAW_GATEWAY_VERSION;
beforeEach(() => {
vi.spyOn(HttpOpenClawTaskAdapter.prototype, 'probeCompletion').mockResolvedValue({
gatewayUrl: 'http://127.0.0.1:18789',
version: '2026.9.2',
});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalOpenClawVersion === undefined) {
delete process.env.OPENCLAW_GATEWAY_VERSION;
} else {
@ -187,7 +195,7 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
['codex-sdk', 'openai-codex-sdk/v1', 'supported', 'ready'],
['claude-code', 'claude-code-stream-json/v1', 'supported', 'ready'],
['hermes-cli', 'hermes-one-shot/v1', 'supported', 'ready'],
['openclaw', 'openclaw-tools/v1', 'unsupported', 'degraded'],
['openclaw', 'openclaw-task-terminal/v1', 'unsupported', 'ready'],
] as const)(
'probes the %s adapter manifest',
async (provider, protocolVersion, stopState, probeState) => {
@ -247,7 +255,8 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
'workflow'
);
expect(taskManifest.protocolVersion).toBe('openclaw-tools/v1');
expect(taskManifest.protocolVersion).toBe('openclaw-task-terminal/v1');
expect(taskManifest.providerVersion).toBe('2026.9.2');
expect(
taskManifest.capabilities.find((capability) => capability.id === 'run.follow-up')?.state
).toBe('unsupported');
@ -263,4 +272,13 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
).toBe('supported');
expect(workflowManifest.digest).not.toBe(taskManifest.digest);
});
it('refuses native launch evidence when authenticated completion is unavailable', async () => {
vi.mocked(HttpOpenClawTaskAdapter.prototype.probeCompletion).mockRejectedValue(
new Error('completion unauthorized')
);
await expect(
new ClawdbotAgentService(health).probeProviderRuntime(config('openclaw'))
).rejects.toThrow('completion unauthorized');
});
});

View file

@ -119,7 +119,7 @@ describe('provider task-envelope renderers', () => {
expect(transport.content).toContain('Guidance: Inspect the schema and nearby tests first.');
});
it('renders an OpenClaw callback transport from a required-commit envelope', async () => {
it('renders an OpenClaw gateway completion transport from a required-commit envelope', async () => {
const taskEnvelope = await envelope('openclaw', 'required');
const transport = renderOpenClawTaskEnvelope({
@ -137,7 +137,7 @@ describe('provider task-envelope renderers', () => {
schemaVersion: 'provider-task-envelope-transport/v1',
provider: 'openclaw',
taskEnvelopeDigest: taskEnvelope.digest,
callbackPosture: 'veritas-http',
callbackPosture: 'harness-owned',
completionNormalization: 'harness',
});
expect(Object.isFrozen(transport)).toBe(true);
@ -150,22 +150,11 @@ describe('provider task-envelope renderers', () => {
expect(transport.content).toContain(
'- Required `provider-output` `terminal-state`: Harness-verified provider terminal state from the native transport.'
);
expect(transport.content).toContain(
'POST http://localhost:3001/api/agents/task_transport/complete'
);
expect(transport.content).toContain(
`"providerRuntimeManifestDigest":"${taskEnvelope.launchManifest.digest}"`
);
expect(transport.content).toContain(
'No native structured-output support is assumed; Veritas validates and normalizes the callback.'
);
expect(transport.content).toContain(
'Native OpenClaw dispatch does not provision callback credentials.'
);
expect(transport.content).toContain('`task:write` permission');
expect(transport.content).toContain('provenance, not authentication');
expect(transport.content).toContain('Do not send an unauthenticated callback');
expect(transport.content).toContain('operator-verified callback address');
expect(transport.content).toContain('veritas-openclaw-completion/v1');
expect(transport.content).toContain('authenticated OpenClaw gateway connection');
expect(transport.content).toContain('No Veritas credential or callback request is needed');
expect(transport.content).toContain('Do not call the Veritas completion callback');
expect(transport.content).not.toContain('/api/agents/task_transport/complete');
expect(transport.content).not.toContain('curl -X POST');
expect(transport.content).toMatchSnapshot();
});

View file

@ -70,7 +70,7 @@ describe('run egress launch policy', () => {
});
it('reports local gateway injection as host-enforced and remote OpenClaw as advisory', () => {
expect(PROVIDER_RUNTIME_PROBE_REVISION).toBe(16);
expect(PROVIDER_RUNTIME_PROBE_REVISION).toBe(17);
for (const provider of [
'codex-cli',

View file

@ -320,6 +320,42 @@ describe('RunSupervisorService', () => {
});
});
it('runs an attempt-specific remote probe only after durable bindings are verified', async () => {
const repository = new InMemoryRunSupervisorRepository();
const owner = service(repository);
const input = registerInput({
provider: 'openclaw',
adapter: 'openclaw',
controlKind: 'remote-session',
sessionId: 'child-bound',
recoveryOperations: ['reattach'],
});
const record = await owner.register(input);
const probe = vi.fn(
async (candidate: RunSupervisorRecord) =>
candidate.control.kind === 'remote-session' && candidate.control.sessionId === 'child-bound'
);
const bindings = {
provider: input.provider,
adapter: input.adapter,
providerRuntimeManifestDigest: SHA_A,
taskEnvelopeDigest: SHA_B,
runLaunchManifestDigest: SHA_C,
worktreePath: input.worktreePath,
worktreeManifestId: input.worktreeManifestId,
worktreeLeaseId: input.worktreeLeaseId,
sessionProbe: probe,
};
await expect(
owner.recover(record.id, { ...bindings, taskEnvelopeDigest: SHA_C })
).resolves.toMatchObject({ outcome: 'recovery-required' });
expect(probe).not.toHaveBeenCalled();
await expect(owner.recover(record.id, bindings)).resolves.toMatchObject({
outcome: 'reattached',
});
expect(probe).toHaveBeenCalledOnce();
});
it('signals the persisted process group and never an unverified reused PID', async () => {
const repository = new InMemoryRunSupervisorRepository();
let alive = true;

View file

@ -54,6 +54,12 @@ import { buildSafeCodexEnv } from '../utils/codex-env.js';
import { getRuntimeDir, getLogsDir } from '../utils/paths.js';
import { buildSafeHermesEnv } from '../utils/hermes-env.js';
import { HttpOpenClawTaskAdapter } from './openclaw-workflow-adapter.js';
import {
OpenClawCompletionService,
assertOpenClawRunBinding,
openClawCompletionProbeSource,
type OpenClawRunBinding,
} from './openclaw-completion-service.js';
import { type ProviderTaskEnvelopeTransport } from './provider-task-envelope-renderer.js';
import type { ThreadEvent } from '@openai/codex-sdk';
import {
@ -524,6 +530,7 @@ interface PendingAgent {
egressGateway?: RunEgressGatewayHandle;
/** Durable session key returned by OpenClaw sessions_spawn (openclaw provider only) */
openclawSessionKey?: string;
openclawRun?: OpenClawRunBinding;
/** Hermes session identity captured from process output (hermes-cli provider only) */
hermesSessionId?: string;
/**
@ -639,6 +646,7 @@ export class ClawdbotAgentService {
private runFileExecutionPolicy: Pick<RunFileExecutionPolicyService, 'evaluate' | 'revalidate'>;
private workspaceCheckpoints: Pick<WorkspaceCheckpointService, 'captureBoundary'>;
private logsDir: string;
private openclawCompletion: OpenClawCompletionService;
constructor(
agentHealth?: AgentHealthChecker,
@ -705,6 +713,16 @@ export class ClawdbotAgentService {
this.runLaunchManifests = new RunLaunchManifestService();
this.providerCompletions = providerCompletions;
this.attemptLifecycle = new AttemptLifecycleCoordinator(this.taskService);
this.openclawCompletion = new OpenClawCompletionService({
getTask: (taskId) => this.taskService.getTask(taskId),
complete: (binding, claim) =>
this.completeAgent(binding.taskId, claim, {
attemptId: binding.attemptId,
providerRuntimeManifestDigest: binding.providerRuntimeManifestDigest,
terminalSource: 'remote-session',
}),
requireRecovery: (binding, reason) => this.requireOpenClawRecovery(binding, reason),
});
this.credentialLeases = credentialLeases;
this.workspaceFiles = workspaceFiles;
this.worktrees =
@ -780,6 +798,14 @@ export class ClawdbotAgentService {
},
pendingRun: (taskId) => pendingAgents.get(taskId),
attachRecoveredRun: (...args) => this.attachRecoveredRun(...args),
probeRemoteSession: (task, supervisor) =>
supervisor.control.kind === 'remote-session' &&
supervisor.control.sessionId === task.attempt?.openclawRun?.sessionKey
? this.openclawCompletion.canRecover(task)
: Promise.resolve(false),
observeRecoveredRemoteRun: (task) => {
if (task.attempt?.openclawRun) this.openclawCompletion.start(task.attempt.openclawRun);
},
dropPendingRun: (taskId) => {
pendingAgents.delete(taskId);
},
@ -1102,6 +1128,7 @@ export class ClawdbotAgentService {
recoveredControl: true,
threadId: attempt.threadId ?? sessionId,
openclawSessionKey: provider === 'openclaw' ? (attempt.sessionKey ?? sessionId) : undefined,
openclawRun: provider === 'openclaw' ? attempt.openclawRun : undefined,
hermesSessionId: provider === 'hermes-cli' ? sessionId : undefined,
};
pendingAgents.set(task.id, pending);
@ -3205,6 +3232,8 @@ export class ClawdbotAgentService {
provider: pending.provider,
model: pending.model,
threadId: pending.threadId,
sessionKey: pending.openclawSessionKey,
openclawRun: pending.openclawRun,
budget: pending.budget,
agentProfile: pending.agentProfile,
providerRuntimeManifest: pending.providerRuntimeManifest,
@ -3301,6 +3330,7 @@ export class ClawdbotAgentService {
if (pendingAgents.get(taskId) === pending) {
pendingAgents.delete(taskId);
}
this.openclawCompletion.stop(taskId, attemptId);
this.recovery.clearRecoveredProcessMonitor(taskId);
await this.releaseAdmission(
pending.admissionReservationId,
@ -4660,10 +4690,24 @@ export class ClawdbotAgentService {
private createProviderAdapterRegistry(): AgentProviderAdapterRegistry {
const host: AgentProviderAdapterHost = {
probe: (provider, context, definition) =>
this.providerRuntimeManifests.probe(
buildProviderRuntimeProbeRequest(provider, context, definition)
),
probe: async (provider, context, definition) => {
const request = buildProviderRuntimeProbeRequest(provider, context, definition);
if (
provider === 'openclaw' &&
definition.protocolVersion !== 'openclaw-workflow-session/v1'
) {
const completion = await new HttpOpenClawTaskAdapter().probeCompletion();
request.command = completion.gatewayUrl;
request.identity = {
providerVersion: completion.version,
verified: true,
authenticated: true,
source: openClawCompletionProbeSource(completion.gatewayUrl),
diagnostics: [],
};
}
return this.providerRuntimeManifests.probe(request);
},
probeAcp: (context, definition) => this.probeAcpProviderRuntime(context, definition),
assertTransport: (provider, transport, manifest) =>
this.assertProviderAdapterTransport(provider, transport, manifest),
@ -4882,6 +4926,18 @@ export class ClawdbotAgentService {
private async startOpenClawAdapter(context: AgentProviderStartContext): Promise<void> {
const { transport, task, attemptId, agentConfig } = context;
const openclawAdapter = new HttpOpenClawTaskAdapter();
const completion = await openclawAdapter.probeCompletion();
const pending = pendingAgents.get(task.id);
if (
!pending ||
pending.attemptId !== attemptId ||
!pending.supervisorId ||
pending.providerRuntimeManifest.providerVersion !== completion.version ||
pending.providerRuntimeManifest.probe.source !==
openClawCompletionProbeSource(completion.gatewayUrl)
) {
throw new ConflictError('OpenClaw launch no longer matches its verified completion runtime.');
}
const result = await openclawAdapter.spawnTask({
taskId: task.id,
attemptId,
@ -4891,8 +4947,23 @@ export class ClawdbotAgentService {
prompt: transport.content,
timeoutSeconds: 900,
});
const binding: OpenClawRunBinding = {
schemaVersion: 'openclaw-task-run/v1',
gatewayUrl: completion.gatewayUrl,
gatewayVersion: completion.version,
runId: result.runId,
sessionKey: result.sessionKey,
workspaceId: pending.taskEnvelope.workspace.workspaceId,
taskId: task.id,
attemptId,
providerRuntimeManifestDigest: pending.providerRuntimeManifest.digest,
taskEnvelopeDigest: pending.taskEnvelope.digest,
runLaunchManifestDigest: pending.runLaunchManifest.digest,
observeUntil: new Date(Date.now() + 20 * 60_000).toISOString(),
};
await this.attemptLifecycle.patchActiveAttempt(task.id, attemptId, {
sessionKey: result.sessionKey,
openclawRun: binding,
});
await this.recordConversationIdentity(task.id, attemptId, {
conversationId: result.sessionKey,
@ -4904,21 +4975,51 @@ export class ClawdbotAgentService {
'openclaw',
agentConfig
);
const pending = pendingAgents.get(task.id);
if (!pending || pending.attemptId !== attemptId || !pending.supervisorId) {
if (pendingAgents.get(task.id) !== pending) {
throw new ConflictError('OpenClaw session has no durable run supervisor binding.', {
taskId: task.id,
attemptId,
});
}
pending.openclawSessionKey = result.sessionKey;
pending.openclawRun = binding;
await this.runSupervisor.attachRemoteSession(pending.supervisorId, result.sessionKey);
this.openclawCompletion.start(binding);
log.info(
{ taskId: task.id, attemptId, sessionKey: result.sessionKey },
'[ClawdbotAgent] OpenClaw session spawned via gateway'
);
}
private async requireOpenClawRecovery(
binding: OpenClawRunBinding,
reason: string
): Promise<void> {
const task = await this.taskService.getTask(binding.taskId);
if (
!task ||
task.attempt?.id !== binding.attemptId ||
task.attempt.completionResult ||
task.attempt.status !== 'running'
)
return;
assertOpenClawRunBinding(task, binding);
if (!task.attempt.runSupervisorId) return;
const supervisor = await this.runSupervisor.requireRecovery(
task.attempt.runSupervisorId,
'session-unreachable',
reason,
'Restore the authenticated gateway connection and restart Veritas to retry observation. Do not start another attempt until the remote session is reconciled.'
);
await this.attemptLifecycle.persistActiveAttempt({
task,
attempt: { ...task.attempt, runRecovery: supervisor.recovery },
...(task.status === 'in-progress' ? { status: 'blocked' } : {}),
});
const pending = pendingAgents.get(task.id);
if (pending?.attemptId === binding.attemptId) pendingAgents.delete(task.id);
}
private assertProviderAdapterLaunchManifest(
provider: ExecutableAgentProvider,
manifest: RunLaunchManifest

View file

@ -0,0 +1,200 @@
import { z } from 'zod';
import type { Task, TaskAttempt } from '@veritas-kanban/shared';
import type { ProviderTerminalClaim } from './provider-completion-service.js';
import { HttpOpenClawTaskAdapter } from './openclaw-workflow-adapter.js';
import type { OpenClawWaitResult } from '../utils/openclaw-gateway-rpc.js';
import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('openclaw-completion');
export type OpenClawRunBinding = NonNullable<TaskAttempt['openclawRun']>;
export function openClawCompletionProbeSource(gatewayUrl: string): string {
return `openclaw-gateway:agent.wait@${digestRunLaunchValue(gatewayUrl)}`;
}
const reportSchema = z
.object({
schemaVersion: z.literal('veritas-openclaw-completion/v1'),
status: z.enum(['success', 'failed', 'blocked']),
summary: z.string().trim().min(1).max(20_000),
error: z.string().max(20_000).optional(),
})
.strict();
/** Only a terminal reply from agent.wait is eligible; session history is never proof. */
export function normalizeOpenClawTerminal(
result: OpenClawWaitResult
): ProviderTerminalClaim | null {
if (!result.endedAt || result.status === 'pending' || result.yielded) return null;
if (result.status === 'error' || result.status === 'timeout') {
return {
terminalSource: 'remote-session',
status: 'failed',
error: result.error || 'OpenClaw run failed or exceeded its execution timeout.',
};
}
const text =
result.terminalReply?.disposition === 'visible' ? result.terminalReply.text : undefined;
try {
const report = reportSchema.parse(
JSON.parse((text ?? '').trim().replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/, '$1'))
);
return {
terminalSource: 'remote-session',
status: report.status,
summary: report.summary,
error: report.error,
};
} catch {
return {
terminalSource: 'remote-session',
status: 'blocked',
error:
'OpenClaw ended without a valid completion report. Inspect the child session and resolve the result manually.',
};
}
}
export function assertOpenClawRunBinding(task: Task, binding: OpenClawRunBinding): void {
const attempt = task.attempt;
if (
binding.schemaVersion !== 'openclaw-task-run/v1' ||
!binding.runId ||
!binding.sessionKey ||
task.id !== binding.taskId ||
attempt?.id !== binding.attemptId ||
attempt.provider !== 'openclaw' ||
attempt.sessionKey !== binding.sessionKey ||
attempt.openclawRun?.runId !== binding.runId ||
attempt.openclawRun.gatewayUrl !== binding.gatewayUrl ||
attempt.openclawRun.gatewayVersion !== binding.gatewayVersion ||
attempt.openclawRun.observeUntil !== binding.observeUntil ||
attempt.providerRuntimeManifest?.digest !== binding.providerRuntimeManifestDigest ||
attempt.providerRuntimeManifest.providerVersion !== binding.gatewayVersion ||
attempt.providerRuntimeManifest.probe.source !==
openClawCompletionProbeSource(binding.gatewayUrl) ||
attempt.taskEnvelope?.digest !== binding.taskEnvelopeDigest ||
attempt.taskEnvelope.workspace.workspaceId !== binding.workspaceId ||
attempt.runLaunchManifest?.digest !== binding.runLaunchManifestDigest ||
!Number.isFinite(Date.parse(binding.observeUntil))
)
throw new Error(
'OpenClaw terminal observation does not match the persisted workspace, task, attempt, session, and launch evidence.'
);
}
interface OpenClawCompletionHost {
getTask(taskId: string): Promise<Task | null>;
complete(binding: OpenClawRunBinding, claim: ProviderTerminalClaim): Promise<void>;
requireRecovery(binding: OpenClawRunBinding, reason: string): Promise<void>;
}
/** Owns observation only; attempt lifecycle remains the sole completion authority. */
export class OpenClawCompletionService {
private readonly monitors = new Map<string, AbortController>();
constructor(
private readonly host: OpenClawCompletionHost,
private readonly adapterFactory = () => new HttpOpenClawTaskAdapter(),
private readonly now = () => Date.now()
) {}
async canRecover(task: Task): Promise<boolean> {
const binding = task.attempt?.openclawRun;
if (!binding) return false;
try {
assertOpenClawRunBinding(task, binding);
const adapter = this.adapterFactory();
adapter.assertGatewayBinding(binding.gatewayUrl);
const { version, result } = await adapter.waitForRun(binding.runId, 0);
return (
version === binding.gatewayVersion &&
(this.now() < Date.parse(binding.observeUntil) ||
normalizeOpenClawTerminal(result) !== null)
);
} catch {
return false;
}
}
start(binding: OpenClawRunBinding): void {
const key = `${binding.taskId}:${binding.attemptId}`;
if (this.monitors.has(key)) return;
const controller = new AbortController();
this.monitors.set(key, controller);
void this.observe(binding, controller.signal)
.catch(async () => {
if (!controller.signal.aborted) {
await this.host.requireRecovery(
binding,
'OpenClaw terminal observation failed. Restore the bound gateway and restart Veritas to retry observation.'
);
}
})
.catch(() => {
log.warn(
{ taskId: binding.taskId, attemptId: binding.attemptId },
'Could not persist OpenClaw recovery state; the durable run binding remains available for reconciliation.'
);
})
.finally(() => {
if (this.monitors.get(key) === controller) this.monitors.delete(key);
});
}
stop(taskId: string, attemptId: string): void {
this.monitors.get(`${taskId}:${attemptId}`)?.abort();
}
private async observe(binding: OpenClawRunBinding, signal: AbortSignal): Promise<void> {
const adapter = this.adapterFactory();
adapter.assertGatewayBinding(binding.gatewayUrl);
while (!signal.aborted) {
const task = await this.host.getTask(binding.taskId);
if (
!task ||
task.attempt?.id !== binding.attemptId ||
task.attempt.completionResult ||
task.attempt.status !== 'running'
)
return;
assertOpenClawRunBinding(task, binding);
const expired = this.now() >= Date.parse(binding.observeUntil);
const { version, result } = await adapter.waitForRun(
binding.runId,
expired ? 0 : 30_000,
signal
);
if (version !== binding.gatewayVersion)
throw new Error('OpenClaw gateway version changed during the run.');
const claim = normalizeOpenClawTerminal(result);
if (claim) {
const current = await this.host.getTask(binding.taskId);
if (!current || current.attempt?.completionResult || current.attempt?.status !== 'running')
return;
assertOpenClawRunBinding(current, binding);
await this.host.complete(binding, claim);
return;
}
if (expired) {
await this.host.requireRecovery(
binding,
'OpenClaw supplied no verifiable terminal result before the observation deadline. Inspect the remote run before starting another attempt.'
);
return;
}
// A queued result can return immediately. Keep bounded pressure on the gateway.
await new Promise<void>((resolve) => {
const done = () => {
clearTimeout(timer);
signal.removeEventListener('abort', done);
resolve();
};
const timer = setTimeout(done, 1_000);
signal.addEventListener('abort', done, { once: true });
if (signal.aborted) done();
});
}
}
}

View file

@ -1,6 +1,8 @@
import { createLogger } from '../lib/logger.js';
import type { UrlValidationOptions } from '../utils/url-validation.js';
import { getOutboundIntegrationService } from './outbound-integration-service.js';
import { randomUUID } from 'node:crypto';
import { waitForOpenClawRun } from '../utils/openclaw-gateway-rpc.js';
const log = createLogger('openclaw-workflow-adapter');
@ -81,7 +83,7 @@ export interface OpenClawTaskSpawnInput {
export interface OpenClawTaskSpawnResult {
/** Durable session key returned by OpenClaw sessions_spawn */
sessionKey: string;
runId?: string;
runId: string;
status: string;
error?: string;
raw?: unknown;
@ -451,8 +453,8 @@ export class HttpOpenClawWorkflowAdapter implements OpenClawWorkflowAdapter {
* synchronous acknowledgement: if sessions_spawn succeeds, a durable session key
* is returned and the task attempt is safe to mark active.
*
* The spawned OpenClaw sub-session receives the full task prompt, which includes
* the Veritas callback URL so the agent can POST completion status.
* The child receives the task prompt and final-report instructions. Veritas observes
* the returned run ID through the authenticated gateway; the child has no callback credential.
*/
export class HttpOpenClawTaskAdapter {
private readonly gatewayUrl: string;
@ -481,22 +483,55 @@ export class HttpOpenClawTaskAdapter {
};
}
/** Uses the same authenticated origin as dispatch, before any attempt is created. */
async probeCompletion(): Promise<{ gatewayUrl: string; version: string }> {
const { version } = await this.waitForRun(`veritas-readiness-${randomUUID()}`, 0);
const parts = /^(\d+)\.(\d+)\.(\d+)(?:\D|$)/.exec(version);
if (
!parts ||
Number(parts[1]) * 10_000 + Number(parts[2]) * 100 + Number(parts[3]) < 20260902
) {
throw new Error(
'Native OpenClaw completion requires gateway v2026.9.2 or later with terminal reply snapshots.'
);
}
return { gatewayUrl: this.gatewayUrl, version };
}
assertGatewayBinding(gatewayUrl: string): void {
if (gatewayUrl !== this.gatewayUrl) {
throw new Error(
'OpenClaw completion gateway changed since launch; restore the bound gateway before recovery.'
);
}
}
waitForRun(runId: string, timeoutMs = 30_000, signal?: AbortSignal) {
return waitForOpenClawRun(
{ gatewayUrl: this.gatewayUrl, token: this.token, validationOptions: this.validationOptions },
runId,
timeoutMs,
signal
);
}
/** Spawn an OpenClaw sub-session for a Veritas task. */
async spawnTask(input: OpenClawTaskSpawnInput): Promise<OpenClawTaskSpawnResult> {
const result = await this.invokeTool('sessions_spawn', buildOpenClawTaskSpawnArguments(input));
const sessionKey =
this.readString(result, 'childSessionKey') || this.readString(result, 'sessionKey');
if (!sessionKey) {
const runId = this.readString(result, 'runId');
if (!sessionKey || !runId) {
throw new Error(
'OpenClaw sessions_spawn did not return a child session key; ' +
'confirm the gateway is running OpenClaw v2026.6.11 or later'
'OpenClaw sessions_spawn did not return both a child session key and run ID; ' +
'inspect the gateway before starting another attempt'
);
}
return {
sessionKey,
runId: this.readString(result, 'runId'),
runId,
status: this.readString(result, 'status') || 'accepted',
error: this.readString(result, 'error'),
raw: result,

View file

@ -319,9 +319,15 @@ const DEFINITIONS: Record<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
'Hermes starts in the worktree without an enforceable write boundary.'
),
}),
openclaw: definition('openclaw', 'OpenClaw', 'openclaw-tools/v1', {
openclaw: definition('openclaw', 'OpenClaw', 'openclaw-task-terminal/v1', {
...COMMON_SUPPORTED,
...NOT_YET_IMPLEMENTED,
'run.complete': supported(
'The server captures the authenticated gateway agent.wait terminal reply for the persisted run ID.'
),
'run.reattach': supported(
'The server resumes terminal observation of the bound gateway run after restart; it never relaunches the child.'
),
'run.stop': unsupported('OpenClaw does not expose a task-session stop API.'),
'run.streaming': unknown('Task-session streaming has not been conformance tested.'),
'run.structured-events': unknown('OpenClaw task event normalization is tracked by issue #850.'),
@ -352,6 +358,7 @@ export function getProviderRuntimeAdapterDefinition(
if (provider !== 'openclaw' || surface !== 'workflow') return base;
const overrides: ProviderRuntimeCapabilityOverrides = {
'run.complete': COMMON_SUPPORTED['run.complete'],
'run.follow-up': supported(
'The workflow adapter sends follow-up prompts to an existing OpenClaw session.'
),

View file

@ -33,9 +33,9 @@ export function renderOpenClawTaskEnvelope(
schemaVersion: PROVIDER_TASK_ENVELOPE_TRANSPORT_SCHEMA_VERSION,
provider: 'openclaw',
taskEnvelopeDigest: input.taskEnvelope.digest,
callbackPosture: 'veritas-http',
callbackPosture: 'harness-owned',
completionNormalization: 'harness',
content: renderEnvelopeContent('OpenClaw', input, renderOpenClawCompletion(input.taskEnvelope)),
content: renderEnvelopeContent('OpenClaw', input, renderOpenClawCompletion()),
});
}
@ -417,33 +417,18 @@ Continue from this checkpoint. Do not repeat work already represented in the sav
`;
}
function renderOpenClawCompletion(taskEnvelope: TaskEnvelope): string {
const callbackUrl = `http://localhost:3001/api/agents/${taskEnvelope.subject.id}/complete`;
const payload = JSON.stringify({
attemptId: taskEnvelope.attempt.id,
providerRuntimeManifestDigest: taskEnvelope.launchManifest.digest,
success: true,
summary: 'Brief description of what was done',
});
return `## Completion (OpenClaw callback)
function renderOpenClawCompletion(): string {
return `## Completion (OpenClaw gateway)
When the work reaches a terminal state, report it through an operator-provisioned authenticated completion tool.
Native OpenClaw dispatch does not provision callback credentials. Before starting work, confirm that the operator has separately configured a trusted completion tool with Veritas API authentication and \`task:write\` permission. The gateway token authenticates Veritas to OpenClaw; it is not a Veritas callback credential. The attempt and manifest identifiers below are provenance, not authentication.
If authenticated completion tooling is unavailable, report that blocker in the OpenClaw session for the operator to resolve. Do not send an unauthenticated callback, obtain an administrator key, disable authentication, or claim that Veritas recorded completion. Do not place credential values in the task prompt or completion payload.
- Endpoint: \`POST ${callbackUrl}\`
- This generated address assumes Veritas is reachable at localhost:3001 from the worker. A remote or container worker requires an operator-verified callback address as well as authentication.
- Use the existing completion tool's authenticated request mechanism with this JSON payload:
End the run with a single JSON object describing the actual outcome:
\`\`\`json
${payload}
{"schemaVersion":"veritas-openclaw-completion/v1","status":"success","summary":"What changed and how it was verified"}
\`\`\`
For failure, send \`success: false\` and include an \`error\` message.
Use \`status: "failed"\` when the work failed, or \`status: "blocked"\` when it cannot proceed, and include an \`error\` string explaining why. Do not report success unless the task's completion requirements are satisfied.
No native structured-output support is assumed; Veritas validates and normalizes the callback.
Veritas reads this final reply through its authenticated OpenClaw gateway connection and validates the persisted run identity before recording completion. No Veritas credential or callback request is needed in the child session. Do not call the Veritas completion callback or request an API key. A normal chat message is not a recorded Veritas completion; the server owns terminal-state capture.
`;
}

View file

@ -58,6 +58,8 @@ export interface RunRecoveryHost<Pending extends RecoveryPendingRun> {
launch(taskId: string, agent: AgentType, options: AgentStartOptions): Promise<AgentLaunchStatus>;
validateFallback(taskId: string, agent: AgentType, options: AgentStartOptions): Promise<void>;
pendingRun(taskId: string): Pending | undefined;
probeRemoteSession?(task: Task, supervisor: RunSupervisorRecord): Promise<boolean>;
observeRecoveredRemoteRun?(task: Task): void;
attachRecoveredRun(
task: Task,
attempt: TaskAttempt,
@ -234,6 +236,9 @@ export class RunRecoveryCoordinator<Pending extends RecoveryPendingRun> {
worktreePath: attempt.taskEnvelope.workspace.worktreePath,
worktreeManifestId: attempt.taskEnvelope.workspace.worktreeManifestId,
worktreeLeaseId: attempt.taskEnvelope.workspace.ownershipLeaseId,
sessionProbe: this.host.probeRemoteSession
? (record) => this.host.probeRemoteSession?.(task, record) ?? Promise.resolve(false)
: undefined,
});
}
if (recovery.outcome === 'lease-held') {
@ -813,6 +818,8 @@ export class RunRecoveryCoordinator<Pending extends RecoveryPendingRun> {
);
if (supervisor.control.kind === 'local-process') {
this.monitorRecoveredProcess(task.id, pending, supervisor);
} else if (supervisor.control.kind === 'remote-session') {
this.host.observeRecoveredRemoteRun?.(task);
}
} catch (error) {
this.clearRecoveredProcessMonitor(task.id);

View file

@ -58,6 +58,8 @@ export interface RunSupervisorCheckpoint {
}
export interface RunSupervisorRecoveryBindings {
/** Adapter-owned observation; invoked only after durable ownership checks pass. */
sessionProbe?: (record: RunSupervisorRecord) => Promise<boolean>;
provider: ExecutableAgentProvider;
adapter: string;
providerRuntimeManifestDigest: string;
@ -406,7 +408,8 @@ export class RunSupervisorService {
'Leave the remote session untouched, inspect it in the provider, and launch a new attempt only after its state is known.'
);
}
const reachable = this.sessionProbe ? await this.sessionProbe(record) : false;
const probe = bindings.sessionProbe ?? this.sessionProbe;
const reachable = probe ? await probe(record) : false;
if (!reachable) {
return this.recoveryRequiredResult(
id,

View file

@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto';
import type { RequestOptions } from 'node:http';
import WebSocket from 'ws';
import { z } from 'zod';
import { resolveOutboundUrl, type UrlValidationOptions } from './url-validation.js';
const helloSchema = z.object({
type: z.literal('hello-ok'),
protocol: z.union([z.literal(3), z.literal(4)]),
server: z.object({ version: z.string().min(1) }),
features: z.object({ methods: z.array(z.string()) }),
});
export const openClawWaitResultSchema = z.object({
runId: z.string().min(1),
status: z.enum(['ok', 'error', 'timeout', 'pending']),
startedAt: z.number().optional(),
endedAt: z.number().optional(),
error: z.string().optional(),
yielded: z.boolean().optional(),
terminalReply: z.object({ disposition: z.string(), text: z.string().optional() }).nullish(),
});
export type OpenClawWaitResult = z.infer<typeof openClawWaitResultSchema>;
export interface OpenClawGatewayRpcOptions {
gatewayUrl: string;
token?: string;
validationOptions?: UrlValidationOptions;
}
/** A bounded, server-only RPC connection. Never forwards a gateway credential to a child. */
export async function waitForOpenClawRun(
options: OpenClawGatewayRpcOptions,
runId: string,
timeoutMs: number,
signal?: AbortSignal
): Promise<{ version: string; result: OpenClawWaitResult }> {
if (!options.token) {
throw new Error('OpenClaw completion requires a server-owned OPENCLAW_GATEWAY_TOKEN.');
}
const resolved = await resolveOutboundUrl(options.gatewayUrl, options.validationOptions);
if (!resolved) throw new Error('OpenClaw completion gateway URL was blocked by outbound policy.');
const url = new URL(resolved.url);
const address = resolved.resolvedAddress.address;
if (url.protocol !== 'https:' && address !== '::1' && !/^127\./.test(address)) {
throw new Error('OpenClaw completion requires HTTPS for a remote gateway.');
}
if (url.username || url.password || url.search || url.hash) {
throw new Error('OpenClaw gateway URL must not contain credentials, a query, or a fragment.');
}
// Protect the authenticated connection from DNS rebinding; ws must use the address
// validated above, while retaining the hostname for TLS certificate verification.
const lookup: NonNullable<RequestOptions['lookup']> = (_hostname, opts, callback) => {
const cb = typeof opts === 'function' ? opts : callback;
if (typeof opts === 'object' && opts?.all) cb(null, [resolved.resolvedAddress]);
else cb(null, resolved.resolvedAddress.address, resolved.resolvedAddress.family);
};
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const socket = new WebSocket(url, {
lookup,
followRedirects: false,
maxPayload: 2 * 1024 * 1024,
handshakeTimeout: 10_000,
});
const connectId = randomUUID();
const waitId = randomUUID();
let version: string | undefined;
let connecting = false;
let settled = false;
const finish = (error?: Error, result?: OpenClawWaitResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
socket.terminate();
if (error) reject(error);
else if (version && result) resolve({ version, result });
else reject(new Error('OpenClaw completion response was incomplete.'));
};
const abort = () => finish(new Error('OpenClaw completion observation was cancelled.'));
const timer = setTimeout(
() => finish(new Error('OpenClaw completion gateway request timed out.')),
timeoutMs + 15_000
);
signal?.addEventListener('abort', abort, { once: true });
socket.on('error', () => finish(new Error('OpenClaw completion gateway connection failed.')));
socket.on('close', () => finish(new Error('OpenClaw completion gateway disconnected.')));
socket.on('message', (data) => {
try {
const frame = JSON.parse(data.toString());
if (frame.type === 'event' && frame.event === 'connect.challenge' && !connecting) {
connecting = true;
socket.send(
JSON.stringify({
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 4,
client: {
id: 'gateway-client',
displayName: 'Veritas Kanban',
version: '1',
platform: process.platform,
mode: 'backend',
},
role: 'operator',
scopes: ['operator.write'],
caps: [],
auth: { token: options.token },
},
})
);
return;
}
if (frame.type !== 'res') return;
if (frame.id === connectId && !version) {
if (frame.ok !== true) {
// Do not echo untrusted gateway errors: they can contain credentials.
throw new Error(
'OpenClaw completion authentication failed; authorize the server gateway connection before launching tasks.'
);
}
const hello = helloSchema.parse(frame.payload);
if (!hello.features.methods.includes('agent.wait')) {
throw new Error('OpenClaw gateway does not advertise agent.wait completion support.');
}
version = hello.server.version;
socket.send(
JSON.stringify({
type: 'req',
id: waitId,
method: 'agent.wait',
params: { runId, timeoutMs },
})
);
} else if (frame.id === waitId && version) {
if (frame.ok !== true)
throw new Error('OpenClaw agent.wait is unavailable or unauthorized.');
const result = openClawWaitResultSchema.parse(frame.payload);
if (result.runId !== runId)
throw new Error('OpenClaw completion run identity does not match the dispatched run.');
finish(undefined, result);
}
} catch (error) {
finish(
error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError
? new Error('OpenClaw completion gateway returned an invalid protocol response.')
: (error as Error)
);
}
});
});
}

View file

@ -111,7 +111,7 @@ export interface HarnessSupportStatus {
export const PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION = 'provider-runtime-manifest/v1' as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 16 as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 17 as const;
export const KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS = [
'run.start',

View file

@ -68,6 +68,21 @@ export interface TaskAttempt {
model?: string;
threadId?: string;
sessionKey?: string;
/** Server-owned native completion identity; no child or gateway credentials. */
openclawRun?: {
schemaVersion: 'openclaw-task-run/v1';
gatewayUrl: string;
gatewayVersion: string;
runId: string;
sessionKey: string;
workspaceId: string;
taskId: string;
attemptId: string;
providerRuntimeManifestDigest: string;
taskEnvelopeDigest: string;
runLaunchManifestDigest: string;
observeUntil: string;
};
cloudUrl?: string;
cloudTarget?: string;
orchestration?: import('./workflow.js').WorkflowPipelineSummary;