feat: add first-class Claude Code adapter (#951)

This commit is contained in:
Brad Groux 2026-07-24 01:00:09 -05:00 committed by GitHub
parent b0f4ea88a2
commit 3b07a5cb4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2197 additions and 92 deletions

View file

@ -113,11 +113,12 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
- All cross-package types live in `shared/src/types/`.
- `AgentProvider` union is the single definition consumed by both server and web.
**Currently supported providers:**
`openclaw` | `codex-cli` | `codex-sdk` | `codex-cloud` | `hermes-cli` |
`openclaw` | `codex-cli` | `codex-sdk` | `codex-cloud` | `claude-code` | `hermes-cli` |
`ollama-local` | `ollama-cloud` | `lm-studio-local` | `custom`
- Executable task adapters are currently `openclaw`, `codex-cli`, `codex-sdk`,
and `hermes-cli`. Explicitly configured providers outside that set must fail
closed; never route them through an implicit OpenClaw fallback.
`claude-code`, and `hermes-cli`. Explicitly configured providers outside
that set must fail closed; never route them through an implicit OpenClaw
fallback.
- Probe and persist `provider-runtime-manifest/v1` before mutating attempt state.
New runtime controls must use the persisted evidence instead of provider-name
checks, and provider version/build changes must invalidate cached conformance.
@ -163,6 +164,19 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
- `codex-sdk`: programmatic SDK, requires `@openai/codex-sdk`
- Auth: `codex login status` / `OPENAI_API_KEY`
### Claude Code (v2.1.218)
- Provider ID: `claude-code`. Default command: `claude`.
- Veritas launches `claude --bare --print --output-format stream-json` with
static sandbox-derived permissions and no shell.
- Bare mode requires explicit environment authentication. OAuth/keychain state
reported by `claude auth status` does not prove bare-mode readiness.
- The terminal `result` record is authoritative. Veritas drains stdout after
process close, persists `session_id`, and maps partial, hook, tool, subagent,
usage, cost, and result records into `run-event/v1`.
- Resume, fork, interactive approval, elicitation, and MCP injection remain
fail-closed until their provider-neutral brokers land.
---
## Security boundaries
@ -171,7 +185,8 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
- **Input validation.** All user input is validated with Zod schemas before processing.
- **Path traversal.** `validatePathSegment()` + `ensureWithinBase()` on every user-supplied path.
- **Env passthrough.** Agents receive only the keys in the configured safe allowlist; see
`server/src/utils/codex-env.ts` and `server/src/utils/hermes-env.ts`.
`server/src/utils/codex-env.ts`, `server/src/utils/hermes-env.ts`, and
`server/src/services/claude-code-adapter.ts`.
- **Launch arguments.** Never put credential values in provider commands or arguments; use an
allowlisted environment key or run-scoped brokered credential reference.
- **Log redaction.** Trace logs and telemetry run through `TRACE_SECRET_PATTERNS` before storage.

View file

@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added a first-class Claude Code v2.1.218 task adapter with a reproducible
no-shell bare-mode launch, static sandbox-derived permissions, explicit
environment authentication with provider credentials scrubbed from tool
subprocesses, bounded health and agent-discovery diagnostics,
immutable task/run manifests, drain-safe stream-json ingestion, causal event
mapping for partial text, thinking, tools, hooks, subagents, usage, cost, and
results, separate session identity persistence, artifact discovery, and
fail-closed capability evidence for lifecycle and broker controls that have
not landed yet. Legacy provider-less Claude records migrate only when command
identity matches, and the old permission-bypass default is removed (#916).
- Added `run-event/v1`, a provider-neutral causal event journal for OpenClaw,
Codex CLI, Codex SDK, and Hermes. Provider mappers now durably append bounded,
redacted, deduplicated lifecycle, message, reasoning, command, file, tool,

View file

@ -9,12 +9,89 @@ Veritas works as a board without any agent runner. When you do enable agents, pr
Fresh v5 installs use OpenAI Codex as the default agent:
- `codex` is enabled by default and uses `codex exec --sandbox workspace-write --json`.
- `codex-sdk` and `hermes` have executable adapters but are disabled by default.
- `claude-code`, `amp`, `copilot`, `gemini`, `codex-cloud`, `ollama-local`, `ollama-cloud`, and `lm-studio-local` remain visible for configuration and migration, but they cannot dispatch until a matching executable adapter ships.
- `codex-sdk`, `claude-code`, and `hermes` have executable adapters but are
disabled by default.
- `amp`, `copilot`, `gemini`, `codex-cloud`, `ollama-local`, `ollama-cloud`,
and `lm-studio-local` remain visible for configuration and migration, but
they cannot dispatch until a matching executable adapter ships.
- Built-in routing sends code, bug, documentation, and review work to `codex` first, with conservative fallbacks for higher-risk code paths.
Existing configs keep the user's chosen default agent. Missing built-in profiles are added during config normalization without overwriting customized commands, arguments, or enabled states.
## Claude Code v2.1.218
The first-class `claude-code` adapter contract is pinned to Claude Code
v2.1.218. Veritas launches the executable directly in the assigned worktree
with no shell and a reproducible system-owned argument set:
```text
claude --bare --print --output-format stream-json --verbose \
--include-partial-messages --include-hook-events \
--forward-subagent-text --permission-mode dontAsk
```
The public Claude Code repository does not contain the complete CLI
implementation. This contract is therefore pinned to the v2.1.218 release,
official CLI/headless documentation, and checked-in golden stream fixtures;
unknown versions invalidate conformance instead of inheriting certification.
The exact launch also disables slash commands and Chrome, caps turns at 100 by
default, applies an optional run cost budget, and derives allowed and denied
tools from the effective sandbox. Read, Glob, and Grep are available in all
sandboxes. Edit and Write require a writable sandbox. Bash requires both a
writable sandbox and network access. WebFetch and WebSearch are denied when
network access is disabled, and sensitive environment, secret, and credential
file patterns are denied. Caller arguments cannot replace these controls,
inherit settings/plugins/MCP configuration, resume an unrelated session, or
bypass permissions.
`--bare` deliberately skips Claude Code's local settings, plugins, MCP servers,
OAuth, and keychain state. Veritas therefore copies the worktree's `AGENTS.md`
into the attributed task request and requires explicit bare-mode
authentication. Supported credential keys are `ANTHROPIC_API_KEY`,
`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_FOUNDRY_API_KEY`,
`ANTHROPIC_FOUNDRY_AUTH_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, and the bounded AWS
credential set used for Bedrock. Vertex requires an explicit
`GOOGLE_APPLICATION_CREDENTIALS` file reference. Vertex, Bedrock, and Foundry
selectors are allowlisted separately but do not prove authentication on their
own. Claude Code's subprocess environment scrubbing is forced on so Bash,
hooks, and MCP subprocesses cannot inherit provider credentials. Arbitrary
custom headers, config directories, and unrelated credential-shaped
environment variables are not forwarded.
Readiness runs bounded `claude --version`, `claude auth status`, and
`claude agents --json` probes without a shell. The auth status probe is useful
diagnostic evidence, but only the explicit bare-mode environment satisfies
launch authentication. The runtime profile requires an exact
`2.1.218 (Claude Code)` version match and probe revision 4; version or build
drift invalidates conformance evidence.
Claude's stream is consumed as bounded JSONL. Partial text/thinking, tool
use/results, hooks, subagents, retries, usage/cost, and terminal results map to
the shared causal event journal. The provider `session_id` is stored separately
from turn identity in `run-event/v1` and on the attempt as its continuation
handle. Veritas waits for queued stdout processing and parses the final
unterminated record after process close, preventing the terminal result from
being lost during stream drain. A successful process exit without an
authoritative successful `result` record fails closed.
Resume/fork, interactive approvals, elicitation, and run-scoped MCP injection
remain explicitly unsupported until their provider-neutral lifecycle and
broker issues land. Static `dontAsk` permissions are the only accepted launch
posture in this adapter version.
Credential-gated release smoke is opt-in:
```bash
VERITAS_CLAUDE_CODE_SMOKE=1 pnpm --filter @veritas-kanban/server exec vitest run \
src/__tests__/claude-code-provider.smoke.test.ts
```
The smoke requires the exact certified executable and explicit bare-mode
authentication. It runs one read-only, network-disabled, one-turn request with
a $0.25 provider cap and verifies the authoritative result record. The normal
test suite skips it.
## Buzz communication harness
Buzz is integrated as a `buzz` communication adapter, not as an
@ -55,18 +132,20 @@ Reasons and remediation are redacted before leaving the server.
Task start rechecks the normalized profile before attempt state is created. An
explicit provider must match the profile's executable adapter. A display-only
Claude Code or Copilot profile, an unsupported provider, or an unknown
provider-less profile fails with an actionable `409` and can never fall through
to OpenClaw. Recognized credential material in the configured command or launch
Copilot profile, an unsupported provider, or an unknown provider-less profile
fails with an actionable `409` and can never fall through to OpenClaw.
Recognized credential material in the configured command or launch
arguments degrades the profile and blocks dispatch before probing or attempt
creation. Put credentials in an allowlisted environment key or a run-scoped
brokered credential reference instead.
For backward compatibility, normalization migrates only known provider-less
Codex and Hermes records when both the built-in type and command identity match
(`codex` -> `codex-cli`, `hermes` -> `hermes-cli`). New and custom profiles must
set an explicit provider. Command-name inference is not a general
adapter-selection mechanism.
Claude Code, Codex, and Hermes records when both the built-in type and command
identity match (`claude-code` plus `claude` -> `claude-code`, `codex` ->
`codex-cli`, `hermes` -> `hermes-cli`). The legacy Claude-only
`--dangerously-skip-permissions` default is removed during that narrow
migration. New and custom profiles must set an explicit provider. Command-name
inference is not a general adapter-selection mechanism.
## Provider Runtime Manifests
@ -77,7 +156,7 @@ timestamp and diagnostics, and every known runtime or sandbox capability as
`supported`, `advisory`, `unsupported`, or `unknown`.
Veritas currently has executable task adapters for `codex-cli`, `codex-sdk`,
`hermes-cli`, and `openclaw`. An explicitly configured Claude Code, Copilot,
`claude-code`, `hermes-cli`, and `openclaw`. An explicitly configured Copilot,
Codex Cloud, Ollama, LM Studio, or custom profile is not silently sent through
OpenClaw; task dispatch fails with an actionable `409` until that provider has
an execution adapter.
@ -132,14 +211,14 @@ snapshot persisted for that attempt.
## Causal Run Event Journal
Executable adapters own a mapper into the shared `run-event/v1` envelope.
OpenClaw, Codex CLI, Codex SDK, and Hermes preserve provider event identity,
turn/item identity, provider time, receive time, source, causal links, and a
monotonic per-attempt sequence. Known kinds cover run lifecycle, operator and
assistant messages, deltas, reasoning, progress, streams, commands, file
changes, tools, approvals, artifacts, usage, and errors. A new provider event
that Veritas does not understand is retained as a namespaced kind or
`provider.unknown`; it is never silently discarded or assigned semantics that
the adapter cannot prove.
OpenClaw, Codex CLI, Codex SDK, Claude Code, and Hermes preserve provider event
identity, session/turn/item identity when the provider reports it, provider
time, receive time, source, causal links, and a monotonic per-attempt sequence.
Known kinds cover run lifecycle, operator and assistant messages, deltas,
reasoning, progress, streams, commands, file changes, tools, approvals,
artifacts, usage, and errors. A new provider event that Veritas does not
understand is retained as a namespaced kind or `provider.unknown`; it is never
silently discarded or assigned semantics that the adapter cannot prove.
The journal is the ordering boundary for provider output. An event is appended
and fsynced or committed before legacy Markdown logs, traces, activity,
@ -203,13 +282,14 @@ run log expose the same immutable envelope.
Each executable task adapter renders the provider-neutral envelope into its
own immutable `provider-task-envelope-transport/v1` request. OpenClaw, Codex
CLI, Codex SDK, and Hermes renderers all include the envelope digest, runtime
identity, objective and bounded context, a bounded workspace-baseline summary,
explicit commit policy, allowed side effects, expected outputs, verification
gates, and completion evidence contract. Profile instructions and saved task
checkpoints are rendered as separate, attributed sections and are capped at
20,000 characters each. The persisted task envelope retains the complete
baseline fingerprints used for later attribution.
CLI, Codex SDK, Claude Code, and Hermes renderers all include the envelope
digest, runtime identity, objective and bounded context, a bounded
workspace-baseline summary, explicit commit policy, allowed side effects,
expected outputs, verification gates, and completion evidence contract.
Profile instructions and saved task checkpoints are rendered as separate,
attributed sections and are capped at 20,000 characters each. The persisted
task envelope retains the complete baseline fingerprints used for later
attribution.
The callback posture belongs to the adapter:
@ -217,6 +297,8 @@ The callback posture belongs to the adapter:
the provider-runtime manifest digest.
- Codex CLI returns terminal output through the supervised process.
- Codex SDK returns terminal output through the captured SDK event stream.
- Claude Code returns its authoritative terminal result through the drained
stream-json process output.
- Hermes returns terminal output through scripted process stdout.
Process and stream adapters are explicitly told not to call the Veritas
@ -261,8 +343,9 @@ Completion status maps to task state as follows:
The legacy bounded `{ success, summary, error }` OpenClaw callback remains
accepted and is normalized into the same contract. New callbacks may report
the explicit status, blockers, provider evidence, artifacts, verification
claims, and a continuation handle. Codex CLI, Codex SDK, and Hermes still use
their native harness-owned terminal paths rather than the callback endpoint.
claims, and a continuation handle. Codex CLI, Codex SDK, Claude Code, and
Hermes still use their native harness-owned terminal paths rather than the
callback endpoint.
If the server restarts before a harness-owned process or stream attempt
persists a terminal result, startup reconciliation records a digest-bound
`interrupted` result instead of leaving a provider-specific running or failed
@ -333,7 +416,12 @@ Presets can be assigned to:
- A workflow agent, as the guardrail for that workflow role.
- A one-off agent start request, by passing `sandboxPresetId`.
The launch path dry-runs the selected preset before starting Codex CLI, Codex SDK, or OpenClaw-backed work. Required controls fail closed when the provider cannot support them. Advisory controls continue with warnings and a governance trace. Settings also includes a dry-run panel that shows effective sandbox mode, network access, environment allowlist, unsupported controls, and the trace ID.
The launch path dry-runs the selected preset before starting Codex CLI, Codex
SDK, Claude Code, or OpenClaw-backed work. Required controls fail closed when
the provider cannot support them. Advisory controls continue with warnings and
a governance trace. Settings also includes a dry-run panel that shows effective
sandbox mode, network access, environment allowlist, unsupported controls, and
the trace ID.
Credential references and environment-style `name=value` values are redacted from dry-run output and governance traces. Prefer brokered credential presets for workflows that need scoped secrets instead of exposing broad environment passthrough.
@ -400,6 +488,7 @@ Soft thresholds create `budget-policy` governance traces and visible warnings. H
| OpenAI Codex | `codex-cli` | `codex exec --sandbox workspace-write --json` | `codex login status` |
| OpenAI Codex SDK | `codex-sdk` | `codex` | SDK import plus Codex login |
| OpenAI Codex Cloud | `codex-cloud` | `gh` | `gh auth status` |
| Claude Code | `claude-code` | `claude` | Explicit bare-mode environment auth plus bounded probes |
| Hermes Agent | `hermes-cli` | `hermes` | `hermes --version` + `HERMES_API_KEY` or `ANTHROPIC_API_KEY` |
| Ollama Local | `ollama-local` | `ollama run llama3.2` | `ollama list` |
| Ollama Cloud | `ollama-cloud` | `ollama run gpt-oss:120b-cloud` | `ollama signin` or `OLLAMA_API_KEY` |

View file

@ -1317,6 +1317,7 @@ The server confirms with `run-session:subscribed` after the connection has
"taskId": "TASK-001",
"runId": "attempt_001",
"attemptId": "attempt_001",
"sessionId": "provider-session-001",
"sequence": 42,
"receivedAt": "2026-07-23T20:00:00.000Z",
"kind": "message.delta",
@ -2210,7 +2211,7 @@ controls:
"providerRuntime": {
"digest": "sha256:...",
"provider": "codex-cli",
"probeRevision": 3
"probeRevision": 4
},
"runtime": {
"command": "codex",
@ -2267,7 +2268,8 @@ The response contains `schemaVersion`, `taskId`, `attemptId`, ordered `events`,
`nextCursor`, and `hasMore`. `afterSequence` must be non-negative and `limit`
must be between 1 and 500. Access uses the same `run.logs` capability check as
attempt logs. Clients should persist `nextCursor` and reconnect with it rather
than inferring order from timestamps.
than inferring order from timestamps. `sessionId`, `turnId`, and `itemId` are
separate optional identities and appear only when the provider reports them.
Stop and message requests must carry the `attemptId` returned by status so a
delayed control cannot affect a replacement run:
@ -2313,8 +2315,9 @@ harness-owned process or stream attempts that were still running when the
server restarted. OpenClaw attempts remain eligible for their authoritative
callback after restart.
Codex CLI, Codex SDK, and Hermes do not call this endpoint; Veritas captures
their terminal process or stream output and owns completion normalization.
Codex CLI, Codex SDK, Claude Code, and Hermes do not call this endpoint;
Veritas captures their terminal process or stream output and owns completion
normalization.
Claimed success becomes `partial` when required harness evidence is absent,
commit policy is violated, a required output is missing, or an unauthorized
side effect is observed. `success` maps the task to `done`, `blocked` maps it
@ -2332,7 +2335,7 @@ Task and trace responses can therefore include the immutable
```json
{
"schemaVersion": "provider-runtime-manifest/v1",
"probeRevision": 3,
"probeRevision": 4,
"provider": "codex-cli",
"adapter": "codex-cli",
"protocolVersion": "codex-exec-json/v1",
@ -2391,8 +2394,8 @@ launch and finalization services may persist those authoritative contracts.
Before dispatch, the selected adapter renders the envelope through an immutable
`provider-task-envelope-transport/v1` request. Built-in renderers exist for
OpenClaw, Codex CLI, Codex SDK, and Hermes. Every rendered request includes the
envelope and runtime identity, objective and bounded context, workspace
OpenClaw, Codex CLI, Codex SDK, Claude Code, and Hermes. Every rendered request
includes the envelope and runtime identity, objective and bounded context, workspace
baseline, explicit commit policy, allowed side effects, outputs, verification
gates, and completion evidence contract. Profile instructions and task
checkpoint state are separate attributed sections capped at 20,000 characters
@ -2400,8 +2403,9 @@ each. The exact rendered content is fingerprinted in the run launch manifest
as `instructions.effective-task-request`.
OpenClaw's transport includes the attempt-bound completion callback. Codex CLI,
Codex SDK, and Hermes transports explicitly forbid calling that callback and
return terminal output through harness-owned process or stream capture.
Codex SDK, Claude Code, and Hermes transports explicitly forbid calling that
callback and return terminal output through harness-owned process or stream
capture.
Provider and adapter identity must match the envelope before dispatch. Veritas
does not infer native structured-output support from prompt rendering and owns
completion validation and normalization.

View file

@ -611,6 +611,12 @@ certification evidence. Use `vk doctor --json` for support-safe automation and
diagnostics, including redacted readiness reasons, safe probe commands, and
remediation.
For Claude Code, doctor reports the bounded version, auth-status, and agent
discovery probes, plus separate bare-mode authentication readiness. A
successful interactive OAuth status is diagnostic only because Veritas
launches Claude Code with `--bare`; configure an explicit supported
environment credential before enabling the profile.
---
## Workflow Commands Deep Dive

View file

@ -29,6 +29,7 @@ For current v5 screenshots and GIFs, see the
- [Agent Integration](#agent-integration)
- [Team Roster & Capability Routing](#team-roster--capability-routing)
- [OpenAI Codex Integration](#openai-codex-integration-v5)
- [Claude Code Integration](#claude-code-integration-v6)
- [Veritas Cutover & Hermes Support](#veritas-cutover--hermes-support)
- [Multi-Agent System](#multi-agent-system)
- [Squad Chat](#squad-chat)
@ -284,8 +285,8 @@ First-class support for autonomous coding agents.
- **Multi-agent support** — Ships with Codex, Codex SDK, Codex Cloud, Hermes Agent, Claude Code, Amp, Copilot, Gemini, Ollama Local, Ollama Cloud, LM Studio Local, and Veritas profiles; add completely custom agents via Settings → Agents
- **Agent CRUD management** — Full Add/Edit/Remove for agents in Settings → Agents; add agent form with name, type slug (auto-generated), command, and args; inline edit via pencil icon; remove via trash icon with confirmation (blocked for the default agent); `AgentType` accepts any string slug, not just built-in names
- **Agent request files** — Server writes structured requests to `.veritas-kanban/agent-requests/` for agent pickup
- **Provider-owned task-envelope transports** — OpenClaw, Codex CLI, Codex SDK, and Hermes each render the immutable task contract through an adapter-owned request with explicit commit policy, bounded attributed profile/checkpoint context, workspace baseline, verification gates, and completion evidence requirements
- **Provider-specific completion posture** — OpenClaw receives an attempt-bound completion callback; Codex CLI, Codex SDK, and Hermes return terminal output through harness-supervised process or stream capture
- **Provider-owned task-envelope transports** — OpenClaw, Codex CLI, Codex SDK, Claude Code, and Hermes each render the immutable task contract through an adapter-owned request with explicit commit policy, bounded attributed profile/checkpoint context, workspace baseline, verification gates, and completion evidence requirements
- **Provider-specific completion posture** — OpenClaw receives an attempt-bound completion callback; Codex CLI, Codex SDK, Claude Code, and Hermes return terminal output through harness-supervised process or stream capture
- **Multiple attempts** — Retry tasks with different agents; full attempt history preserved with status (pending, running, complete, failed)
- **Attempt history viewer** — Browse past attempts with agent name, status, and log output
- **Time tracking** — Start/stop timer or add manual time entries per task; running timer display with live elapsed counter
@ -293,17 +294,21 @@ First-class support for autonomous coding agents.
- **Agent status indicator** — Header-level indicator showing global agent state (idle, working, sub-agent mode with count)
- **Running indicator on cards** — Animated spinner on task cards when an agent is actively working
- **Agent output stream** — Real-time agent output via WebSocket with auto-scroll and clear
- **Causal run-event journal** — OpenClaw, Codex CLI, Codex SDK, and Hermes map provider output into one bounded, redacted, append-only `run-event/v1` stream with per-attempt ordering, provider deduplication, REST cursor replay, gap-free WebSocket reconnect, and compatible legacy output projections
- **Causal run-event journal** — OpenClaw, Codex CLI, Codex SDK, Claude Code, and Hermes map provider output into one bounded, redacted, append-only `run-event/v1` stream with per-attempt ordering, provider deduplication, REST cursor replay, gap-free WebSocket reconnect, and compatible legacy output projections
- **Send message to agent** — Send text messages to running agents
- **Optional OpenClaw support** — Built-in integration with [OpenClaw](https://github.com/openclaw/openclaw) (formerly Clawdbot/Moltbot) via gateway URL when you want OpenClaw to execute or wake agents
- **HermesAgent operating support** — v4.3 documents HermesAgent/Hermes Gateway as the active control plane, with Veritas tracking task truth, QA evidence, and GitHub delivery state
- **OpenAI Codex support** — Local CLI attempts, SDK sessions, GitHub-native Codex Cloud delegation, workflow steps, review actions, Settings health checks, MCP setup, and fresh-install default routing
- **Claude Code support** — First-class v2.1.218 bare-mode process adapter with
static sandbox-derived permissions, bounded JSONL event ingestion, session
persistence, usage/cost telemetry, artifact discovery, deterministic health
diagnostics, and fail-closed unsupported lifecycle controls
- **Local LLM provider profiles** — Ollama Local, Ollama Cloud, and LM Studio Local profiles can be enabled, health-checked, and targeted by routing rules in the web app or macOS app
- **Team roster routing manifests** — Workspace coordinators can define enabled members, capabilities, routing rules, fallbacks, reviewers, and escalation posture before `/api/agents/route` selects an agent
- **Workspace capability discovery** — Trusted workspace catalogs expose supported task types, SLA/queue posture, intake requirements, and delegated-work packaging so cross-workspace handoffs are explicit
- **Agent profile packages** — Reusable YAML/JSON packages bundle role, runtime, model, prompt instructions, tools, permissions, sandbox, budget, workflow, and health metadata for portable task launches
- **Provider runtime manifests** — Every executable adapter records a versioned, evidence-backed capability snapshot and digest on the attempt, history, trace, and log; provider version skew reruns conformance and unsupported configured providers fail closed instead of falling back to OpenClaw
- **Task-envelope transports** — Provider-owned renderers for OpenClaw, Codex CLI, Codex SDK, and Hermes bind the exact task-envelope digest and commit policy to the launched request; the rendered request is fingerprinted in the run launch manifest and mismatched provider/adapter identities fail closed
- **Task-envelope transports** — Provider-owned renderers for OpenClaw, Codex CLI, Codex SDK, Claude Code, and Hermes bind the exact task-envelope digest and commit policy to the launched request; the rendered request is fingerprinted in the run launch manifest and mismatched provider/adapter identities fail closed
- **Sandbox policy presets** — Built-in and custom presets control filesystem scope, network egress, environment passthrough, and credential brokering for agent profiles, workflow agents, and per-run overrides
- **Agent budget enforcement** — Workspace, agent, workflow, workflow-agent, and per-run budgets can cap tokens, provider cost, tool calls, runtime, retries, and workflow fan-out with warning, approval, downgrade, pause, or cancel actions
- **Platform-agnostic REST API** — Any platform that can make HTTP calls can drive the full agent lifecycle
@ -338,6 +343,41 @@ Documentation:
---
## Claude Code Integration (v6)
The `claude-code` provider runs Claude Code v2.1.218 directly in the assigned
worktree with `--bare`, `--print`, and `stream-json`. Veritas owns the task
envelope, static permissions, environment allowlist, process lifecycle, causal
event journal, terminal result, and completion normalization.
Implemented:
- **Bare-mode launch** — No shell, inherited settings, plugins, MCP config,
Chrome integration, slash commands, or permission bypass.
- **Static permissions** — Read tools are always available; writes, Bash, and
web tools are derived from the effective filesystem and network sandbox.
- **Explicit authentication** — OAuth/keychain status is diagnostic only;
launch requires an allowlisted environment credential or supported cloud
selector.
- **Full stream capture** — Partial text/thinking, tool results, hooks,
subagents, retries, usage/cost, artifacts, and terminal results map into
`run-event/v1`.
- **Drain-safe completion** — Veritas waits for queued output and parses a final
unterminated record after process close. A successful exit without a
successful provider result fails closed.
- **Session continuity evidence** — Claude `session_id` is stored on the attempt
and separately from turn/item identity in the event schema.
- **Versioned readiness** — The exact v2.1.218 runtime, probe revision 4,
authentication posture, and safe agent-discovery summary determine support
status.
- **Capability truth** — Resume/fork, interactive approvals, elicitation, and
MCP injection stay unsupported until the shared brokers are implemented.
See [Agent Providers](AGENT-PROVIDERS.md#claude-code-v21218) for setup,
credentials, arguments, permissions, and limitations.
---
## Team Roster & Capability Routing
Workspace-level routing metadata for agent teams and delegated work intake. Added in v5.2.
@ -1017,7 +1057,8 @@ Reusable launch-time sandbox presets for provider execution guardrails.
- Broker leases are internal until an accepted network or tool boundary can
consume them without provider bypass. Existing provider authentication and
explicit environment passthrough are not mislabeled as brokered.
- Provider capability checks currently distinguish Codex CLI, Codex SDK, Hermes, and OpenClaw execution behavior.
- Provider capability checks currently distinguish Codex CLI, Codex SDK,
Claude Code, Hermes, and OpenClaw execution behavior.
### Session Isolation

View file

@ -1,15 +1,106 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`provider task-envelope renderers > renders a callback-free Claude Code stream transport 1`] = `
"# Claude Code Task Envelope
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:fce321af942e8df197cd72f07cbd79ecf6f816883dea983369570c3ce348e4c8\`
- Provider: \`claude-code\`
- Adapter: \`claude-code\`
- Runtime manifest: \`sha256:81797719b9f52b60ef06c360b4c22ab58266ecfd16709fe848996dc8c9103c71\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
## Objective
Ship the provider transport
## Background
- Translate the immutable task contract without changing completion ownership.
## Constraints
- Follow the release checklist.
- Operate only inside the assigned worktree: /tmp/veritas-kanban-transport
## Workspace
- Repository: \`veritas-kanban\`
- Branch: \`feat/claude-code-transport\`
- Base branch: \`main\`
- Worktree: \`/tmp/veritas-kanban-transport\`
- Launch HEAD: \`aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\`
- Launch state: clean
### Launch Baseline Files
- None.
## Commit Policy
Required: create at least one new commit attributable to this attempt.
## Acceptance Criteria
- The provider receives the persisted envelope.
## Allowed Side Effects
- \`filesystem-write\` within \`/tmp/veritas-kanban-transport\`
- \`process-execute\` within \`/tmp/veritas-kanban-transport\`
- \`git-commit\` within \`/tmp/veritas-kanban-transport\`
- \`network-egress\` within \`sandbox policy\`
## Expected Outputs
- Required \`text\` \`completion-summary\`: A concise summary of the completed work and remaining risks.
- Required \`commit\` \`git-commit\`: At least one new commit attributable to this attempt.
## Verification Gates
- Required \`provider-snapshot\`: Run the provider transport snapshot. Evidence is required.
## Completion Evidence Contract
- Schema: \`completion-result/v1\`
- Required \`provider-output\` \`terminal-state\`: Harness-verified provider terminal state from the native transport.
- Required \`verification\` \`verification\`: Harness-verified evidence for every required verification gate.
- Required \`commit\` \`commit\`: Harness-verified commit created after the launch baseline.
## Completion (Claude Code stream)
Return the final response through the terminal result record captured by Veritas.
Do not call the Veritas completion callback. The harness owns terminal-state capture.
No native structured-output support is assumed; Veritas validates and normalizes stream-json events."
`;
exports[`provider task-envelope renderers > renders a callback-free Codex CLI transport from a forbidden-commit envelope 1`] = `
"# Codex CLI Task Envelope
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:9124d04ba445ec2a704a7a180f78897c4c97654e2db2fc0a40dd49fe36ba9f9f\`
- Digest: \`sha256:06cf84e060e67a0c1d571095f52e2a5cf23a2248f0ad380035649da277a9fa54\`
- Provider: \`codex-cli\`
- Adapter: \`codex-cli\`
- Runtime manifest: \`sha256:ac8168ea1345156e76f1ebd1dbdcb42ff19b808e52e2164a07257da52a546d24\`
- Runtime manifest: \`sha256:a9b0eaa76b547be237e5aa15d4e086dd8dd276f56b2bfd946bf9525d303a1dc8\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -94,10 +185,10 @@ exports[`provider task-envelope renderers > renders a callback-free Codex SDK tr
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:a3eafc0cbfd2b125c2e63d86614a97643ce5023dafc058499c863c41027a9e39\`
- Digest: \`sha256:ac0141209cc24ace565b5129478512298ab1158961e52d699d12d6931a2ba419\`
- Provider: \`codex-sdk\`
- Adapter: \`codex-sdk\`
- Runtime manifest: \`sha256:e00151ad599160706e2d814412bbf8c71bdc352317352a9a72df14574def305a\`
- Runtime manifest: \`sha256:21c5ddc1f5125634a9033069f3122894d866d6f70d357c7975c61bced5305f1d\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -183,10 +274,10 @@ exports[`provider task-envelope renderers > renders a callback-free Hermes scrip
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:e5c6eac76fa867f258802075bbd01e46f3842745ee2270eb454d860da4fc65f2\`
- Digest: \`sha256:b266ef347ab66676420169da055797f6d940a406e5e0f57d58bae94c4c0663f3\`
- Provider: \`hermes-cli\`
- Adapter: \`hermes-cli\`
- Runtime manifest: \`sha256:35efe1a6d4eee34a91f88ddf751fb2e746969f33a66c3dd1820c7dcc9ebed1eb\`
- Runtime manifest: \`sha256:08072e6dfdf09b99490b739f638fb1d71484ea9b5963f2f0a8828c9b51212204\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -274,10 +365,10 @@ exports[`provider task-envelope renderers > renders an OpenClaw callback transpo
## Transport
- Envelope: \`task-envelope/v1\`
- Digest: \`sha256:b15c3895ab0b1573673a7079718b3fa45106b0704594932b5ffd1b8761213dc0\`
- Digest: \`sha256:28896d406f7b2fa91aeb5fcff9ddc599f27c8d333f3b8b3399b26658033d3b15\`
- Provider: \`openclaw\`
- Adapter: \`openclaw\`
- Runtime manifest: \`sha256:a1b31859e0674254b1a7bffadc0d28b4a56999177a209be10003054f0ebad598\`
- Runtime manifest: \`sha256:0fb8600110961c719fd719ca519ef3dc33d99854dbafa011b6f6a44543a12d7f\`
- Protocol: \`fixture-runtime/v1\`
- Task: \`task_transport\`
- Attempt: \`attempt_transport\`
@ -376,7 +467,7 @@ When the work reaches a terminal state, report it to Veritas.
\`\`\`bash
curl -X POST http://localhost:3001/api/agents/task_transport/complete \\
-H "Content-Type: application/json" \\
-d '{"attemptId":"attempt_transport","providerRuntimeManifestDigest":"sha256:a1b31859e0674254b1a7bffadc0d28b4a56999177a209be10003054f0ebad598","success":true,"summary":"Brief description of what was done"}'
-d '{"attemptId":"attempt_transport","providerRuntimeManifestDigest":"sha256:0fb8600110961c719fd719ca519ef3dc33d99854dbafa011b6f6a44543a12d7f","success":true,"summary":"Brief description of what was done"}'
\`\`\`
For failure, send \`success: false\` and include an \`error\` message.

View file

@ -16,11 +16,13 @@ const baseAgent: AgentConfig = {
const originalHermesApiKey = process.env.HERMES_API_KEY;
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
afterEach(() => {
vi.restoreAllMocks();
restoreEnv('HERMES_API_KEY', originalHermesApiKey);
restoreEnv('ANTHROPIC_API_KEY', originalAnthropicApiKey);
restoreEnv('ANTHROPIC_AUTH_TOKEN', originalAnthropicAuthToken);
});
describe('AgentHealthService provider version evidence', () => {
@ -94,6 +96,86 @@ describe('AgentHealthService provider version evidence', () => {
});
expect(runCommand).toHaveBeenCalledOnce();
});
it('probes Claude Code auth and agent discovery without trusting OAuth for bare mode', async () => {
process.env.ANTHROPIC_API_KEY = 'test-only';
const runCommand = vi
.fn<AgentHealthCommandRunner>()
.mockResolvedValueOnce({
stdout: '2.1.218 (Claude Code)\n',
stderr: '',
})
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"api_key"}\n',
stderr: '',
})
.mockResolvedValueOnce({
stdout: '[{"name":"reviewer"},{"name":"builder"}]\n',
stderr: '',
});
const claudeAgent: AgentConfig = {
...baseAgent,
type: 'claude-code',
name: 'Claude Code',
command: process.execPath,
provider: 'claude-code',
};
const result = await new AgentHealthService(runCommand).checkAgent(claudeAgent);
expect(result).toMatchObject({
healthy: true,
authenticated: true,
providerVersion: '2.1.218 (Claude Code)',
diagnostics: expect.arrayContaining([
'Claude Code authentication status is ready.',
'Explicit bare-mode authentication is configured.',
'Claude Code agent discovery returned 2 definition(s).',
]),
});
expect(runCommand).toHaveBeenNthCalledWith(2, process.execPath, ['auth', 'status'], {
timeout: 5_000,
maxBuffer: 8 * 1024,
shell: false,
});
expect(runCommand).toHaveBeenNthCalledWith(3, process.execPath, ['agents', '--json'], {
timeout: 5_000,
maxBuffer: 8 * 1024,
shell: false,
});
});
it('fails Claude Code readiness when bare-mode authentication is absent', async () => {
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
const runCommand = vi
.fn<AgentHealthCommandRunner>()
.mockResolvedValueOnce({
stdout: '2.1.218 (Claude Code)\n',
stderr: '',
})
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"oauth"}\n',
stderr: '',
})
.mockResolvedValueOnce({
stdout: '[]\n',
stderr: '',
});
const result = await new AgentHealthService(runCommand).checkAgent({
...baseAgent,
type: 'claude-code',
name: 'Claude Code',
command: process.execPath,
provider: 'claude-code',
});
expect(result).toMatchObject({
healthy: false,
authenticated: false,
reason: expect.stringMatching(/bare mode requires explicit environment authentication/i),
});
});
});
function restoreEnv(key: string, value: string | undefined): void {

View file

@ -0,0 +1,59 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';
import {
buildClaudeCodeArgs,
buildSafeClaudeCodeEnv,
CLAUDE_CODE_CERTIFIED_VERSION,
classifyClaudeCodeStreamRecord,
hasClaudeCodeBareAuthentication,
parseClaudeCodeStreamLine,
} from '../services/claude-code-adapter.js';
const execFileAsync = promisify(execFile);
const smokeEnabled = process.env.VERITAS_CLAUDE_CODE_SMOKE === '1';
describe.runIf(smokeEnabled)('@smoke Claude Code v2.1.218', () => {
it(
'runs one pinned bare-mode stream and returns an authoritative result',
{ timeout: 120_000 },
async () => {
expect(hasClaudeCodeBareAuthentication(process.env)).toBe(true);
const version = await execFileAsync('claude', ['--version'], {
encoding: 'utf8',
timeout: 10_000,
maxBuffer: 8 * 1024,
shell: false,
});
expect(`${version.stdout}${version.stderr}`.trim()).toBe(CLAUDE_CODE_CERTIFIED_VERSION);
const args = buildClaudeCodeArgs({
prompt: 'Reply with exactly: VERITAS_CLAUDE_CODE_SMOKE_OK',
sandboxMode: 'read-only',
networkAccessEnabled: false,
maxBudgetUsd: 0.25,
extraArgs: ['--max-turns', '1'],
});
const result = await execFileAsync('claude', args, {
encoding: 'utf8',
timeout: 90_000,
maxBuffer: 8 * 1024 * 1024,
shell: false,
env: buildSafeClaudeCodeEnv(process.env),
});
const records = String(result.stdout)
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map(parseClaudeCodeStreamLine)
.map(classifyClaudeCodeStreamRecord);
const terminal = records.find((record) => record.terminal)?.terminal;
expect(terminal).toMatchObject({
success: true,
subtype: 'success',
});
expect(terminal?.summary).toContain('VERITAS_CLAUDE_CODE_SMOKE_OK');
}
);
});

View file

@ -0,0 +1,246 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
buildClaudeCodeArgs,
buildSafeClaudeCodeEnv,
classifyClaudeCodeStreamRecord,
hasClaudeCodeBareAuthentication,
parseClaudeCodeStreamLine,
} from '../services/claude-code-adapter.js';
import { getProviderRunEventMapper } from '../services/provider-run-event-mappers.js';
import { getProviderRuntimeAdapterDefinition } from '../services/provider-runtime-adapter-registry.js';
const FIXTURE_PATH = fileURLToPath(
new URL('./fixtures/claude-code-v2.1.218.stream.jsonl', import.meta.url)
);
describe('Claude Code v2.1.218 adapter contract', () => {
it('builds a reproducible bare-mode streaming launch with static permissions', () => {
const args = buildClaudeCodeArgs({
prompt: 'Complete the task.',
model: 'claude-opus-4-6',
sandboxMode: 'workspace-write',
networkAccessEnabled: false,
maxBudgetUsd: 4.25,
});
expect(args).toEqual(
expect.arrayContaining([
'--bare',
'--print',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--include-hook-events',
'--forward-subagent-text',
'--permission-mode',
'dontAsk',
'--max-budget-usd',
'4.25',
'--max-turns',
'100',
'--model',
'claude-opus-4-6',
])
);
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args[args.length - 1]).toBe('Complete the task.');
expect(args[args.indexOf('--allowedTools') + 1]).not.toContain('Bash');
expect(args[args.indexOf('--disallowedTools') + 1]).toContain('WebFetch');
});
it('rejects launch arguments that can inherit or bypass ungoverned configuration', () => {
for (const extraArgs of [
['--dangerously-skip-permissions'],
['--permission-prompt-tool', 'mcp__unowned__approve'],
['--settings', '/tmp/unowned.json'],
['--plugin-dir', '/tmp/plugin'],
['--mcp-config', '/tmp/mcp.json'],
['--allowedTools', 'Bash'],
]) {
expect(() =>
buildClaudeCodeArgs({
prompt: 'task',
sandboxMode: 'workspace-write',
networkAccessEnabled: true,
extraArgs,
})
).toThrow(/not allowed|broker|controlled/i);
}
});
it('passes only explicit Claude Code credentials and safe process context', () => {
const source = {
HOME: '/home/operator',
PATH: '/usr/bin',
ANTHROPIC_API_KEY: 'test-key',
CLAUDE_CONFIG_DIR: '/home/operator/.claude',
GITHUB_TOKEN: 'do-not-forward',
DATABASE_URL: 'do-not-forward',
EXTRA_SAFE: 'allowed-by-policy',
};
const env = buildSafeClaudeCodeEnv(source, ['EXTRA_SAFE', 'GITHUB_TOKEN']);
expect(env).toMatchObject({
HOME: '/home/operator',
PATH: '/usr/bin',
ANTHROPIC_API_KEY: 'test-key',
EXTRA_SAFE: 'allowed-by-policy',
VK_API_URL: 'http://localhost:3001',
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1',
});
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.DATABASE_URL).toBeUndefined();
expect(hasClaudeCodeBareAuthentication(source)).toBe(true);
expect(hasClaudeCodeBareAuthentication({ HOME: '/home/operator' })).toBe(false);
expect(
hasClaudeCodeBareAuthentication({
CLAUDE_CODE_USE_FOUNDRY: '1',
ANTHROPIC_FOUNDRY_RESOURCE: 'resource-without-credentials',
})
).toBe(false);
expect(
hasClaudeCodeBareAuthentication({
CLAUDE_CODE_USE_FOUNDRY: '1',
ANTHROPIC_FOUNDRY_AUTH_TOKEN: 'foundry-token',
})
).toBe(true);
expect(
hasClaudeCodeBareAuthentication({
CLAUDE_CODE_USE_BEDROCK: '0',
AWS_PROFILE: 'profile-that-must-not-be-used',
})
).toBe(false);
});
it('parses the pinned golden stream into session, event, usage, artifact, and terminal evidence', async () => {
const records = (await readFile(FIXTURE_PATH, 'utf8'))
.trim()
.split(/\r?\n/)
.map(parseClaudeCodeStreamLine)
.map(classifyClaudeCodeStreamRecord);
expect(records[0]).toMatchObject({
providerType: 'system.init',
sessionId: '11111111-1111-4111-8111-111111111111',
});
expect(records[1]).toMatchObject({
providerType: 'stream_event.content_block_delta.text_delta',
summary: 'Inspecting the repository.',
});
expect(records[2]).toMatchObject({
providerType: 'assistant.tool_use',
tool: 'Read',
files: ['server/src/index.ts'],
});
expect(records[3]).toMatchObject({
providerType: 'user.tool_result',
parentToolUseId: 'tool_1',
});
expect(records[4].providerType).toBe('system.hook_started');
expect(records[5]).toMatchObject({
providerType: 'assistant.subagent',
parentToolUseId: 'agent_tool_1',
summary: 'Subagent report.',
});
expect(records[6]).toMatchObject({
providerType: 'result.success',
terminal: {
success: true,
summary: 'Implemented and verified the requested change.',
},
usage: {
inputTokens: 120,
outputTokens: 80,
totalTokens: 200,
cost: 0.041,
},
});
});
it('maps Claude stream semantics without discarding the raw provider record', async () => {
const [initLine, deltaLine, toolLine, resultLine] = (await readFile(FIXTURE_PATH, 'utf8'))
.trim()
.split(/\r?\n/)
.filter((_, index) => [0, 1, 2, 6].includes(index));
const mapper = getProviderRunEventMapper('claude-code');
const mapped = [initLine, deltaLine, toolLine, resultLine].map((line) => {
const record = parseClaudeCodeStreamLine(line);
const classified = classifyClaudeCodeStreamRecord(record);
return mapper.mapEvent(classified.providerType, record, classified.summary);
});
expect(mapped.map((event) => event.kind)).toEqual([
'progress',
'message.delta',
'tool.started',
'progress',
]);
expect(mapped[1]).toMatchObject({
providerEventId: 'event_delta_1',
sessionId: '11111111-1111-4111-8111-111111111111',
});
expect(mapped[2].payload).toMatchObject({
providerType: 'assistant.tool_use',
raw: expect.objectContaining({ type: 'assistant' }),
});
});
it('publishes fail-closed capabilities until shared lifecycle and approval brokers land', () => {
const capabilities = getProviderRuntimeAdapterDefinition('claude-code').capabilities;
const state = (id: string) => capabilities.find((capability) => capability.id === id)?.state;
expect(state('run.start')).toBe('supported');
expect(state('run.streaming')).toBe('supported');
expect(state('run.structured-events')).toBe('supported');
expect(state('usage.tokens')).toBe('supported');
expect(state('run.resume')).toBe('advisory');
expect(state('run.approvals')).toBe('unsupported');
expect(state('run.elicitation')).toBe('unsupported');
expect(state('tool.mcp')).toBe('unsupported');
});
it('fails malformed and truncated records closed', () => {
expect(() => parseClaudeCodeStreamLine('not json')).toThrow(/valid JSON/i);
expect(() => parseClaudeCodeStreamLine('{"type":"result"')).toThrow(/valid JSON/i);
expect(() => parseClaudeCodeStreamLine('[]')).toThrow(/object/i);
expect(() => parseClaudeCodeStreamLine(JSON.stringify({ no_type: true }))).toThrow(/type/i);
expect(() =>
parseClaudeCodeStreamLine(
JSON.stringify({ type: 'system', payload: 'x'.repeat(1024 * 1024) })
)
).toThrow(/1 MiB/i);
expect(() =>
buildClaudeCodeArgs({
prompt: 'task',
sandboxMode: 'workspace-write',
networkAccessEnabled: true,
extraArgs: ['--effort', 'ultracode'],
})
).toThrow(/not supported/i);
});
it('drops oversized or control-bearing provider identifiers and artifact paths', () => {
const classified = classifyClaudeCodeStreamRecord({
type: 'assistant',
session_id: `session-${'x'.repeat(300)}`,
message: {
content: [
{
type: 'tool_use',
name: 'Read\nInjected heading',
input: { file_path: 'server/src/index.ts\nInjected log content' },
},
],
},
});
expect(classified.providerType).toBe('assistant.tool_use');
expect(classified.sessionId).toBeUndefined();
expect(classified.tool).toBeUndefined();
expect(classified.files).toEqual([]);
});
});

View file

@ -567,6 +567,104 @@ describe('ClawdbotAgentService Codex providers', () => {
}
);
it('drains a final Claude Code result without a trailing newline before completion', async () => {
const fixture = await fs.readFile(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'fixtures',
'claude-code-v2.1.218.stream.jsonl'
),
'utf-8'
);
const records = fixture.trim().split(/\r?\n/);
const terminal = {
...(JSON.parse(records.at(-1) as string) as Record<string, unknown>),
apiKey: 'sk-must-not-be-persisted',
};
const stream = [...records.slice(0, -1), JSON.stringify(terminal)].join('\n');
mockSpawn.mockReturnValue(createFakeChild(stream));
vi.stubEnv('ANTHROPIC_API_KEY', 'test-claude-key');
task = { ...task, agent: 'claude-code' };
mockGetConfig.mockResolvedValue({
agents: [
{
type: 'claude-code',
name: 'Claude Code',
command: 'claude',
args: [],
enabled: true,
provider: 'claude-code',
model: 'claude-opus-4-6',
},
],
});
mockCheckAgent.mockResolvedValue({
type: 'claude-code',
name: 'Claude Code',
enabled: true,
configured: true,
command: 'claude',
executableFound: true,
executablePath: '/usr/local/bin/claude',
providerVersion: '2.1.218 (Claude Code)',
providerVersionSource: 'claude --version',
authenticated: true,
healthy: true,
checkedAt: '2026-07-23T00:00:00.000Z',
});
const service = testableService(tmpDir);
const active = await service.startAgent(task.id, 'claude-code');
await waitFor(() => expect(task.status).toBe('done'));
expect(active.runLaunchManifest).toMatchObject({
runtime: {
command: 'claude',
args: expect.arrayContaining([
'--bare',
'--output-format',
'stream-json',
'--permission-mode',
'dontAsk',
]),
credentialReferences: expect.arrayContaining(['env:ANTHROPIC_API_KEY']),
},
});
expect(mockSpawn).toHaveBeenCalledWith(
'claude',
expect.arrayContaining([
'--bare',
'--output-format',
'stream-json',
'--permission-mode',
'dontAsk',
]),
expect.objectContaining({ cwd: tmpDir, shell: false })
);
expect(task.attempt).toMatchObject({
id: active.attemptId,
status: 'complete',
threadId: '11111111-1111-4111-8111-111111111111',
completionResult: {
status: 'success',
terminalSource: 'process',
},
});
expect(mockTelemetryEmit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'run.tokens',
inputTokens: 120,
outputTokens: 80,
totalTokens: 200,
cost: 0.041,
})
);
const log = await service.getAttemptLog(task.id, active.attemptId);
expect(log).toContain('Implemented and verified the requested change.');
expect(log).not.toContain('sk-must-not-be-persisted');
expect(log).toContain('"apiKey": "[REDACTED]"');
});
it('redacts provider identity secrets before emitting harness telemetry', async () => {
const fixture = await fs.readFile(path.join(fixtureDir, 'success.jsonl'), 'utf-8');
mockSpawn.mockReturnValue(createFakeChild(fixture));

View file

@ -49,8 +49,9 @@ describe('ConfigService', () => {
supportProfile: expect.objectContaining({
schemaVersion: 'harness-support-profile/v1',
id: 'claude-code',
adapterId: 'claude-code',
transport: 'process-jsonl',
supportTier: 'unsupported',
supportTier: 'configured',
}),
}),
expect.objectContaining({
@ -114,7 +115,7 @@ describe('ConfigService', () => {
expect(config.defaultAgent).toBe('codex');
const expectedSupport = [
['claude-code', undefined, 'unsupported'],
['claude-code', 'claude-code', 'configured'],
['amp', undefined, 'unsupported'],
['copilot', undefined, 'unsupported'],
['gemini', undefined, 'unsupported'],

View file

@ -0,0 +1,7 @@
{"type":"system","subtype":"init","uuid":"event_init_1","session_id":"11111111-1111-4111-8111-111111111111","model":"claude-opus-4-6","tools":["Read","Edit","Bash"],"mcp_servers":[],"plugins":[],"capabilities":["interrupt_receipt_v1"]}
{"type":"stream_event","uuid":"event_delta_1","session_id":"11111111-1111-4111-8111-111111111111","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Inspecting the repository."}}}
{"type":"assistant","uuid":"event_tool_1","session_id":"11111111-1111-4111-8111-111111111111","parent_tool_use_id":null,"message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"file_path":"server/src/index.ts"}}]}}
{"type":"user","uuid":"event_tool_result_1","session_id":"11111111-1111-4111-8111-111111111111","parent_tool_use_id":"tool_1","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","content":"file contents"}]}}
{"type":"system","subtype":"hook_started","uuid":"event_hook_1","session_id":"11111111-1111-4111-8111-111111111111","hook_name":"PostToolUse"}
{"type":"assistant","uuid":"event_subagent_1","session_id":"11111111-1111-4111-8111-111111111111","parent_tool_use_id":"agent_tool_1","message":{"content":[{"type":"text","text":"Subagent report."}]}}
{"type":"result","subtype":"success","uuid":"event_result_1","session_id":"11111111-1111-4111-8111-111111111111","is_error":false,"result":"Implemented and verified the requested change.","duration_ms":1550,"duration_api_ms":1200,"num_turns":3,"total_cost_usd":0.041,"usage":{"input_tokens":120,"output_tokens":80,"cache_read_input_tokens":20},"modelUsage":{"claude-opus-4-6":{"inputTokens":120,"outputTokens":80,"costUSD":0.041}},"permission_denials":[]}

View file

@ -20,7 +20,7 @@ function agent(overrides: Partial<AgentConfig> = {}): AgentConfig {
}
describe('evaluateHarnessSupportStatus', () => {
it('classifies a display-only profile as unsupported even when its executable is installed', () => {
it('requires exact Claude Code version evidence before treating the executable as configured', () => {
const candidate = agent();
expect(
@ -37,8 +37,8 @@ describe('evaluateHarnessSupportStatus', () => {
})
).toMatchObject({
profileId: 'claude-code',
supportTier: 'unsupported',
failureClass: 'adapter-unavailable',
supportTier: 'degraded',
failureClass: 'incompatible-build',
executableFound: true,
});
});

View file

@ -37,7 +37,7 @@ function config(provider: AgentConfig['provider']): AgentConfig {
return {
type: provider ?? 'fixture',
name: provider ?? 'Fixture',
command: provider === 'hermes-cli' ? 'hermes' : 'codex',
command: provider === 'hermes-cli' ? 'hermes' : provider === 'claude-code' ? 'claude' : 'codex',
args: [],
enabled: true,
provider,
@ -46,10 +46,7 @@ function config(provider: AgentConfig['provider']): AgentConfig {
}
describe('ClawdbotAgentService provider runtime adapters', () => {
it.each([
['claude-code', 'claude'],
['copilot', 'copilot'],
] as const)(
it.each([['copilot', 'copilot']] as const)(
'fails closed when the provider-less %s display profile is probed',
async (type, command) => {
await expect(
@ -122,7 +119,7 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
details: expect.objectContaining({
profileId: 'claude-code',
provider: 'openclaw',
reason: 'Harness support profile has no executable adapter',
reason: 'Harness support profile adapter does not match the configured provider',
}),
});
});
@ -172,6 +169,7 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
it.each([
['codex-cli', 'codex-exec-json/v1', 'supported', 'ready'],
['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'],
] as const)(

View file

@ -6,6 +6,7 @@ import {
type CompletionEvidenceSource,
} from '../services/task-envelope-service.js';
import {
renderClaudeCodeTaskEnvelope,
renderCodexCliTaskEnvelope,
renderCodexSdkTaskEnvelope,
renderHermesTaskEnvelope,
@ -174,6 +175,24 @@ describe('provider task-envelope renderers', () => {
expect(transport.content).toMatchSnapshot();
});
it('renders a callback-free Claude Code stream transport', async () => {
const taskEnvelope = await envelope('claude-code', 'required');
const transport = renderClaudeCodeTaskEnvelope({ taskEnvelope });
expect(transport).toMatchObject({
provider: 'claude-code',
callbackPosture: 'harness-owned',
completionNormalization: 'harness',
});
expect(transport.content).toContain('## Completion (Claude Code stream)');
expect(transport.content).toContain(
'Return the final response through the terminal result record captured by Veritas.'
);
expect(transport.content).not.toContain('/api/agents/task_transport/complete');
expect(transport.content).toMatchSnapshot();
});
it('renders a callback-free Hermes scripted transport', async () => {
const taskEnvelope = await envelope('hermes-cli', 'required');

View file

@ -108,6 +108,23 @@ describe('RunEventJournalService', () => {
expect(raw).not.toContain('sk-secret');
});
it('persists provider session identity separately from turn identity', async () => {
const directory = await temporaryDirectory();
const journal = new RunEventJournalService(new FileRunEventRepository(directory));
const result = await journal.append(
appendInput({
sessionId: 'session_claude_1',
turnId: 'turn_1',
payload: { summary: 'session-bound event' },
})
);
expect(result.event).toMatchObject({
sessionId: 'session_claude_1',
turnId: 'turn_1',
});
});
it('drops payload bodies that remain oversized after bounded normalization', async () => {
const directory = await temporaryDirectory();
const journal = new RunEventJournalService(new FileRunEventRepository(directory));

View file

@ -28,6 +28,7 @@ export const AgentProfileRuntimeSchema = z
'openclaw',
'codex-cli',
'codex-sdk',
'claude-code',
'hermes-cli',
'codex-cloud',
'ollama-local',

View file

@ -40,6 +40,7 @@ export const RunEventEnvelopeSchema: z.ZodType<RunEventEnvelope> = z
taskId: IdentifierSchema,
runId: IdentifierSchema,
attemptId: IdentifierSchema,
sessionId: IdentifierSchema.optional(),
turnId: IdentifierSchema.optional(),
itemId: IdentifierSchema.optional(),
providerEventId: IdentifierSchema.optional(),

View file

@ -4,6 +4,7 @@ import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { AgentConfig } from '@veritas-kanban/shared';
import { hasClaudeCodeBareAuthentication } from './claude-code-adapter.js';
const execFileAsync = promisify(execFile);
const PROVIDER_VERSION_TIMEOUT_MS = 5_000;
@ -55,6 +56,7 @@ export interface AgentHealthStatus {
healthy: boolean;
checkedAt: string;
reason?: string;
diagnostics?: string[];
}
export interface AgentHealthChecker {
@ -87,6 +89,7 @@ export class AgentHealthService implements AgentHealthChecker {
healthy: agent.enabled && executable.found && auth.authenticated !== false,
checkedAt,
reason,
...(auth.diagnostics?.length ? { diagnostics: auth.diagnostics } : {}),
};
}
@ -113,7 +116,7 @@ export class AgentHealthService implements AgentHealthChecker {
private async checkAuth(
agent: AgentConfig,
versionProbe: ProviderVersionProbe
): Promise<{ authenticated: boolean | null; error?: string }> {
): Promise<{ authenticated: boolean | null; error?: string; diagnostics?: string[] }> {
const command = path.basename(agent.command);
const provider = agent.provider ?? '';
@ -154,6 +157,10 @@ export class AgentHealthService implements AgentHealthChecker {
return { authenticated: true };
}
if (provider === 'claude-code') {
return this.probeClaudeCodeAuthentication(agent.command);
}
if (provider.startsWith('codex') || command === 'codex') {
return this.runAuthProbe(agent.command, ['login', 'status'], /logged in/i);
}
@ -161,6 +168,69 @@ export class AgentHealthService implements AgentHealthChecker {
return { authenticated: null };
}
private async probeClaudeCodeAuthentication(
command: string
): Promise<{ authenticated: boolean; error?: string; diagnostics: string[] }> {
const diagnostics: string[] = [];
try {
const { stdout, stderr } = await this.runCommand(command, ['auth', 'status'], {
timeout: PROVIDER_VERSION_TIMEOUT_MS,
maxBuffer: PROVIDER_VERSION_MAX_BUFFER_BYTES,
shell: false,
});
const output = boundUtf8(`${stdout}${stderr}`.trim(), PROVIDER_VERSION_MAX_BUFFER_BYTES);
const authStatusReady = /"loggedIn"\s*:\s*true|"logged_in"\s*:\s*true|logged in/i.test(
output
);
diagnostics.push(
authStatusReady
? 'Claude Code authentication status is ready.'
: 'Claude Code authentication status did not report a logged-in session.'
);
} catch {
diagnostics.push('Claude Code authentication status probe failed.');
}
const bareAuthentication = hasClaudeCodeBareAuthentication(process.env);
diagnostics.push(
bareAuthentication
? 'Explicit bare-mode authentication is configured.'
: 'Bare mode requires explicit environment authentication; OAuth and keychain state are not inherited.'
);
try {
const { stdout } = await this.runCommand(command, ['agents', '--json'], {
timeout: PROVIDER_VERSION_TIMEOUT_MS,
maxBuffer: PROVIDER_VERSION_MAX_BUFFER_BYTES,
shell: false,
});
const parsed = JSON.parse(
boundUtf8(String(stdout).trim(), PROVIDER_VERSION_MAX_BUFFER_BYTES) || '[]'
) as unknown;
const count = Array.isArray(parsed)
? parsed.length
: parsed &&
typeof parsed === 'object' &&
Array.isArray((parsed as { agents?: unknown }).agents)
? (parsed as { agents: unknown[] }).agents.length
: 0;
diagnostics.push(`Claude Code agent discovery returned ${count} definition(s).`);
} catch {
diagnostics.push('Claude Code agent discovery probe failed; run `claude agents --json`.');
}
const authenticated = bareAuthentication;
return {
authenticated,
...(authenticated
? {}
: {
error: 'Claude Code bare mode requires explicit environment authentication.',
}),
diagnostics,
};
}
private async runAuthProbe(
command: string,
args: string[],

View file

@ -0,0 +1,494 @@
import type { SandboxPolicyDryRunResult } from '@veritas-kanban/shared';
export const CLAUDE_CODE_CERTIFIED_VERSION = '2.1.218 (Claude Code)';
export const CLAUDE_CODE_PROTOCOL_VERSION = 'claude-code-stream-json/v1';
export const CLAUDE_CODE_MAX_STREAM_RECORD_BYTES = 1024 * 1024;
const DEFAULT_MAX_TURNS = 100;
const MAX_CONFIGURED_TURNS = 1_000;
const MAX_SUMMARY_LENGTH = 8_000;
const BASE_ENVIRONMENT_ALLOWLIST = new Set([
'CI',
'FORCE_COLOR',
'HOME',
'LANG',
'LC_ALL',
'LC_CTYPE',
'LOGNAME',
'NODE_EXTRA_CA_CERTS',
'NO_COLOR',
'PATH',
'SHELL',
'SSL_CERT_FILE',
'TEMP',
'TERM',
'TMP',
'TMPDIR',
'USER',
'VK_API_URL',
]);
export const CLAUDE_CODE_CREDENTIAL_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_FOUNDRY_API_KEY',
'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_BEARER_TOKEN_BEDROCK',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
] as const;
export const CLAUDE_CODE_ENVIRONMENT_KEYS = [
'ANTHROPIC_BEDROCK_BASE_URL',
'ANTHROPIC_FOUNDRY_BASE_URL',
'ANTHROPIC_FOUNDRY_RESOURCE',
'ANTHROPIC_VERTEX_BASE_URL',
'ANTHROPIC_VERTEX_PROJECT_ID',
'AWS_DEFAULT_REGION',
'AWS_PROFILE',
'AWS_REGION',
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_VERTEX',
'CLOUD_ML_REGION',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
] as const;
const EXPLICIT_CREDENTIAL_KEYS = new Set<string>(CLAUDE_CODE_CREDENTIAL_ENV_KEYS);
const EXPLICIT_ENVIRONMENT_KEYS = new Set<string>(CLAUDE_CODE_ENVIRONMENT_KEYS);
const SECRET_ENV_KEY_PATTERN =
/(?:SECRET|TOKEN|PASSWORD|PASS|CREDENTIAL|COOKIE|SESSION|WEBHOOK|DATABASE|DB_URL|PRIVATE|SERVICE_ROLE|ADMIN_KEY|API_KEYS?|GITHUB|GH_|SUPABASE|STRIPE|AZURE_|GCP_)/i;
const CONTROLLED_OR_UNSAFE_FLAGS = new Set([
'--add-dir',
'--agent',
'--agents',
'--allow-dangerously-skip-permissions',
'--allowed-tools',
'--allowedTools',
'--append-system-prompt-file',
'--bare',
'--continue',
'--dangerously-skip-permissions',
'--disable-slash-commands',
'--disallowed-tools',
'--disallowedTools',
'--fork-session',
'--include-hook-events',
'--include-partial-messages',
'--input-format',
'--json-schema',
'--mcp-config',
'--model',
'--no-chrome',
'--output-format',
'--permission-mode',
'--permission-prompt-tool',
'--plugin-dir',
'--plugin-url',
'--print',
'--replay-user-messages',
'--resume',
'--safe-mode',
'--session-id',
'--setting-sources',
'--settings',
'--strict-mcp-config',
'--system-prompt',
'--system-prompt-file',
'--tools',
'--verbose',
]);
export interface ClaudeCodeLaunchInput {
prompt: string;
model?: string;
extraArgs?: string[];
sandboxMode: SandboxPolicyDryRunResult['effective']['sandboxMode'];
networkAccessEnabled: boolean;
maxBudgetUsd?: number;
}
export interface ClaudeCodeUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cost?: number;
model?: string;
}
export interface ClaudeCodeTerminalResult {
success: boolean;
summary?: string;
error?: string;
subtype: string;
}
export interface ClaudeCodeStreamClassification {
providerType: string;
summary?: string;
sessionId?: string;
parentToolUseId?: string;
tool?: string;
files: string[];
usage?: ClaudeCodeUsage;
terminal?: ClaudeCodeTerminalResult;
}
export function buildSafeClaudeCodeEnv(
source: NodeJS.ProcessEnv = process.env,
passthroughKeys?: Iterable<string>
): Record<string, string> {
const allowlist = new Set(BASE_ENVIRONMENT_ALLOWLIST);
for (const key of EXPLICIT_CREDENTIAL_KEYS) allowlist.add(key);
for (const key of EXPLICIT_ENVIRONMENT_KEYS) allowlist.add(key);
if (passthroughKeys) {
for (const key of passthroughKeys) allowlist.add(key.toUpperCase());
}
const env: Record<string, string> = {};
for (const key of allowlist) {
const value = source[key];
if (typeof value !== 'string') continue;
if (
SECRET_ENV_KEY_PATTERN.test(key) &&
!EXPLICIT_CREDENTIAL_KEYS.has(key) &&
!EXPLICIT_ENVIRONMENT_KEYS.has(key)
) {
continue;
}
env[key] = value;
}
env.VK_API_URL = source.VK_API_URL || 'http://localhost:3001';
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = '1';
return env;
}
export function hasClaudeCodeBareAuthentication(source: NodeJS.ProcessEnv = process.env): boolean {
if (source.ANTHROPIC_API_KEY || source.ANTHROPIC_AUTH_TOKEN) return true;
if (
enabledEnvironmentFlag(source.CLAUDE_CODE_USE_BEDROCK) &&
(source.AWS_BEARER_TOKEN_BEDROCK ||
source.AWS_PROFILE ||
(source.AWS_ACCESS_KEY_ID && source.AWS_SECRET_ACCESS_KEY))
) {
return true;
}
if (
enabledEnvironmentFlag(source.CLAUDE_CODE_USE_VERTEX) &&
source.GOOGLE_APPLICATION_CREDENTIALS
) {
return true;
}
return Boolean(
enabledEnvironmentFlag(source.CLAUDE_CODE_USE_FOUNDRY) &&
(source.ANTHROPIC_FOUNDRY_API_KEY || source.ANTHROPIC_FOUNDRY_AUTH_TOKEN)
);
}
function enabledEnvironmentFlag(value: string | undefined): boolean {
return value === '1' || value?.toLowerCase() === 'true';
}
export function buildClaudeCodeArgs(input: ClaudeCodeLaunchInput): string[] {
const normalizedExtras = normalizeExtraArgs(input.extraArgs ?? []);
const configuredMaxTurns = readConfiguredMaxTurns(normalizedExtras);
const extraArgs = removeFlagAndValue(normalizedExtras, '--max-turns');
const writable = input.sandboxMode !== 'read-only';
const allowedTools = ['Read', 'Glob', 'Grep', ...(writable ? ['Edit', 'Write'] : [])];
if (writable && input.networkAccessEnabled) allowedTools.push('Bash');
const deniedTools = [
'Read(.env)',
'Read(.env.*)',
'Read(**/.env)',
'Read(**/.env.*)',
'Read(**/*secret*)',
'Read(**/*credential*)',
...(!input.networkAccessEnabled ? ['WebFetch', 'WebSearch'] : []),
];
const args = [
'--bare',
'--print',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--include-hook-events',
'--forward-subagent-text',
'--disable-slash-commands',
'--no-chrome',
'--permission-mode',
'dontAsk',
'--allowedTools',
allowedTools.join(','),
'--disallowedTools',
deniedTools.join(','),
'--max-turns',
String(configuredMaxTurns ?? DEFAULT_MAX_TURNS),
...extraArgs,
];
if (input.maxBudgetUsd !== undefined) {
if (!Number.isFinite(input.maxBudgetUsd) || input.maxBudgetUsd <= 0) {
throw new Error('Claude Code maximum budget must be a positive finite number.');
}
args.push('--max-budget-usd', String(input.maxBudgetUsd));
}
if (input.model?.trim()) args.push('--model', input.model.trim());
args.push(input.prompt);
return args;
}
function normalizeExtraArgs(args: string[]): string[] {
const normalized: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const flag = args[index];
if (
CONTROLLED_OR_UNSAFE_FLAGS.has(flag) ||
[...CONTROLLED_OR_UNSAFE_FLAGS].some((candidate) => flag.startsWith(`${candidate}=`))
) {
if (flag === '--permission-prompt-tool' || flag.startsWith('--permission-prompt-tool=')) {
throw new Error(
'Claude Code permission prompt routing is unavailable until the Veritas approval and MCP brokers are active.'
);
}
throw new Error(`Claude Code launch argument "${flag}" is controlled or not allowed.`);
}
if (!['--effort', '--fallback-model', '--betas', '--max-turns', '--name'].includes(flag)) {
throw new Error(`Claude Code launch argument "${flag}" is not allowed.`);
}
const value = args[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Claude Code launch argument "${flag}" requires a value.`);
}
if (flag === '--effort' && !['low', 'medium', 'high', 'xhigh', 'max'].includes(value)) {
throw new Error(`Claude Code effort "${value}" is not supported.`);
}
if (flag === '--max-turns') {
const turns = Number(value);
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_CONFIGURED_TURNS) {
throw new Error(
`Claude Code max turns must be an integer between 1 and ${MAX_CONFIGURED_TURNS}.`
);
}
}
if (Buffer.byteLength(value, 'utf8') > 500) {
throw new Error(`Claude Code launch argument "${flag}" exceeds the bounded value limit.`);
}
normalized.push(flag, value);
index += 1;
}
return normalized;
}
function readConfiguredMaxTurns(args: string[]): number | undefined {
const index = args.indexOf('--max-turns');
return index >= 0 ? Number(args[index + 1]) : undefined;
}
function removeFlagAndValue(args: string[], flag: string): string[] {
const filtered: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === flag) {
index += 1;
continue;
}
filtered.push(args[index]);
}
return filtered;
}
export function parseClaudeCodeStreamLine(line: string): Record<string, unknown> {
const trimmed = line.trim();
if (!trimmed) throw new Error('Claude Code stream record was empty.');
if (Buffer.byteLength(trimmed, 'utf8') > CLAUDE_CODE_MAX_STREAM_RECORD_BYTES) {
throw new Error('Claude Code stream record exceeded the 1 MiB safety limit.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
throw new Error('Claude Code stream record was not valid JSON.', { cause: error });
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Claude Code stream record must be a JSON object.');
}
const record = parsed as Record<string, unknown>;
if (typeof record.type !== 'string' || !record.type.trim()) {
throw new Error('Claude Code stream record is missing its type.');
}
return record;
}
export function classifyClaudeCodeStreamRecord(
record: Record<string, unknown>
): ClaudeCodeStreamClassification {
const type = boundedIdentifier(record.type) ?? 'unknown';
const subtype = boundedIdentifier(record.subtype);
const sessionId = boundedIdentifier(record.session_id);
const parentToolUseId = boundedIdentifier(record.parent_tool_use_id);
const content = messageContent(record);
const toolUse = content.find((entry) => stringValue(entry.type) === 'tool_use');
const toolResult = content.find((entry) => stringValue(entry.type) === 'tool_result');
const streamEvent = recordValue(record.event);
const delta = recordValue(streamEvent?.delta);
const deltaType = stringValue(delta?.type);
const streamType = stringValue(streamEvent?.type);
const textBlocks = content
.filter((entry) => stringValue(entry.type) === 'text')
.map((entry) => stringValue(entry.text))
.filter((value): value is string => Boolean(value));
const deltaText = stringValue(delta?.text) ?? stringValue(delta?.thinking);
const resultText = stringValue(record.result);
const providerType =
type === 'stream_event'
? [type, streamType, deltaType].filter(Boolean).join('.')
: type === 'assistant' && toolUse
? 'assistant.tool_use'
: type === 'assistant' && parentToolUseId
? 'assistant.subagent'
: type === 'user' && toolResult
? 'user.tool_result'
: [type, subtype].filter(Boolean).join('.');
const messageText = textBlocks.length > 0 ? textBlocks.join('\n') : undefined;
const summary = boundedSummary(
deltaText ?? messageText ?? resultText ?? systemSummary(record, subtype) ?? undefined
);
const usage = extractClaudeCodeUsage(record);
const terminal =
type === 'result' ? terminalResult(record, subtype ?? 'unknown', resultText) : undefined;
return {
providerType,
...(summary ? { summary } : {}),
...(sessionId ? { sessionId } : {}),
...(parentToolUseId ? { parentToolUseId } : {}),
...(toolUse && boundedIdentifier(toolUse.name)
? { tool: boundedIdentifier(toolUse.name) }
: {}),
files: extractClaudeCodeFiles(content),
...(usage ? { usage } : {}),
...(terminal ? { terminal } : {}),
};
}
function extractClaudeCodeUsage(record: Record<string, unknown>): ClaudeCodeUsage | undefined {
const usage = recordValue(record.usage);
if (!usage) return undefined;
const inputTokens = finiteNumber(usage.input_tokens) ?? finiteNumber(usage.inputTokens) ?? 0;
const outputTokens = finiteNumber(usage.output_tokens) ?? finiteNumber(usage.outputTokens) ?? 0;
const totalTokens =
finiteNumber(usage.total_tokens) ??
finiteNumber(usage.totalTokens) ??
inputTokens + outputTokens;
const cost = finiteNumber(record.total_cost_usd) ?? finiteNumber(record.totalCostUsd);
const model = stringValue(record.model);
return {
inputTokens,
outputTokens,
totalTokens,
...(cost !== undefined ? { cost } : {}),
...(model ? { model } : {}),
};
}
function terminalResult(
record: Record<string, unknown>,
subtype: string,
resultText: string | undefined
): ClaudeCodeTerminalResult {
const success = subtype === 'success' && record.is_error !== true;
const fallbackError =
stringValue(record.error) ??
stringValue(record.message) ??
(!success ? `Claude Code returned terminal result ${subtype}.` : undefined);
return {
success,
...(resultText ? { summary: boundedSummary(resultText) } : {}),
...(!success && fallbackError ? { error: boundedSummary(fallbackError) } : {}),
subtype,
};
}
function extractClaudeCodeFiles(content: Array<Record<string, unknown>>): string[] {
const files = new Set<string>();
for (const block of content) {
if (stringValue(block.type) !== 'tool_use') continue;
const input = recordValue(block.input);
for (const key of ['file_path', 'path', 'notebook_path']) {
const candidate = boundedIdentifier(input?.[key], 2_048);
if (candidate) files.add(candidate);
}
}
return [...files].slice(0, 20);
}
function messageContent(record: Record<string, unknown>): Array<Record<string, unknown>> {
const message = recordValue(record.message);
const content = message?.content;
if (!Array.isArray(content)) return [];
return content
.filter((entry): entry is Record<string, unknown> =>
Boolean(entry && typeof entry === 'object' && !Array.isArray(entry))
)
.slice(0, 200);
}
function systemSummary(
record: Record<string, unknown>,
subtype: string | undefined
): string | undefined {
if (!subtype) return undefined;
if (subtype === 'api_retry') {
const attempt = finiteNumber(record.attempt);
const maxRetries = finiteNumber(record.max_retries);
return `Claude Code API retry${attempt !== undefined ? ` ${attempt}` : ''}${
maxRetries !== undefined ? ` of ${maxRetries}` : ''
}.`;
}
const hook = stringValue(record.hook_name);
return hook ? `${subtype}: ${hook}` : subtype;
}
function boundedSummary(value: string | undefined): string | undefined {
const normalized = value?.trim();
if (!normalized) return undefined;
return normalized.length > MAX_SUMMARY_LENGTH
? `${normalized.slice(0, MAX_SUMMARY_LENGTH)}[truncated]`
: normalized;
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function boundedIdentifier(value: unknown, maxLength = 256): string | undefined {
const normalized = stringValue(value);
const containsControlCharacter = [...(normalized ?? '')].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
});
if (!normalized || normalized.length > maxLength || containsControlCharacter) {
return undefined;
}
return normalized;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isFinite(value) &&
value >= 0 &&
value <= Number.MAX_SAFE_INTEGER
? value
: undefined;
}

View file

@ -41,6 +41,7 @@ import {
import {
renderCodexCliTaskEnvelope,
renderCodexSdkTaskEnvelope,
renderClaudeCodeTaskEnvelope,
renderHermesTaskEnvelope,
renderOpenClawTaskEnvelope,
type ProviderTaskEnvelopeRenderInput,
@ -145,6 +146,17 @@ import {
type ProviderMappedRunEvent,
type ProviderRunEventMapper,
} from './provider-run-event-mappers.js';
import {
buildClaudeCodeArgs,
buildSafeClaudeCodeEnv,
CLAUDE_CODE_CREDENTIAL_ENV_KEYS,
CLAUDE_CODE_MAX_STREAM_RECORD_BYTES,
classifyClaudeCodeStreamRecord,
parseClaudeCodeStreamLine,
type ClaudeCodeStreamClassification,
type ClaudeCodeTerminalResult,
type ClaudeCodeUsage,
} from './claude-code-adapter.js';
const log = createLogger('clawdbot-agent-service');
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
@ -158,6 +170,7 @@ const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
],
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
];
const CLAUDE_CODE_MAX_STDERR_BUFFER_BYTES = 64 * 1024;
export interface AgentProviderStartContext {
task: Task;
@ -2401,6 +2414,7 @@ export class ClawdbotAgentService {
agentConfig.provider === 'openclaw' ||
agentConfig.provider === 'codex-sdk' ||
agentConfig.provider === 'codex-cli' ||
agentConfig.provider === 'claude-code' ||
agentConfig.provider === 'hermes-cli'
) {
provider = agentConfig.provider;
@ -2414,6 +2428,11 @@ export class ClawdbotAgentService {
}
);
}
} else if (
agent === 'claude-code' &&
path.basename(agentConfig?.command.trim().split(/\s+/)[0] ?? '') === 'claude'
) {
provider = 'claude-code';
} else if (
agent === 'codex' &&
path.basename(agentConfig?.command.trim().split(/\s+/)[0] ?? '') === 'codex'
@ -2623,6 +2642,55 @@ export class ClawdbotAgentService {
};
}
if (provider === 'claude-code') {
return {
id: definition.id,
label: definition.label,
renderTaskEnvelope: renderClaudeCodeTaskEnvelope,
probe,
runEventMapper: getProviderRunEventMapper(provider),
start: async ({
task,
agentConfig,
transport,
logPath,
attemptId,
startedAt,
emitter,
sandboxPolicy,
runLaunchManifest,
}) => {
this.assertProviderAdapterTransport(provider, transport, runLaunchManifest);
await this.startClaudeCode(
task,
agentConfig,
transport.content,
logPath,
attemptId,
startedAt,
emitter,
sandboxPolicy,
runLaunchManifest
);
},
stop: ({ pending }) => {
const child = pending.process;
if (!child || child.exitCode != null || child.signalCode != null) return;
child.kill('SIGTERM');
const forcedStop = setTimeout(() => {
if (child.exitCode == null && child.signalCode == null) {
child.kill('SIGKILL');
log.warn(
{ taskId: pending.taskId },
'[ClawdbotAgent] Claude Code SIGKILL issued after graceful stop timeout'
);
}
}, 5_000);
child.once('close', () => clearTimeout(forcedStop));
},
};
}
if (provider === 'hermes-cli') {
return {
id: definition.id,
@ -2791,7 +2859,7 @@ export class ClawdbotAgentService {
provider === 'codex-sdk' && context.health.providerVersion
? `codex-cli:${context.health.providerVersion}`
: undefined;
const diagnostics: string[] = [];
const diagnostics: string[] = [...(context.health.diagnostics ?? [])];
if (!providerVersion) {
diagnostics.push(
@ -2831,6 +2899,454 @@ export class ClawdbotAgentService {
};
}
private async startClaudeCode(
task: Task,
agentConfig: AgentConfig | undefined,
prompt: string,
logPath: string,
attemptId: string,
startedAt: string,
emitter: EventEmitter,
sandboxPolicy: SandboxPolicyDryRunResult | undefined,
runLaunchManifest: RunLaunchManifest
): Promise<void> {
const worktreePath = this.expandPath(task.git?.worktreePath || '');
if (!worktreePath) {
throw new Error('Task worktree path is required for Claude Code');
}
const runtimeSeconds = runLaunchManifest.budget.enabled
? runLaunchManifest.budget.limits?.runtimeSeconds
: undefined;
if (
runtimeSeconds !== undefined &&
runtimeSeconds > 0 &&
!Number.isSafeInteger(runtimeSeconds * 1_000)
) {
throw new Error('Claude Code runtime budget exceeds the supported timer range.');
}
const repositoryInstructions =
(await this.workspaceFiles.readOptionalText(worktreePath, 'AGENTS.md'))?.trim() ?? '';
const effectivePrompt = repositoryInstructions
? `${prompt}\n\n# Repository Instructions\n\n${repositoryInstructions}`
: prompt;
const command = agentConfig?.command || 'claude';
const args = buildClaudeCodeArgs({
prompt: effectivePrompt,
model: agentConfig?.model,
extraArgs: agentConfig?.args,
sandboxMode: sandboxPolicy?.effective.sandboxMode ?? 'workspace-write',
networkAccessEnabled: sandboxPolicy?.effective.networkAccessEnabled ?? true,
maxBudgetUsd: runLaunchManifest.budget.enabled
? runLaunchManifest.budget.limits?.costUsd
: undefined,
});
await this.appendLog(
logPath,
`\n## Claude Code\n\n**Command:** \`${[
command,
...args.map((argument) => (argument === effectivePrompt ? '<prompt>' : argument)),
].join(
' '
)}\`\n**Worktree:** \`${worktreePath}\`\n**Configuration:** bare mode with Veritas-owned static permissions\n\n`
);
await this.recordAgentStarted(
task,
attemptId,
agentConfig?.type || 'claude-code',
'claude-code',
agentConfig
);
const pending = pendingAgents.get(task.id);
if (!pending || pending.attemptId !== attemptId) {
throw new ConflictError('Claude Code launch was cancelled before process spawn.', {
taskId: task.id,
attemptId,
});
}
const child = spawn(command, args, {
cwd: worktreePath,
env: buildSafeClaudeCodeEnv(process.env, sandboxPolicy?.effective.envPassthrough),
shell: false,
});
pending.process = child;
let stdoutBuffer = '';
let stderrBuffer = '';
let finalSummary = '';
let terminalResult: ClaudeCodeTerminalResult | undefined;
let tokenUsage: ClaudeCodeUsage | undefined;
let recordedSessionId: string | undefined;
let eventProcessing = Promise.resolve();
let eventProcessingError: Error | undefined;
let runtimeTimedOut = false;
const enqueueEventProcessing = (work: () => Promise<void>) => {
eventProcessing = eventProcessing.then(async () => {
if (eventProcessingError) return;
try {
await work();
} catch (error) {
eventProcessingError =
error instanceof Error ? error : new Error('Provider event ingestion failed closed.');
child.kill('SIGTERM');
}
});
};
const processLine = async (line: string) => {
const classified = await this.handleClaudeCodeJsonLine(
line,
task,
attemptId,
agentConfig,
logPath
);
if (classified.summary) finalSummary = classified.summary;
if (classified.usage) tokenUsage = classified.usage;
if (classified.terminal) terminalResult = classified.terminal;
if (classified.sessionId && classified.sessionId !== recordedSessionId) {
recordedSessionId = classified.sessionId;
await this.recordClaudeCodeSession(task, attemptId, classified.sessionId);
}
};
child.stdout.setEncoding('utf-8');
child.stdout.on('data', (chunk: string) => {
enqueueEventProcessing(async () => {
await this.assertPendingManifestSnapshotForAttempt(task.id, attemptId);
await this.recordStreamChunk(task, attemptId, agentConfig, 'claude-code', 'stdout', chunk);
stdoutBuffer += chunk;
const lines = stdoutBuffer.split(/\r?\n/);
stdoutBuffer = lines.pop() || '';
if (Buffer.byteLength(stdoutBuffer, 'utf8') > CLAUDE_CODE_MAX_STREAM_RECORD_BYTES) {
throw new Error('Claude Code stream record exceeded the 1 MiB safety limit.');
}
for (const line of lines) {
if (line.trim()) await processLine(line);
}
});
});
child.stderr.setEncoding('utf-8');
child.stderr.on('data', (chunk: string) => {
const accumulated = Buffer.from(`${stderrBuffer}${chunk}`, 'utf8');
stderrBuffer =
accumulated.byteLength > CLAUDE_CODE_MAX_STDERR_BUFFER_BYTES
? accumulated
.subarray(accumulated.byteLength - CLAUDE_CODE_MAX_STDERR_BUFFER_BYTES)
.toString('utf8')
: accumulated.toString('utf8');
enqueueEventProcessing(async () => {
await this.assertPendingManifestSnapshotForAttempt(task.id, attemptId);
await this.recordStreamChunk(task, attemptId, agentConfig, 'claude-code', 'stderr', chunk);
await this.appendLog(
logPath,
`\n### stderr\n\n\`\`\`\n${this.redactTraceText(chunk.trimEnd())}\n\`\`\`\n`
);
});
});
child.on('error', (error) => {
enqueueEventProcessing(async () => {
const message = this.redactTraceText(error.message);
const journalEvent = await this.appendRunEvent(
task.id,
attemptId,
'run.error',
{ summary: message, error: message, phase: 'process' },
{
provider: 'claude-code',
adapter: 'claude-code',
agent: agentConfig?.type || 'claude-code',
model: agentConfig?.model,
}
);
this.emitJournalOutput(journalEvent);
await this.appendLog(logPath, `\n## Claude Code Process Error\n\n${message}\n`);
if (emitter.listenerCount('error') > 0) emitter.emit('error', error);
});
});
let runtimeTimer: NodeJS.Timeout | undefined;
let remainingRuntimeMs =
runtimeSeconds && runtimeSeconds > 0 ? runtimeSeconds * 1_000 : undefined;
const onRuntimeTimeout = () => {
runtimeTimedOut = true;
enqueueEventProcessing(async () => {
const message = `Claude Code runtime limit exceeded after ${runtimeSeconds} seconds.`;
const event = await this.appendRunEvent(
task.id,
attemptId,
'run.error',
{ summary: message, error: message, phase: 'timeout' },
{
provider: 'claude-code',
adapter: 'claude-code',
agent: agentConfig?.type || 'claude-code',
model: agentConfig?.model,
dedupeKey: 'claude-code.runtime-timeout',
}
);
this.emitJournalOutput(event);
});
child.kill('SIGTERM');
};
const scheduleRuntimeTimer = () => {
if (remainingRuntimeMs === undefined) return;
const delay = Math.min(remainingRuntimeMs, 2_147_483_647);
runtimeTimer = setTimeout(() => {
remainingRuntimeMs = Math.max(0, (remainingRuntimeMs ?? 0) - delay);
if (remainingRuntimeMs > 0) {
scheduleRuntimeTimer();
} else {
onRuntimeTimeout();
}
}, delay);
};
if (remainingRuntimeMs !== undefined) scheduleRuntimeTimer();
child.on('close', (code, signal) => {
if (runtimeTimer) clearTimeout(runtimeTimer);
if (!pending || pendingAgents.get(task.id) !== pending || pending.attemptId !== attemptId) {
return;
}
void this.finalizePendingAgent(task.id, pending, async () => {
await eventProcessing;
if (stdoutBuffer.trim() && !eventProcessingError) {
try {
await processLine(stdoutBuffer);
} catch (error) {
eventProcessingError =
error instanceof Error ? error : new Error('Claude Code final stream record failed.');
}
}
const signalError = signal ? `Claude Code terminated by signal ${signal}.` : undefined;
const timeoutError = runtimeTimedOut
? `Claude Code runtime limit exceeded after ${runtimeSeconds} seconds.`
: undefined;
const protocolError =
eventProcessingError?.message ??
(!terminalResult
? 'Claude Code stream ended without an authoritative result record.'
: undefined);
const succeeded =
code === 0 &&
!signal &&
!runtimeTimedOut &&
!eventProcessingError &&
terminalResult?.success === true;
const error =
timeoutError ??
protocolError ??
terminalResult?.error ??
signalError ??
(!succeeded ? `Claude Code exited with code ${code ?? 'unknown'}.` : undefined);
const summary =
terminalResult?.summary ||
finalSummary ||
error ||
(succeeded ? 'Claude Code completed.' : this.redactTraceText(stderrBuffer.trim()));
if (tokenUsage && !eventProcessingError) {
await this.assertRunControl(task.id, 'token-usage', attemptId);
await getTelemetryService().emit<TokenTelemetryEvent>({
type: 'run.tokens',
taskId: task.id,
attemptId,
agent: agentConfig?.type || 'claude-code',
project: task.project,
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
cost: tokenUsage.cost,
model: tokenUsage.model || agentConfig?.model,
});
await this.evaluatePendingBudget(
task.id,
attemptId,
{
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
costUsd: tokenUsage.cost,
},
'agent.tokens',
false
);
}
await this.appendLog(
logPath,
`\n## Claude Code Exit\n\n**Exit code:** ${code ?? 'none'}\n**Signal:** ${signal ?? 'none'}\n**Duration:** ${Date.now() - new Date(startedAt).getTime()}ms\n**Session:** ${recordedSessionId ?? 'not reported'}\n**Result:** ${terminalResult?.subtype ?? 'missing'}\n`
);
this.recordTraceStep(attemptId, succeeded ? 'finalize' : 'error', {
eventType: 'run.finalizing',
exitCode: code,
signal,
success: succeeded,
terminalSubtype: terminalResult?.subtype,
sessionId: recordedSessionId,
provider: 'claude-code',
agent: agentConfig?.type || 'claude-code',
model: agentConfig?.model,
});
return {
success: succeeded,
terminalSource: 'process',
summary,
error: succeeded ? undefined : error,
};
}).catch((error) => {
if (pendingAgents.get(task.id) !== pending) return;
log.error({ err: error, taskId: task.id }, 'Failed to finalize Claude Code attempt');
});
});
}
private async handleClaudeCodeJsonLine(
line: string,
task: Task,
attemptId: string,
agentConfig: AgentConfig | undefined,
logPath: string
): Promise<ClaudeCodeStreamClassification> {
const record = parseClaudeCodeStreamLine(line);
const rawClassification = classifyClaudeCodeStreamRecord(record);
const classified: ClaudeCodeStreamClassification = {
...rawClassification,
...(rawClassification.summary
? { summary: this.redactTraceText(rawClassification.summary) }
: {}),
...(rawClassification.terminal
? {
terminal: {
...rawClassification.terminal,
...(rawClassification.terminal.summary
? { summary: this.redactTraceText(rawClassification.terminal.summary) }
: {}),
...(rawClassification.terminal.error
? { error: this.redactTraceText(rawClassification.terminal.error) }
: {}),
},
}
: {}),
};
const agent = agentConfig?.type || 'claude-code';
const journalEvent = await this.appendMappedProviderEvent(
task,
attemptId,
agentConfig,
'claude-code',
this.resolveProviderAdapter('claude-code').runEventMapper.mapEvent(
classified.providerType,
record,
classified.summary
)
);
this.emitJournalOutput(journalEvent);
if (classified.usage) {
await this.appendRunEvent(
task.id,
attemptId,
'usage.updated',
{
inputTokens: classified.usage.inputTokens,
outputTokens: classified.usage.outputTokens,
totalTokens: classified.usage.totalTokens,
cost: classified.usage.cost,
model: classified.usage.model || agentConfig?.model,
},
{
provider: 'claude-code',
adapter: 'claude-code',
agent,
model: classified.usage.model || agentConfig?.model,
causalEventId: journalEvent.eventId,
dedupeKey: `${journalEvent.eventId}:usage`,
}
);
}
this.recordTraceStep(
attemptId,
classified.providerType.includes('text_delta')
? 'stream'
: classified.terminal?.success
? 'complete'
: classified.terminal
? 'error'
: classified.providerType.includes('api_retry')
? 'retry'
: 'execute',
{
provider: 'claude-code',
eventType: classified.providerType,
summary: classified.summary,
tool: classified.tool,
files: classified.files,
sessionId: classified.sessionId,
parentToolUseId: classified.parentToolUseId,
inputTokens: classified.usage?.inputTokens,
outputTokens: classified.usage?.outputTokens,
totalTokens: classified.usage?.totalTokens,
cost: classified.usage?.cost,
model: classified.usage?.model || agentConfig?.model,
}
);
if (classified.tool && classified.providerType === 'assistant.tool_use') {
await this.assertRunControl(task.id, 'tool-calls', attemptId);
await this.evaluatePendingBudget(task.id, attemptId, { toolCalls: 1 }, 'agent.tool', true);
}
if (classified.files.length > 0) {
await this.attachProviderDeliverables(
task,
attemptId,
agent,
'claude-code',
'Claude Code',
classified.files
);
}
if (
classified.tool ||
classified.terminal ||
classified.providerType.includes('hook_') ||
classified.providerType.includes('api_retry')
) {
await activityService.logActivity(
'agent_event',
task.id,
task.title,
{
attemptId,
provider: 'claude-code',
eventType: classified.providerType,
summary: classified.summary,
},
agent
);
}
await this.appendLog(
logPath,
`\n### ${classified.providerType}\n\n${
classified.summary ? `${this.redactTraceText(classified.summary)}\n\n` : ''
}<details><summary>Raw event</summary>\n\n\`\`\`json\n${this.redactTraceText(
JSON.stringify(journalEvent.payload.raw ?? {}, null, 2)
)}\n\`\`\`\n\n</details>\n`
);
return classified;
}
private async recordClaudeCodeSession(
task: Task,
attemptId: string,
sessionId: string
): Promise<void> {
const pending = pendingAgents.get(task.id);
if (pending && pending.attemptId === attemptId) pending.threadId = sessionId;
await this.taskService.patchTaskAttempt(task.id, attemptId, { threadId: sessionId });
}
private startHermesCli(
task: Task,
agentConfig: AgentConfig | undefined,
@ -3505,6 +4021,7 @@ export class ClawdbotAgentService {
payload,
providerEventId: options.providerEventId,
providerTimestamp: options.providerTimestamp,
sessionId: options.sessionId,
turnId: options.turnId,
itemId: options.itemId,
parentEventId: options.parentEventId,
@ -3613,9 +4130,11 @@ export class ClawdbotAgentService {
? 'codex-sdk:workspace-write:approval-never'
: provider === 'codex-cli'
? 'codex-cli:workspace-write'
: provider === 'hermes-cli'
? 'hermes-cli:workspace-write'
: 'openclaw:delegated',
: provider === 'claude-code'
? 'claude-code:static-permissions'
: provider === 'hermes-cli'
? 'hermes-cli:workspace-write'
: 'openclaw:delegated',
provider,
model: agentConfig?.model,
taskType: task.type,
@ -3717,7 +4236,14 @@ export class ClawdbotAgentService {
if (files.length > 0) {
await this.assertRunControl(task.id, 'artifacts', attemptId);
await this.attachCodexDeliverables(task, attemptId, agent, files);
await this.attachProviderDeliverables(
task,
attemptId,
agent,
agentConfig?.provider || 'codex-cli',
'Codex',
files
);
}
}
@ -3952,10 +4478,12 @@ export class ClawdbotAgentService {
return /^[\w.-]+\/[\w./-]+$/.test(trimmed) || /\.[a-z0-9]{1,12}$/i.test(trimmed);
}
private async attachCodexDeliverables(
private async attachProviderDeliverables(
task: Task,
attemptId: string,
agent: string,
provider: string,
providerLabel: string,
files: string[]
): Promise<void> {
await this.assertRunControl(task.id, 'artifacts', attemptId);
@ -3984,7 +4512,7 @@ export class ClawdbotAgentService {
sourceRunId: attemptId,
version: 1,
created,
description: `Codex event artifact from attempt ${attemptId}`,
description: `${providerLabel} event artifact from attempt ${attemptId}`,
});
}
@ -4000,7 +4528,7 @@ export class ClawdbotAgentService {
task.title,
{
attemptId,
provider: 'codex',
provider,
deliverableCount: additions.length,
paths: additions.map((deliverable) => deliverable.path),
},
@ -4406,7 +4934,8 @@ export class ClawdbotAgentService {
input.task.id,
input.logPath,
input.attemptId,
input.sandboxPolicy
input.sandboxPolicy,
input.budgetPolicy
);
const worktreePath = input.task.git?.worktreePath
? this.expandPath(input.task.git.worktreePath)
@ -5009,7 +5538,8 @@ export class ClawdbotAgentService {
taskId: string,
logPath: string,
attemptId: string,
sandboxPolicy: SandboxPolicyDryRunResult
sandboxPolicy: SandboxPolicyDryRunResult,
budgetPolicy?: AgentBudgetPolicy
): RunLaunchRuntime {
const environment = this.buildRunLaunchEnvironment(provider, sandboxPolicy);
const runtimeBase = {
@ -5045,6 +5575,20 @@ export class ClawdbotAgentService {
],
};
}
if (provider === 'claude-code') {
return {
...runtimeBase,
command: agentConfig?.command || 'claude',
args: buildClaudeCodeArgs({
prompt: '<prompt>',
model: agentConfig?.model,
extraArgs: agentConfig?.args,
sandboxMode: sandboxPolicy.effective.sandboxMode,
networkAccessEnabled: sandboxPolicy.effective.networkAccessEnabled,
maxBudgetUsd: budgetPolicy?.enabled ? budgetPolicy.limits?.costUsd : undefined,
}),
};
}
if (provider === 'hermes-cli') {
return {
...runtimeBase,
@ -5143,6 +5687,19 @@ export class ClawdbotAgentService {
],
};
}
if (provider === 'claude-code') {
const environmentKeys = Object.keys(
buildSafeClaudeCodeEnv(process.env, sandboxPolicy.effective.envPassthrough)
);
const credentialKeys = new Set<string>(CLAUDE_CODE_CREDENTIAL_ENV_KEYS);
return {
environmentKeys,
credentialReferences: [
...sandboxPolicy.effective.credentialRefs,
...environmentKeys.filter((key) => credentialKeys.has(key)).map((key) => `env:${key}`),
],
};
}
const gatewayUrlKey = this.firstConfiguredEnvironmentKey([
'OPENCLAW_GATEWAY_URL',

View file

@ -31,8 +31,9 @@ const DEFAULT_CONFIG: AppConfig = {
type: 'claude-code',
name: 'Claude Code',
command: 'claude',
args: ['--dangerously-skip-permissions'],
args: [],
enabled: false,
provider: 'claude-code',
},
{
type: 'amp',
@ -142,6 +143,16 @@ function mergeDefaultAgents(agents: AgentConfig[]): AgentConfig[] {
function migrateLegacyAgentProvider(agent: AgentConfig): AgentConfig {
if (agent.provider) return agent;
const command = path.basename(agent.command.trim().split(/\s+/)[0] ?? '');
if (agent.type === 'claude-code' && command === 'claude') {
return {
...agent,
provider: 'claude-code',
args:
agent.args.length === 1 && agent.args[0] === '--dangerously-skip-permissions'
? []
: agent.args,
};
}
if (agent.type === 'codex' && command === 'codex') {
return { ...agent, provider: 'codex-cli' };
}

View file

@ -9,6 +9,12 @@ import {
containsUnredactedProviderRuntimeSecret,
sanitizeProviderRuntimeDiagnostic,
} from '../utils/provider-runtime-manifest-sanitize.js';
import {
buildClaudeCodeArgs,
CLAUDE_CODE_CERTIFIED_VERSION,
CLAUDE_CODE_CREDENTIAL_ENV_KEYS,
CLAUDE_CODE_ENVIRONMENT_KEYS,
} from './claude-code-adapter.js';
const ALL_PLATFORMS: HarnessSupportProfile['platforms'] = ['darwin', 'linux', 'win32'];
const INVALIDATION_KEYS: HarnessSupportProfile['compatibility']['invalidateOn'] = [
@ -50,6 +56,7 @@ interface ProfileDefinition {
| { kind: 'none' };
environmentAllowlist?: string[];
credentialAllowlist?: string[];
testedVersions?: string[];
documentationUrl: string;
remediation: string[];
}
@ -65,12 +72,21 @@ interface RedactedCommand {
}
const DEFINITIONS: Record<string, ProfileDefinition> = {
'claude-code': unsupported(
'claude-code',
'Claude Code',
'process-jsonl',
'The Claude Code adapter is tracked by issue #916.'
),
'claude-code': {
id: 'claude-code',
displayName: 'Claude Code',
adapterId: 'claude-code',
transport: 'process-jsonl',
auth: { kind: 'command', commandArgs: ['auth', 'status'] },
environmentAllowlist: [...CLAUDE_CODE_ENVIRONMENT_KEYS],
credentialAllowlist: [...CLAUDE_CODE_CREDENTIAL_ENV_KEYS],
testedVersions: [CLAUDE_CODE_CERTIFIED_VERSION],
documentationUrl: '/docs/AGENT-PROVIDERS.md#claude-code-v21218',
remediation: [
'Install Claude Code v2.1.218 and run `claude auth status`.',
'Configure explicit bare-mode authentication such as ANTHROPIC_API_KEY, then run `vk doctor`.',
],
},
amp: unsupported('amp', 'Amp', 'process-text', 'No executable Amp adapter is registered.'),
copilot: unsupported(
'github-copilot-cli',
@ -156,6 +172,7 @@ const PROVIDER_DEFINITIONS: Record<string, ProfileDefinition> = {
),
'codex-cli': DEFINITIONS.codex,
'codex-sdk': DEFINITIONS['codex-sdk'],
'claude-code': DEFINITIONS['claude-code'],
'hermes-cli': DEFINITIONS.hermes,
};
@ -175,6 +192,22 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
const redactedLaunchArgs = redactLaunchArgs(agent.args);
const unsafeLaunchConfiguration =
redactedCommand.containsCredentialMaterial || redactedLaunchArgs.containsCredentialMaterial;
let providerLaunchError: string | undefined;
let normalizedProviderArgs = redactedLaunchArgs.args;
if (definition.adapterId === 'claude-code' && !unsafeLaunchConfiguration) {
try {
normalizedProviderArgs = buildClaudeCodeArgs({
prompt: '<prompt>',
model: agent.model,
extraArgs: agent.args,
sandboxMode: 'workspace-write',
networkAccessEnabled: true,
}).slice(0, -1);
} catch (error) {
providerLaunchError =
error instanceof Error ? error.message : 'Claude Code launch configuration is unsafe.';
}
}
const executable = {
command: redactedCommand.command,
versionArgs: ['--version'],
@ -184,7 +217,7 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
nonMutating: true as const,
};
const launch = {
args: redactedLaunchArgs.args,
args: normalizedProviderArgs,
workingDirectory: 'task-worktree' as const,
worktree: 'required' as const,
environmentAllowlist: [
@ -219,20 +252,20 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
transport: definition.transport,
supportTier: !executableProfile
? 'unsupported'
: unsafeLaunchConfiguration
: unsafeLaunchConfiguration || providerLaunchError
? 'degraded'
: 'configured',
supportReason: !executableProfile
? (definition.remediation[0] ?? 'No executable adapter is registered.')
: unsafeLaunchConfiguration
? unsafeConfigurationReason
: unsafeLaunchConfiguration || providerLaunchError
? (providerLaunchError ?? unsafeConfigurationReason)
: 'An explicit executable adapter is registered; live readiness requires a runtime probe.',
executable,
authentication,
compatibility: {
policy:
'When testedVersions is populated, require an exact provider-version match; always invalidate certification on runtime drift.',
testedVersions: [],
testedVersions: [...(definition.testedVersions ?? [])],
invalidateOn: [...INVALIDATION_KEYS],
configurationDigest,
},
@ -245,6 +278,9 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
documentationUrl: definition.documentationUrl,
remediation: [
...(unsafeLaunchConfiguration ? [unsafeConfigurationRemediation] : []),
...(providerLaunchError
? ['Remove ungoverned Claude Code launch flags and use Veritas policy fields instead.']
: []),
...definition.remediation,
],
};

View file

@ -84,6 +84,13 @@ export function evaluateHarnessSupportStatus(
...(providerVersion ? { providerVersion: sanitized(providerVersion) } : {}),
...(manifest?.providerBuild ? { providerBuild: sanitized(manifest.providerBuild) } : {}),
...(manifest?.digest ? { manifestDigest: manifest.digest } : {}),
...((manifest?.probe.diagnostics.length ?? 0) > 0 || (health.diagnostics?.length ?? 0) > 0
? {
diagnostics: [...(health.diagnostics ?? []), ...(manifest?.probe.diagnostics ?? [])]
.map(sanitized)
.slice(0, 32),
}
: {}),
diagnosticCommands: buildDiagnosticCommands(profile),
remediation: profile.remediation.map(sanitized),
};

View file

@ -6,6 +6,7 @@ export interface ProviderMappedRunEvent {
payload: Record<string, unknown>;
providerEventId?: string;
providerTimestamp?: string;
sessionId?: string;
turnId?: string;
itemId?: string;
parentEventId?: string;
@ -52,8 +53,10 @@ function nestedRecord(value: unknown): Record<string, unknown> | undefined {
function eventIdentity(event: Record<string, unknown>): {
providerEventId?: string;
sessionId?: string;
turnId?: string;
itemId?: string;
parentEventId?: string;
providerTimestamp?: string;
} {
const item = nestedRecord(event.item);
@ -61,7 +64,9 @@ function eventIdentity(event: Record<string, unknown>): {
boundedIdentifier(event.event_id) ??
boundedIdentifier(event.eventId) ??
boundedIdentifier(event.id) ??
boundedIdentifier(event.uuid) ??
boundedIdentifier(item?.id);
const sessionId = boundedIdentifier(event.session_id) ?? boundedIdentifier(event.sessionId);
const turnId =
boundedIdentifier(event.turn_id) ??
boundedIdentifier(event.turnId) ??
@ -78,7 +83,11 @@ function eventIdentity(event: Record<string, unknown>): {
timestamp && !Number.isNaN(Date.parse(timestamp))
? new Date(timestamp).toISOString()
: undefined;
return { providerEventId, turnId, itemId, providerTimestamp };
const parentEventId =
boundedIdentifier(event.parent_event_id) ??
boundedIdentifier(event.parentEventId) ??
boundedIdentifier(event.parent_tool_use_id);
return { providerEventId, sessionId, turnId, itemId, parentEventId, providerTimestamp };
}
function itemKind(type: string, event: Record<string, unknown>): RunEventKind {
@ -187,6 +196,47 @@ const HERMES_MAPPER: ProviderRunEventMapper = {
},
};
function claudeCodeKind(type: string): RunEventKind {
const normalized = type.toLowerCase();
if (normalized.includes('text_delta')) return 'message.delta';
if (normalized.includes('thinking_delta')) return 'reasoning.delta';
if (normalized === 'assistant.tool_use') return 'tool.started';
if (normalized === 'user.tool_result') return 'tool.completed';
if (normalized.includes('hook_started')) return 'tool.started';
if (
normalized.includes('hook_response') ||
normalized.includes('hook_completed') ||
normalized.includes('hook_progress')
) {
return 'tool.completed';
}
if (normalized === 'assistant.subagent' || normalized === 'assistant') {
return 'message.assistant';
}
if (normalized.includes('permission_denial')) return 'approval.resolved';
if (normalized.includes('api_retry') || normalized.startsWith('system.')) return 'progress';
if (normalized.startsWith('result.')) return 'progress';
return 'provider.unknown';
}
const CLAUDE_CODE_MAPPER: ProviderRunEventMapper = {
mapStream(stream, content) {
return {
kind: stream === 'stdout' ? 'stream.stdout' : 'stream.stderr',
payload: { stream, content },
};
},
mapEvent(providerType, event, summary) {
const identity = eventIdentity(event);
return {
...identity,
kind: claudeCodeKind(providerType),
dedupeKey: providerDedupeKey('claude-code', providerType, identity.providerEventId),
payload: { providerType, summary, raw: event },
};
},
};
const OPENCLAW_MAPPER: ProviderRunEventMapper = {
mapStream(stream, content) {
return {
@ -209,6 +259,7 @@ const MAPPERS: Record<ExecutableAgentProvider, ProviderRunEventMapper> = {
openclaw: OPENCLAW_MAPPER,
'codex-cli': codexMapper('codex-cli'),
'codex-sdk': codexMapper('codex-sdk'),
'claude-code': CLAUDE_CODE_MAPPER,
'hermes-cli': HERMES_MAPPER,
};

View file

@ -76,6 +76,60 @@ const DEFINITIONS: Record<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
'network.block-private': supported('Disabling network access blocks private network ranges.'),
'network.block-metadata': supported('Disabling network access blocks metadata endpoints.'),
}),
'claude-code': definition('claude-code', 'Claude Code', 'claude-code-stream-json/v1', {
...COMMON_SUPPORTED,
...NOT_YET_IMPLEMENTED,
'run.stop': supported(
'The adapter sends SIGTERM to the supervised Claude Code process with a bounded SIGKILL fallback.'
),
'run.streaming': supported(
'Claude Code stream-json output is drained and journaled through terminal result.'
),
'run.structured-events': supported(
'Claude Code emits contract-tested stream-json lifecycle records.'
),
'run.interrupt': advisory(
'SIGTERM performs cooperative process interruption; semantic steering is not yet exposed.'
),
'run.resume': advisory(
'Claude session IDs are persisted, but task-level resume remains gated by issue #856.'
),
'run.fork': unsupported(
'Claude session forking remains gated by provider-neutral lifecycle controls in issue #856.'
),
'tool.calls': supported(
'Claude assistant tool-use and user tool-result records are journaled and budgeted.'
),
'tool.mcp': unsupported(
'Bare mode blocks inherited MCP servers until the run-scoped MCP control plane in issue #857 is available.'
),
'output.structured': advisory(
'The adapter validates bounded JSONL stream records without enforcing a caller output schema.'
),
'usage.tokens': supported('Claude terminal usage and cost evidence is parsed and persisted.'),
'artifact.write': supported('Write and edit tool records create task deliverable evidence.'),
'workspace.worktrees': supported(
'Claude Code runs with the task worktree as its working directory.'
),
'filesystem.read': advisory(
'Claude permission rules restrict tools, while host filesystem enforcement remains provider-dependent.'
),
'filesystem.write': advisory(
'Claude permission rules restrict writes to the selected static policy; host enforcement remains provider-dependent.'
),
'filesystem.deny-paths': advisory(
'Sensitive path patterns are denied through Claude permission rules.'
),
'network.disable': advisory(
'Network tools and Bash are removed when network access is disabled; host egress enforcement remains separate.'
),
'environment.allowlist': supported(
'The adapter constructs an explicit process environment allowlist.'
),
'credential.broker': unsupported(
'Claude Code currently receives only explicitly allowlisted environment authentication; brokered handles remain gated by issue #932.'
),
}),
'hermes-cli': definition('hermes-cli', 'Hermes Agent', 'hermes-one-shot/v1', {
...COMMON_SUPPORTED,
...CLI_SANDBOX,

View file

@ -83,6 +83,28 @@ export function renderCodexSdkTaskEnvelope(
});
}
export function renderClaudeCodeTaskEnvelope(
input: ProviderTaskEnvelopeRenderInput
): ProviderTaskEnvelopeTransport {
assertEnvelopeTransport(input.taskEnvelope, 'claude-code');
return immutableTransport({
schemaVersion: PROVIDER_TASK_ENVELOPE_TRANSPORT_SCHEMA_VERSION,
provider: 'claude-code',
taskEnvelopeDigest: input.taskEnvelope.digest,
callbackPosture: 'harness-owned',
completionNormalization: 'harness',
content: renderEnvelopeContent(
'Claude Code',
input,
renderHarnessOwnedCompletion(
'Claude Code stream',
'Return the final response through the terminal result record captured by Veritas.',
'stream-json events'
)
),
});
}
export function renderHermesTaskEnvelope(
input: ProviderTaskEnvelopeRenderInput
): ProviderTaskEnvelopeTransport {

View file

@ -186,6 +186,7 @@ export class RunEventJournalService {
taskId: input.taskId,
runId: input.attemptId,
attemptId: input.attemptId,
sessionId: input.sessionId,
turnId: input.turnId,
itemId: input.itemId,
providerEventId: input.providerEventId,

View file

@ -36,6 +36,9 @@
"attemptId": {
"$ref": "#/$defs/identifier"
},
"sessionId": {
"$ref": "#/$defs/identifier"
},
"turnId": {
"$ref": "#/$defs/identifier"
},
@ -106,7 +109,15 @@
"required": ["provider", "adapter"],
"properties": {
"provider": {
"enum": ["openclaw", "codex-cli", "codex-sdk", "hermes-cli", "operator", "system"]
"enum": [
"openclaw",
"codex-cli",
"codex-sdk",
"claude-code",
"hermes-cli",
"operator",
"system"
]
},
"adapter": {
"$ref": "#/$defs/identifier"

View file

@ -44,6 +44,7 @@ export type AgentProvider =
| 'openclaw'
| 'codex-cli'
| 'codex-sdk'
| 'claude-code'
| 'codex-cloud'
| 'hermes-cli'
| 'ollama-local'
@ -55,6 +56,7 @@ export const EXECUTABLE_AGENT_PROVIDERS = [
'openclaw',
'codex-cli',
'codex-sdk',
'claude-code',
'hermes-cli',
] as const satisfies readonly AgentProvider[];

View file

@ -103,13 +103,14 @@ export interface HarnessSupportStatus {
providerVersion?: string;
providerBuild?: string;
manifestDigest?: string;
diagnostics?: string[];
diagnosticCommands: string[];
remediation: string[];
}
export const PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION = 'provider-runtime-manifest/v1' as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 3 as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 4 as const;
export const KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS = [
'run.start',

View file

@ -62,6 +62,7 @@ export interface RunEventEnvelope {
taskId: string;
runId: string;
attemptId: string;
sessionId?: string;
turnId?: string;
itemId?: string;
providerEventId?: string;
@ -81,6 +82,7 @@ export interface RunEventEnvelope {
export interface RunEventAppendInput {
taskId: string;
attemptId: string;
sessionId?: string;
turnId?: string;
itemId?: string;
providerEventId?: string;

View file

@ -94,6 +94,7 @@ const AGENT_PROVIDER_OPTIONS: Array<{ value: AgentProvider | '__none__'; label:
{ value: '__none__', label: 'None / legacy' },
{ value: 'codex-cli', label: 'Codex CLI' },
{ value: 'codex-sdk', label: 'Codex SDK' },
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'hermes-cli', label: 'Hermes Agent' },
{ value: 'codex-cloud', label: 'Codex Cloud' },
{ value: 'ollama-local', label: 'Ollama Local' },