mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add first-class Claude Code adapter (#951)
This commit is contained in:
parent
b0f4ea88a2
commit
3b07a5cb4f
34 changed files with 2197 additions and 92 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
59
server/src/__tests__/claude-code-provider.smoke.test.ts
Normal file
59
server/src/__tests__/claude-code-provider.smoke.test.ts
Normal 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');
|
||||
}
|
||||
);
|
||||
});
|
||||
246
server/src/__tests__/claude-code-provider.test.ts
Normal file
246
server/src/__tests__/claude-code-provider.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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":[]}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const AgentProfileRuntimeSchema = z
|
|||
'openclaw',
|
||||
'codex-cli',
|
||||
'codex-sdk',
|
||||
'claude-code',
|
||||
'hermes-cli',
|
||||
'codex-cloud',
|
||||
'ollama-local',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
494
server/src/services/claude-code-adapter.ts
Normal file
494
server/src/services/claude-code-adapter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue