Run an external agent harness — Claude Code, Codex CLI, OpenCode, Gemini CLI, Pi — as the reasoning substrate for a MemberJunction agent, while MJ keeps identity, permissions, governed data access, payload contracts, HITL, cost control and run-level audit.
Design principle: the harness is a substrate, not a peer. MJ owns the run record, the credentials, the tool surface and the approval flow. The harness owns the reasoning inside a turn.
Design plan: plans/external-agent-harness.md
BaseAgent already runs an iterate → decide → execute-steps → iterate loop where the "decide" input
is a prompt execution. This package substitutes a harness turn for that prompt call and changes
nothing else.
┌─ BaseAgent loop (unchanged) ──────────────────────────────────┐
│ │
│ executePrompt() ──► [ HarnessAgentBase override ] │
│ │ │ │
│ │ ├─ adapter.RunTurn(input) │
│ │ ├─ accumulate usage │
│ │ └─ write AIPromptRun │
│ ▼ │
│ DetermineNextStep (inherited from LoopAgentType) │
│ ▼ │
│ validate ─► execute actions / sub-agents / skills │
│ ▼ │
│ checkExecutionGuardrails ─► next turn │
└───────────────────────────────────────────────────────────────┘
The harness ends each turn by emitting the Loop next-step JSON envelope. MJ then executes any actions, sub-agents or skills through its own validated machinery and resumes the session with the results.
Why this matters: every guardrail, payload ACL, HITL gate and accounting path already written for Loop agents applies to harness agents with no new enforcement code — and there is exactly one authority channel to audit, not two.
ClassFactory registrations are namespaced per base class, so 'HarnessAgentType' is registered
twice against different roots:
| Registered under | Class | Resolved by | Gives you |
|---|---|---|---|
BaseAgentType |
HarnessAgentType |
BaseAgentType.GetAgentTypeInstance |
the turn protocol (inherits Loop) |
BaseAgent |
HarnessAgentBase |
AgentRunner (AgentRunner.ts:101) |
the execution driver |
Both read AIAgentType.DriverClass, so one metadata value selects both halves. This is the
mechanism working as designed — AgentRunner already treats the type's DriverClass as a
BaseAgent key and falls back to plain BaseAgent when unregistered, which is why every Loop agent
gets the base execution class today.
| Harness | DriverClass | Mechanism | Notes |
|---|---|---|---|
| Claude Code | ClaudeCodeCliAdapter |
CLI, stream-json |
Session resume; permission hook pending |
| Codex | CodexAdapter |
codex exec --json |
Session resume |
| OpenCode | OpenCodeAdapter |
opencode run JSON |
Session resume |
| Gemini CLI | GeminiCliAdapter |
gemini --output-format json |
No resume — context replayed |
| Pi | PiAdapter |
stdio-JSON contract | Requires ExecutablePath |
| anything | StdioJsonAdapter |
documented JSON contract | Escape hatch — zero MJ code |
Register your own with @RegisterClass(BaseHarnessAdapter, 'MyAdapter') and point a harness row's
DriverClass at it. No core changes required.
AIAgentHarness.CapabilitySettings (typed as IHarnessCapabilitySettings) declares what an adapter
actually implements, because the runtime emulates what is missing. Gemini CLI reports
SessionResume: false, so context is replayed each turn and those extra tokens are budgeted against
the run's guardrails rather than quietly absorbed.
Claiming a capability that is not wired up produces a silent behavioural gap, not an error. Report
false and let the runtime compensate.
Adapters never call spawn. They run everything through SandboxExecutor, obtained from the
handle the provider returns.
| Provider | Execution | Isolation |
|---|---|---|
LocalDirectorySandboxProvider |
direct spawn | None — dev only |
DockerSandboxProvider |
docker exec, container per run |
Real FS boundary; networkPolicy: 'none' enforced |
⚠️ The local provider scopes a directory; it does not contain the process.
networkPolicyis advisory there. Anyone who believes'none'is enforced locally has a false sense of containment, which is worse than knowing the boundary is soft.
HarnessProcess is deliberately stream-based, not ChildProcess-based — a Kubernetes exec is
streams over a websocket and a remote runner is HTTP, and neither could honestly implement a
ChildProcess contract. It also makes adapters unit-testable with a fake executor: no binary, no
container, no network.
WorkspacePath means "as the harness sees it"A host path under the local provider; a container-internal path under Docker. Pass it to harness
processes — do not open it with fs unless you know you are on the local provider.
Per-agent, in AIAgent.TypeConfiguration, validated against AIAgentType.ConfigSchema:
{
"harnessName": "Claude Code", // lookup into MJ: AI Agent Harnesses
"sandbox": {
"provider": "local", // local | docker
"image": "ghcr.io/memberjunction/harness-sandbox:latest",
"workspaceScope": "agent-user", // run | agent | agent-user
"networkPolicy": "mcp-only"
}
}
Workspace scope decides how long files live: run is discarded, agent is shared across every
run of that agent, agent-user (default) is per agent per user — continuity without one user's
working files leaking into another's session.
AIPromptRunRun totals are derived: calculateTokenStats sums AIAgentRunStep.PromptRun rollups. A turn
that records no prompt run contributes nothing, so the run reports zero tokens and zero cost
forever — and its cost ceiling has nothing to compare against.
AIPromptRun.PromptID, .ModelID and .VendorID are all NOT NULL, and each resolves to a
real catalog row rather than a placeholder:
| Column | Resolves to | Why it is not a fiction |
|---|---|---|
PromptID |
the agent type's system prompt | that template really did produce the turn |
VendorID |
AIAgentHarness.AIVendorID |
Claude Code really does call Anthropic |
ModelID |
AIAgentHarness.AIModelID |
the harness really does run that model |
If none resolves, the runtime fails loudly rather than skipping the row. A silent skip is exactly how a cost ceiling stops protecting anything.
MJ: AI Agent Credentials records the grant edge — which credentials an agent carries into its
sandbox. Custody stays in MJ: Credentials / CredentialEngine.
Environment injection is the only channel by which a secret reaches the harness, and it carries exactly what was granted — never the MJAPI process environment, never DB credentials, never a user token.
Distinct from MJ: AI Credential Bindings, which is inference-selection plumbing for
AIPromptRunner failover when MJ itself executes a prompt.
MJ records what crosses the boundary: MCP loopback reads and the turn-end step. Activity inside
the sandbox — file edits, shell commands — streams to onProgress for live view but is not
persisted as run steps.
This "opaque super-step" granularity is intentional. In-sandbox behaviour is governed by posture policy, not by run steps. Widening it is a design change, not a bug fix.
| Environment | Provider | Notes |
|---|---|---|
| Local dev | local |
Fast; uses the dev's own installed CLI and auth |
| Local parity | docker |
Same path as production |
| AWS / Azure | docker → ECS/Fargate or ACI |
Sandbox image versioned separately from MJAPI |
Do not bake harness binaries into the MJAPI image. A harness running inside the API container inherits that container's network reach and IAM role — the wrong blast radius for a process executing an autonomous agent's shell commands.
PermissionHooks: false on every adapter. The strict posture needs an MCP permission-prompt
tool that does not exist yet. Reported honestly so the runtime cannot assume interception it lacks.networkPolicy mcp-only / allowlist are not enforced at the packet level under Docker —
documented as such rather than aliased to open.HarnessSessionConfig carries the fields; the server and
per-run scoped credential are still to come.ModelID uses the declared model, not the model the harness reported for the turn. The
refinement belongs in resolveAccountingIds once adapters surface it.Business Source License 1.1 — see LICENSE for details.