@boldblackai/harness 1.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/harness.d.ts +2 -0
- package/bin/harness.js +516 -0
- package/bin/seccomp-profiles/block-af-alg.json +10 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julio Capote
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<p align="center" style="margin-bottom: 0">
|
|
2
|
+
<img alt="harness" src="logo-light.png#gh-light-mode-only" width="250">
|
|
3
|
+
<img alt="harness" src="logo-dark.png#gh-dark-mode-only" width="250">
|
|
4
|
+
</p>
|
|
5
|
+
|
|
6
|
+
<p align="center">
|
|
7
|
+
<strong>Run agents in a sandboxed container — ready to drop into any project.</strong>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
Harness conveniently wraps Docker around three open-source coding agents — [`pi`](https://pi.dev/), [`opencode`](https://opencode.ai),
|
|
11
|
+
and [`hermes`](https://github.com/NousResearch/hermes-agent) — so you can point one at a directory (or file) without giving it access to your entire machine.
|
|
12
|
+
|
|
13
|
+
> **Documentation:** [boldblackai.github.io/harness](https://boldblackai.github.io/harness/)
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Sandboxed by default** — capability-dropped container with `no-new-privileges`; the agent only sees the directory (or file) you mount.
|
|
18
|
+
- **Three agents, one CLI** — switch between `pi`, `opencode`, and `hermes` with `-a`. Same flags, same flow.
|
|
19
|
+
- **Supply-chain hardened** — the image is signed and verified with cosign and SLSA provenance on every run; dependencies installed inside the container are always pinned and verified where possible and a 7-day "cooldown" is used to mitigate supply-chain attacks.
|
|
20
|
+
- **Local-first** — defaults to LM Studio with `gemma-4-e4b`. Drop in an `--env-file` to use Anthropic, OpenRouter, OpenAI, Gemini, and others.
|
|
21
|
+
- **Stateful or one-shot** — interactive runs persist agent state under `$XDG_DATA_HOME/harness/<project>/<agent>/` (defaults to `~/.local/share/harness/`); one-shot prompts (`-p` or piped stdin) stay ephemeral.
|
|
22
|
+
- **User skills** — automatically mounts `~/.agents/skills` and `~/.claude/skills` into the container so agents can discover and use custom skills. Disable with `--no-skills`.
|
|
23
|
+
- **Context files** — automatically mounts `~/.agents/AGENTS.md` and `~/.claude/CLAUDE.md` into the agent's context directory so cross-agent rules apply inside the container. Disable with `--no-context-files` (`-nc`).
|
|
24
|
+
- **Zero install** — `npx @boldblackai/harness` just works.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
[Docker](https://www.docker.com) is required. By default, harness uses LM Studio locally:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
lms daemon up
|
|
32
|
+
lms get google/gemma-4-e4b
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The container is preconfigured to use `gemma-4-e4b` via LM Studio's local API.
|
|
36
|
+
|
|
37
|
+
You can also specify a different local model with `-m`. HuggingFace-style names with slashes (e.g. `qwen/qwen3.5-9b`) work correctly in local mode:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @boldblackai/harness -m "qwen/qwen3.5-9b" -p "write a fizzbuzz in Go"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Using a cloud provider instead
|
|
44
|
+
|
|
45
|
+
#### pi (default agent)
|
|
46
|
+
|
|
47
|
+
If you pass an API key for a supported provider via `--env-file`, [`pi`](https://pi.dev/) will use that provider instead of the local LM Studio setup. Supported keys:
|
|
48
|
+
|
|
49
|
+
| Provider | Environment Variable |
|
|
50
|
+
|----------|----------------------|
|
|
51
|
+
| Anthropic | `ANTHROPIC_API_KEY` |
|
|
52
|
+
| OpenRouter | `OPENROUTER_API_KEY` |
|
|
53
|
+
| OpenAI | `OPENAI_API_KEY` |
|
|
54
|
+
| Google Gemini | `GEMINI_API_KEY` |
|
|
55
|
+
| Mistral | `MISTRAL_API_KEY` |
|
|
56
|
+
| Groq | `GROQ_API_KEY` |
|
|
57
|
+
| Cerebras | `CEREBRAS_API_KEY` |
|
|
58
|
+
| xAI | `XAI_API_KEY` |
|
|
59
|
+
| Hugging Face | `HF_TOKEN` |
|
|
60
|
+
|
|
61
|
+
See the [full list of supported providers](https://github.com/badlogic/pi-mono/blob/c779c14e91bc2ea65143e59b0dc1baf3646ba8c9/packages/coding-agent/docs/providers.md#api-keys) for more options. When using LM Studio locally, 16k context is sufficient.
|
|
62
|
+
|
|
63
|
+
#### opencode agent
|
|
64
|
+
|
|
65
|
+
[`opencode`](https://opencode.ai) uses LM Studio by default. Pass `--env-file` to switch to cloud mode — the agent auto-detects the provider from whichever API key is in the file:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
echo 'OPENROUTER_API_KEY=sk-...' > .env
|
|
69
|
+
npx @boldblackai/harness -e .env -p "write me a fizzbuzz in Go"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
That's it. Your current directory is mounted at `/workspace` inside the container and the agent works against it.
|
|
73
|
+
|
|
74
|
+
## Contents
|
|
75
|
+
|
|
76
|
+
- [Examples](#examples)
|
|
77
|
+
- [Agents](#agents)
|
|
78
|
+
- [pi (default)](#pi-default)
|
|
79
|
+
- [opencode](#opencode)
|
|
80
|
+
- [hermes](#hermes)
|
|
81
|
+
- [Security model](#security-model)
|
|
82
|
+
- [Sandbox](#sandbox)
|
|
83
|
+
- [Image verification](#image-verification)
|
|
84
|
+
- [Dependency cooldown](#dependency-cooldown)
|
|
85
|
+
- [Persistence](#persistence)
|
|
86
|
+
- [Reference](#reference)
|
|
87
|
+
- [CLI flags](#cli-flags)
|
|
88
|
+
- [Environment variables](#environment-variables)
|
|
89
|
+
- [Agent-specific behavior](#agent-specific-behavior)
|
|
90
|
+
- [Deploying hermes as a claw](#deploying-hermes-as-a-claw)
|
|
91
|
+
- [Developing](#developing)
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# One-shot prompt
|
|
97
|
+
npx @boldblackai/harness -p "write me a fizzbuzz in Go"
|
|
98
|
+
|
|
99
|
+
# Pipe via stdin
|
|
100
|
+
echo "write me a fizzbuzz in Go" | npx @boldblackai/harness
|
|
101
|
+
|
|
102
|
+
# Interactive session (no -p, no piped stdin) — state persists under XDG data dir
|
|
103
|
+
npx @boldblackai/harness
|
|
104
|
+
|
|
105
|
+
# Use a cloud provider via env file
|
|
106
|
+
npx @boldblackai/harness -e .env -p "add a login endpoint"
|
|
107
|
+
|
|
108
|
+
# Override the model
|
|
109
|
+
npx @boldblackai/harness -m anthropic/claude-sonnet-4-5 -p "refactor the auth module"
|
|
110
|
+
|
|
111
|
+
# Mount a single file instead of the whole directory
|
|
112
|
+
npx @boldblackai/harness -f ./script.py -p "add type hints"
|
|
113
|
+
|
|
114
|
+
# Switch agents
|
|
115
|
+
npx @boldblackai/harness -a opencode -p "write me a fizzbuzz in Go"
|
|
116
|
+
npx @boldblackai/harness -a hermes -e .env -p "add tests"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`npx`, `bunx`, and `pnpm dlx` are interchangeable. Or install globally:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install -g @boldblackai/harness
|
|
123
|
+
# or
|
|
124
|
+
pnpm add -g @boldblackai/harness
|
|
125
|
+
# or
|
|
126
|
+
bun add -g @boldblackai/harness
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Agents
|
|
130
|
+
|
|
131
|
+
Pick an agent with `-a`. Default is `pi`.
|
|
132
|
+
|
|
133
|
+
### pi (default)
|
|
134
|
+
|
|
135
|
+
[`pi`](https://pi.dev/) defaults to LM Studio with `google/gemma-4-e4b` (16k context is enough). Pass an `--env-file` containing any of the keys below and `pi` switches to that provider:
|
|
136
|
+
|
|
137
|
+
| Provider | Environment Variable |
|
|
138
|
+
|---------------|----------------------|
|
|
139
|
+
| Anthropic | `ANTHROPIC_API_KEY` |
|
|
140
|
+
| OpenRouter | `OPENROUTER_API_KEY` |
|
|
141
|
+
| OpenAI | `OPENAI_API_KEY` |
|
|
142
|
+
| Google Gemini | `GEMINI_API_KEY` |
|
|
143
|
+
| Mistral | `MISTRAL_API_KEY` |
|
|
144
|
+
| Groq | `GROQ_API_KEY` |
|
|
145
|
+
| Cerebras | `CEREBRAS_API_KEY` |
|
|
146
|
+
| xAI | `XAI_API_KEY` |
|
|
147
|
+
| Hugging Face | `HF_TOKEN` |
|
|
148
|
+
|
|
149
|
+
See the [full provider list](https://github.com/badlogic/pi-mono/blob/c779c14e91bc2ea65143e59b0dc1baf3646ba8c9/packages/coding-agent/docs/providers.md#api-keys). The `-m` flag is forwarded directly.
|
|
150
|
+
|
|
151
|
+
### opencode
|
|
152
|
+
|
|
153
|
+
[`opencode`](https://opencode.ai) defaults to LM Studio in local mode. Pass `--env-file` to enter cloud mode — the agent auto-detects the provider from whichever API key is in the file (`ZAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.). The `-m` flag takes a bare model name; the provider prefix is added for you.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx @boldblackai/harness -a opencode -e .env -p "refactor the auth module"
|
|
157
|
+
npx @boldblackai/harness -a opencode -e .env -m anthropic/claude-sonnet-4-5 -p "add tests"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
To pass env vars but stay in local mode, use `--local`:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npx @boldblackai/harness -a opencode -e .env --local -p "refactor the auth module"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
When using LM Studio locally, set the model's context length to at least 32k tokens.
|
|
167
|
+
|
|
168
|
+
### hermes
|
|
169
|
+
|
|
170
|
+
[`hermes`](https://github.com/NousResearch/hermes-agent) by NousResearch supports many providers. Pass `--env-file` to enter cloud mode — the agent auto-detects the provider from whichever API key is in the file. Use a `provider/model` for `-m`:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npx @boldblackai/harness -a hermes -e .env -m anthropic/claude-sonnet-4-5 -p "add tests"
|
|
174
|
+
npx @boldblackai/harness -a hermes -e .env -m openrouter/auto -p "add tests"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Common keys: `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, [and others](https://github.com/NousResearch/hermes-agent/blob/main/.env.example). LM Studio context length should be at least 64k tokens.
|
|
178
|
+
|
|
179
|
+
## Security model
|
|
180
|
+
|
|
181
|
+
Harness layers protections at runtime, image, and dependency level.
|
|
182
|
+
|
|
183
|
+
### Sandbox
|
|
184
|
+
|
|
185
|
+
Each run starts the container with:
|
|
186
|
+
|
|
187
|
+
- `--cap-drop=ALL --cap-add=NET_RAW` — minimal capability set
|
|
188
|
+
- `--security-opt no-new-privileges:true` — block privilege escalation
|
|
189
|
+
- `--security-opt seccomp=...` — inline seccomp profile blocks `socket(AF_ALG)` to prevent kernel crypto API access (a known container escape vector)
|
|
190
|
+
- Only your mounted directory (or single file with `-f`) is visible to the agent
|
|
191
|
+
|
|
192
|
+
### Image verification
|
|
193
|
+
|
|
194
|
+
By default, harness verifies that the container image was signed by the official CI workflow and carries a valid SLSA provenance attestation. This requires [cosign](https://github.com/sigstore/cosign):
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
brew install cosign
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Verified digests are cached at `~/.cache/harness/cosign-verified.json` so verification only runs once per image. Skip with `--no-verify` (or by setting `HARNESS_IMAGE_TAG`, which implies skip):
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npx @boldblackai/harness --no-verify -p "write me a fizzbuzz in Go"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Dependency cooldown
|
|
207
|
+
|
|
208
|
+
The image build enforces a 7-day cooldown on dependency resolution — a guard against supply-chain compromises that are typically discovered and yanked within hours.
|
|
209
|
+
|
|
210
|
+
- **pnpm**: `PNPM_MINIMUM_RELEASE_AGE=10080` (minutes) via environment variable
|
|
211
|
+
- **uv**: `--exclude-newer` set to 7 days ago at image build time
|
|
212
|
+
|
|
213
|
+
The cooldown applies to transitive dependencies too. Older packages install normally.
|
|
214
|
+
|
|
215
|
+
## Persistence
|
|
216
|
+
|
|
217
|
+
Interactive runs (no `-p` and no piped stdin) store persistence data at `$XDG_DATA_HOME/harness/<project>/<agent>/` (defaults to `~/.local/share/harness/`). The `<project>` segment is the working directory path with `/` replaced by `_` and the home prefix stripped. This lets agents resume sessions, skip database migrations on repeat runs, and retain memories across invocations. The container's `~/.config` is persisted at `<persist-root>/../xdg_config` (one level above the per-agent root, i.e. `$XDG_DATA_HOME/harness/<project>/xdg_config`), so tool configs (e.g. jj) survive across runs and are shared by every agent working in the same project. For the opencode adapter the existing per-agent `.config/opencode` mount nests inside this project-level one. Per-agent `mise` tool data and trust settings are persisted at `<persist-root>/mise/` and `<persist-root>/mise-state/` respectively. For the pi adapter, extensions/skills installed via `npm install -g` are persisted at `<persist-root>/npm/`, avoiding re-downloads on every boot.
|
|
218
|
+
|
|
219
|
+
One-shot runs (`-p` or piped stdin) are implicitly ephemeral — no persistence data is created. Use `--ephemeral` to force-disable persistence on interactive runs.
|
|
220
|
+
|
|
221
|
+
If an old `.harness/` directory exists in your working directory, harness will emit a deprecation warning with migration instructions.
|
|
222
|
+
|
|
223
|
+
## Reference
|
|
224
|
+
|
|
225
|
+
### CLI flags
|
|
226
|
+
|
|
227
|
+
| Flag | Alias | Description |
|
|
228
|
+
|---------------|-------|-------------|
|
|
229
|
+
| `--prompt` | `-p` | Pass a prompt directly to the agent |
|
|
230
|
+
| `--env-file` | `-e` | Load environment variables into the container |
|
|
231
|
+
| `--file` | `-f` | Mount a single file instead of the current directory |
|
|
232
|
+
| `--model` | `-m` | Override the model used by the agent |
|
|
233
|
+
| `--agent` | `-a` | Select agent: `pi`, `opencode`, `hermes` (default: `pi`) |
|
|
234
|
+
| `--volumes` | `-v` | Additional volume mount (`host:container[:opts]`); may be repeated |
|
|
235
|
+
| `--no-verify` | | Skip cosign signature and provenance verification |
|
|
236
|
+
| `--no-skills` | | Disable mounting user skills directories (`~/.agents/skills`, `~/.claude/skills`) |
|
|
237
|
+
| `--no-context-files` | `-nc` | Disable mounting global context files (`~/.agents/AGENTS.md`, `~/.claude/CLAUDE.md`) |
|
|
238
|
+
| `--ephemeral` | | Disable session persistence (implied by `-p` and piped stdin) |
|
|
239
|
+
| `--local` | | Force local mode even with `-e` (use LM Studio / local defaults) |
|
|
240
|
+
| `--help` | `-h` | Show help |
|
|
241
|
+
|
|
242
|
+
### Environment variables
|
|
243
|
+
|
|
244
|
+
| Variable | Description |
|
|
245
|
+
|----------------------|-------------|
|
|
246
|
+
| `HARNESS_IMAGE_TAG` | Override the Docker image tag (defaults to the package version). Setting this implies `--no-verify`. |
|
|
247
|
+
|
|
248
|
+
### Agent-specific behavior
|
|
249
|
+
|
|
250
|
+
- **pi** — `-m` is passed straight to the binary as `--model`.
|
|
251
|
+
- **opencode** — `-m` is passed via the `OPENCODE_MODEL` env var. Without `-e`, uses LM Studio locally. With `-e`, enters cloud mode and auto-detects the provider from whichever API key is in the env file. Use `--local` to force local mode even with `-e`.
|
|
252
|
+
- **hermes** — `-m` is passed as `--model` in `provider/model` form. Without `-e`, uses local config. With `-e`, enters cloud mode and auto-detects from env vars. Use `--local` to force local mode even with `-e`.
|
|
253
|
+
|
|
254
|
+
## Deploying hermes as a claw
|
|
255
|
+
|
|
256
|
+
You can run `hermes` as a long-running "claw" — a persistent agent process reachable over a messaging gateway (e.g. Telegram). Three deployment targets are documented:
|
|
257
|
+
|
|
258
|
+
- [fly.io](docs/deploying-to-fly.md)
|
|
259
|
+
- [Kubernetes](docs/deploying-to-k8s.md)
|
|
260
|
+
- [AWS (ECS Fargate or EC2 + SSM)](docs/deploying-to-aws.md)
|
|
261
|
+
|
|
262
|
+
## Developing
|
|
263
|
+
|
|
264
|
+
Link your local checkout globally:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pnpm link --global
|
|
268
|
+
# unlink with:
|
|
269
|
+
pnpm unlink --global @boldblackai/harness
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Building the image
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
make image
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Builds `ghcr.io/boldblackai/harness` with Debian stable-slim, Node.js v24, `git`, [`@earendil-works/pi-coding-agent`](https://pi.dev/), [`opencode-ai`](https://opencode.ai), [`hermes-agent`](https://github.com/NousResearch/hermes-agent), `fd`, `ripgrep`, `jq`, and `curl`. The hermes variant also includes the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) for connecting to MCP servers, and [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper) for local speech-to-text.
|
|
279
|
+
|
|
280
|
+
The base image is pinned by manifest-list digest (the OCI image index, not a per-platform manifest) for reproducible multi-arch builds. To bump it:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
docker buildx imagetools inspect debian:stable-slim --format '{{.Manifest.Digest}}'
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Linting
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
pnpm lint # all
|
|
290
|
+
pnpm lint:ts # Biome
|
|
291
|
+
pnpm lint:md # markdownlint
|
|
292
|
+
pnpm lint:sh # shellcheck
|
|
293
|
+
pnpm lint:docker # hadolint
|
|
294
|
+
pnpm lint:actions # actionlint
|
|
295
|
+
pnpm format # auto-format with Biome
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`shellcheck`, `hadolint`, and `actionlint` are system binaries. Install with [mise](https://mise.jdx.dev/) (recommended):
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
mise install
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Or install manually:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
brew install shellcheck hadolint actionlint
|
|
308
|
+
```
|
package/bin/harness.d.ts
ADDED
package/bin/harness.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const minimist_1 = __importDefault(require("minimist"));
|
|
12
|
+
const SECCOMP_PROFILE = node_path_1.default.join(__dirname, "seccomp-profiles", "block-af-alg.json");
|
|
13
|
+
class PiAdapter {
|
|
14
|
+
buildCommand({ prompt, model, envFilePath }) {
|
|
15
|
+
// In local mode (no env file), pass --provider ollama so pi routes
|
|
16
|
+
// the model to the local LM Studio provider. Without this, model names
|
|
17
|
+
// containing slashes (e.g. HuggingFace IDs like "qwen/qwen3.5-9b") are
|
|
18
|
+
// misinterpreted as provider/model format, causing pi to silently ignore
|
|
19
|
+
// --model and fall back to a default that may require cloud credentials.
|
|
20
|
+
const providerArgs = !envFilePath && model ? ["--provider", "ollama"] : [];
|
|
21
|
+
const modelArgs = model ? ["--model", model] : [];
|
|
22
|
+
if (prompt !== null) {
|
|
23
|
+
return ["pi", "-p", prompt, ...providerArgs, ...modelArgs];
|
|
24
|
+
}
|
|
25
|
+
return ["pi", ...providerArgs, ...modelArgs];
|
|
26
|
+
}
|
|
27
|
+
persistMounts() {
|
|
28
|
+
return [
|
|
29
|
+
{ hostSubpath: "", containerPath: "/home/harness/.pi/agent" },
|
|
30
|
+
{ hostSubpath: "npm", containerPath: "/home/harness/.local/share/npm" },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
contextDir() {
|
|
34
|
+
return "/home/harness/.pi/agent";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
class OpenCodeAdapter {
|
|
38
|
+
buildCommand({ prompt }) {
|
|
39
|
+
if (prompt !== null) {
|
|
40
|
+
return ["opencode", "run", prompt];
|
|
41
|
+
}
|
|
42
|
+
return ["opencode"];
|
|
43
|
+
}
|
|
44
|
+
extraDockerArgs({ model }) {
|
|
45
|
+
return model ? ["-e", `OPENCODE_MODEL=${model}`] : [];
|
|
46
|
+
}
|
|
47
|
+
persistMounts() {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
hostSubpath: "config",
|
|
51
|
+
containerPath: "/home/harness/.config/opencode",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
hostSubpath: "share",
|
|
55
|
+
containerPath: "/home/harness/.local/share/opencode",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
hostSubpath: "state",
|
|
59
|
+
containerPath: "/home/harness/.local/state/opencode",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
contextDir() {
|
|
64
|
+
return "/home/harness/.config/opencode";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
class HermesAdapter {
|
|
68
|
+
buildCommand({ prompt, model }) {
|
|
69
|
+
const args = ["hermes", "chat"];
|
|
70
|
+
if (model)
|
|
71
|
+
args.push("-m", model);
|
|
72
|
+
if (prompt !== null)
|
|
73
|
+
args.push("-q", prompt);
|
|
74
|
+
return args;
|
|
75
|
+
}
|
|
76
|
+
persistMounts() {
|
|
77
|
+
return [{ hostSubpath: "", containerPath: "/home/harness/.hermes" }];
|
|
78
|
+
}
|
|
79
|
+
contextDir() {
|
|
80
|
+
return "/home/harness/.hermes";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const IDENTITY_REGEXP = "https://github.com/boldblackai/harness/.github/workflows/docker.yml@refs/tags/";
|
|
84
|
+
const OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
85
|
+
const CACHE_VERSION = 1;
|
|
86
|
+
function cachePath() {
|
|
87
|
+
const base = process.env.XDG_CACHE_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".cache");
|
|
88
|
+
return node_path_1.default.join(base, "harness", "cosign-verified.json");
|
|
89
|
+
}
|
|
90
|
+
function inspectLocalImage(image) {
|
|
91
|
+
try {
|
|
92
|
+
const out = (0, node_child_process_1.execFileSync)("docker", [
|
|
93
|
+
"image",
|
|
94
|
+
"inspect",
|
|
95
|
+
"--format",
|
|
96
|
+
"{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}",
|
|
97
|
+
image,
|
|
98
|
+
], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
99
|
+
.toString()
|
|
100
|
+
.trim();
|
|
101
|
+
return {
|
|
102
|
+
exists: true,
|
|
103
|
+
digest: /@sha256:[0-9a-f]{64}$/.test(out) ? out : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { exists: false, digest: null };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function readCache() {
|
|
111
|
+
try {
|
|
112
|
+
const raw = node_fs_1.default.readFileSync(cachePath(), "utf8");
|
|
113
|
+
const parsed = JSON.parse(raw);
|
|
114
|
+
if (parsed &&
|
|
115
|
+
parsed.version === CACHE_VERSION &&
|
|
116
|
+
parsed.verified &&
|
|
117
|
+
typeof parsed.verified === "object") {
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
return { version: CACHE_VERSION, verified: {} };
|
|
123
|
+
}
|
|
124
|
+
function writeCacheAtomic(cache) {
|
|
125
|
+
try {
|
|
126
|
+
const file = cachePath();
|
|
127
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true, mode: 0o700 });
|
|
128
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
129
|
+
node_fs_1.default.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
|
|
130
|
+
node_fs_1.default.renameSync(tmp, file);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// best-effort; never break verify path
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function cosign(args) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
(0, node_child_process_1.execFile)("cosign", args, { timeout: 30000 }, (err, _stdout, stderr) => {
|
|
139
|
+
if (err) {
|
|
140
|
+
const e = err;
|
|
141
|
+
e.stderr = stderr;
|
|
142
|
+
reject(e);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
resolve();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async function verifyImage(image) {
|
|
151
|
+
let { exists, digest: digestRef } = inspectLocalImage(image);
|
|
152
|
+
const cache = readCache();
|
|
153
|
+
if (digestRef && cache.verified[digestRef]) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (exists && !digestRef) {
|
|
157
|
+
console.error(`harness: refusing to verify ${image}: image exists locally but has no registry digest (locally-built?).`);
|
|
158
|
+
console.error("harness: verifying the tag would check registry bytes, not the local image docker would run.");
|
|
159
|
+
console.error("harness: use --no-verify, or set HARNESS_IMAGE_TAG for an implicit skip.");
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
if (!digestRef) {
|
|
163
|
+
console.error(`harness: pulling ${image} for verification...`);
|
|
164
|
+
try {
|
|
165
|
+
(0, node_child_process_1.execFileSync)("docker", ["pull", image], {
|
|
166
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
167
|
+
timeout: 600000,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
console.error(`harness: docker pull failed for ${image}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
digestRef = inspectLocalImage(image).digest;
|
|
175
|
+
if (!digestRef) {
|
|
176
|
+
console.error(`harness: failed to resolve digest for ${image} after pull`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
if (cache.verified[digestRef]) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const identityArgs = [
|
|
184
|
+
"--certificate-identity-regexp",
|
|
185
|
+
IDENTITY_REGEXP,
|
|
186
|
+
"--certificate-oidc-issuer",
|
|
187
|
+
OIDC_ISSUER,
|
|
188
|
+
];
|
|
189
|
+
const verifyP = cosign(["verify", ...identityArgs, digestRef]);
|
|
190
|
+
const attestP = cosign([
|
|
191
|
+
"verify-attestation",
|
|
192
|
+
"--type",
|
|
193
|
+
"slsaprovenance",
|
|
194
|
+
...identityArgs,
|
|
195
|
+
digestRef,
|
|
196
|
+
]);
|
|
197
|
+
const [verifyResult, attestResult] = await Promise.allSettled([
|
|
198
|
+
verifyP,
|
|
199
|
+
attestP,
|
|
200
|
+
]);
|
|
201
|
+
if (verifyResult.status === "rejected") {
|
|
202
|
+
const e = verifyResult.reason;
|
|
203
|
+
if (e.code === "ENOENT") {
|
|
204
|
+
console.error("harness: cosign not found — cannot verify image without it.");
|
|
205
|
+
console.error("harness: install cosign (brew install cosign) or pass --no-verify to skip verification.");
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
console.error(`harness: image signature verification failed for ${digestRef}`);
|
|
209
|
+
console.error(e.stderr?.trim() || e.message);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
if (attestResult.status === "rejected") {
|
|
213
|
+
console.error(`harness: WARNING: no provenance attestation found for ${digestRef}`);
|
|
214
|
+
}
|
|
215
|
+
cache.verified[digestRef] = {
|
|
216
|
+
tag: image,
|
|
217
|
+
verifiedAt: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
writeCacheAtomic(cache);
|
|
220
|
+
}
|
|
221
|
+
const AGENT_NAMES = ["pi", "opencode", "hermes"];
|
|
222
|
+
const ADAPTERS = {
|
|
223
|
+
pi: new PiAdapter(),
|
|
224
|
+
opencode: new OpenCodeAdapter(),
|
|
225
|
+
hermes: new HermesAdapter(),
|
|
226
|
+
};
|
|
227
|
+
function isAgentName(name) {
|
|
228
|
+
return AGENT_NAMES.includes(name);
|
|
229
|
+
}
|
|
230
|
+
const USAGE = `Usage: harness [options]
|
|
231
|
+
|
|
232
|
+
Options:
|
|
233
|
+
-p, --prompt <text> Pass a prompt directly to the coding agent
|
|
234
|
+
-e, --env-file <file> Load environment variables from a file into the container
|
|
235
|
+
-f, --file <file> Mount a single file into the container instead of the current directory
|
|
236
|
+
-m, --model <model> Override the model used by the agent
|
|
237
|
+
-a, --agent <name> Select the coding agent adapter: pi, opencode, hermes (default: pi)
|
|
238
|
+
-v, --volumes <spec> Additional volume mount (host:container[:opts]); may be repeated
|
|
239
|
+
--no-verify Skip cosign image signature and provenance verification
|
|
240
|
+
--no-skills Disable mounting user skills directories (~/.agents/skills, ~/.claude/skills)
|
|
241
|
+
--no-context-files Disable mounting global context files (~/.agents/AGENTS.md, ~/.claude/CLAUDE.md); alias -nc
|
|
242
|
+
--ephemeral Disable session persistence (implied by -p and piped stdin)
|
|
243
|
+
--local Force local mode even with -e (use LM Studio / local defaults)
|
|
244
|
+
-h, --help Show this help message
|
|
245
|
+
|
|
246
|
+
Environment variables:
|
|
247
|
+
HARNESS_IMAGE_TAG Override the Docker image tag (defaults to package version)
|
|
248
|
+
XDG_DATA_HOME Override the base directory for persistence data (defaults to ~/.local/share)
|
|
249
|
+
XDG_CACHE_HOME Override the base directory for cosign cache (defaults to ~/.cache)
|
|
250
|
+
|
|
251
|
+
Persistence data is stored at $XDG_DATA_HOME/harness/<project>/<agent>/.
|
|
252
|
+
|
|
253
|
+
You can also pipe text to harness as an implied -p:
|
|
254
|
+
echo "write me a fizzbuzz in Go" | harness
|
|
255
|
+
`;
|
|
256
|
+
const workspace = process.cwd();
|
|
257
|
+
const REGISTRY = "ghcr.io/boldblackai/harness";
|
|
258
|
+
const VERSION = require("../package.json").version;
|
|
259
|
+
const IMAGE_TAG = process.env.HARNESS_IMAGE_TAG ?? VERSION;
|
|
260
|
+
function normalizeCwd(cwd) {
|
|
261
|
+
const home = node_os_1.default.homedir();
|
|
262
|
+
let normalized = cwd;
|
|
263
|
+
if (normalized.startsWith(home)) {
|
|
264
|
+
normalized = normalized.slice(home.length);
|
|
265
|
+
}
|
|
266
|
+
normalized = normalized.replace(/\//g, "_");
|
|
267
|
+
if (normalized === "") {
|
|
268
|
+
normalized = "_home";
|
|
269
|
+
}
|
|
270
|
+
return normalized;
|
|
271
|
+
}
|
|
272
|
+
function xdgDataDir() {
|
|
273
|
+
return (process.env.XDG_DATA_HOME || node_path_1.default.join(node_os_1.default.homedir(), ".local", "share"));
|
|
274
|
+
}
|
|
275
|
+
function getImage(agent) {
|
|
276
|
+
const tag = agent === "pi" ? IMAGE_TAG : `${agent}-${IMAGE_TAG}`;
|
|
277
|
+
return `${REGISTRY}:${tag}`;
|
|
278
|
+
}
|
|
279
|
+
const MINIMIST_OPTS = {
|
|
280
|
+
boolean: ["help", "h", "ephemeral", "local"],
|
|
281
|
+
string: [
|
|
282
|
+
"env-file",
|
|
283
|
+
"e",
|
|
284
|
+
"file",
|
|
285
|
+
"f",
|
|
286
|
+
"prompt",
|
|
287
|
+
"p",
|
|
288
|
+
"model",
|
|
289
|
+
"m",
|
|
290
|
+
"agent",
|
|
291
|
+
"a",
|
|
292
|
+
"volumes",
|
|
293
|
+
],
|
|
294
|
+
alias: {
|
|
295
|
+
e: "env-file",
|
|
296
|
+
f: "file",
|
|
297
|
+
p: "prompt",
|
|
298
|
+
m: "model",
|
|
299
|
+
h: "help",
|
|
300
|
+
a: "agent",
|
|
301
|
+
v: "volumes",
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
// Translate pi's short context-files flag (-nc) to its long form before
|
|
305
|
+
// minimist, which would otherwise split -nc into clustered -n -c flags.
|
|
306
|
+
const rawArgs = process.argv
|
|
307
|
+
.slice(2)
|
|
308
|
+
.map((a) => (a === "-nc" ? "--no-context-files" : a));
|
|
309
|
+
const argv = (0, minimist_1.default)(rawArgs, MINIMIST_OPTS);
|
|
310
|
+
// Warn on unrecognized flags
|
|
311
|
+
{
|
|
312
|
+
const knownKeys = new Set([
|
|
313
|
+
"_", // minimist internal positional args
|
|
314
|
+
...MINIMIST_OPTS.boolean,
|
|
315
|
+
...MINIMIST_OPTS.string,
|
|
316
|
+
...Object.keys(MINIMIST_OPTS.alias),
|
|
317
|
+
...Object.values(MINIMIST_OPTS.alias),
|
|
318
|
+
"verify", // --no-verify sets verify=false
|
|
319
|
+
"skills", // --no-skills sets skills=false
|
|
320
|
+
"context-files", // --no-context-files / -nc sets context-files=false
|
|
321
|
+
"local",
|
|
322
|
+
]);
|
|
323
|
+
const unknown = Object.keys(argv).filter((k) => !knownKeys.has(k));
|
|
324
|
+
if (unknown.length > 0) {
|
|
325
|
+
process.stderr.write(`warning: unrecognized flag(s): ${unknown.map((k) => `--${k}`).join(", ")}\n`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (argv.help) {
|
|
329
|
+
process.stdout.write(USAGE);
|
|
330
|
+
process.exit(0);
|
|
331
|
+
}
|
|
332
|
+
const noVerify = argv.verify === false;
|
|
333
|
+
const noSkills = argv.skills === false;
|
|
334
|
+
const noContextFiles = argv["context-files"] === false;
|
|
335
|
+
const localMode = argv.local;
|
|
336
|
+
const envFilePath = argv["env-file"] || null;
|
|
337
|
+
const fileArg = argv.file || null;
|
|
338
|
+
const promptArg = argv.prompt || null;
|
|
339
|
+
const modelArg = argv.model || null;
|
|
340
|
+
const effectiveEphemeral = argv.ephemeral || promptArg !== null || !process.stdin.isTTY;
|
|
341
|
+
const agentName = (() => {
|
|
342
|
+
const name = argv.agent ?? "pi";
|
|
343
|
+
if (!isAgentName(name)) {
|
|
344
|
+
console.error(`harness: unknown agent: "${name}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
return name;
|
|
348
|
+
})();
|
|
349
|
+
if (envFilePath && !node_fs_1.default.existsSync(envFilePath)) {
|
|
350
|
+
console.error(`harness: env file not found: ${envFilePath}`);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
if (fileArg && !node_fs_1.default.existsSync(fileArg)) {
|
|
354
|
+
console.error(`harness: file not found: ${fileArg}`);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
if (fileArg && node_fs_1.default.statSync(fileArg).isDirectory()) {
|
|
358
|
+
console.error(`harness: --file requires a file, not a directory: ${fileArg}`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
const volumeArgList = Array.isArray(argv.volumes)
|
|
362
|
+
? argv.volumes
|
|
363
|
+
: argv.volumes
|
|
364
|
+
? [argv.volumes]
|
|
365
|
+
: [];
|
|
366
|
+
for (const spec of volumeArgList) {
|
|
367
|
+
const parts = spec.split(":");
|
|
368
|
+
if (parts.length < 2) {
|
|
369
|
+
console.error(`harness: invalid volume spec "${spec}" (expected host:container[:opts])`);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
if (parts[0] && !node_fs_1.default.existsSync(parts[0])) {
|
|
373
|
+
console.error(`harness: volume source path does not exist: ${parts[0]}`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function run(prompt) {
|
|
378
|
+
const image = getImage(agentName);
|
|
379
|
+
if (!noVerify) {
|
|
380
|
+
if (process.env.HARNESS_IMAGE_TAG) {
|
|
381
|
+
console.error(`harness: HARNESS_IMAGE_TAG is set; skipping cosign verification for ${image}`);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
await verifyImage(image);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const envFileArgs = envFilePath
|
|
388
|
+
? ["--env-file", node_path_1.default.resolve(envFilePath)]
|
|
389
|
+
: [];
|
|
390
|
+
// Cloud mode: -e without --local signals entrypoints to skip local/defaults
|
|
391
|
+
// and let agents auto-detect providers from env vars in the file.
|
|
392
|
+
const cloudModeEnv = envFilePath && !localMode ? ["-e", "HARNESS_CLOUD_MODE=1"] : [];
|
|
393
|
+
const adapter = ADAPTERS[agentName];
|
|
394
|
+
const adapterOptions = { prompt, model: modelArg, envFilePath };
|
|
395
|
+
const containerCmd = adapter.buildCommand(adapterOptions);
|
|
396
|
+
const adapterDockerArgs = adapter.extraDockerArgs?.(adapterOptions) ?? [];
|
|
397
|
+
const interactive = process.stdin.isTTY;
|
|
398
|
+
const ttyFlags = interactive ? ["-it"] : ["-i"];
|
|
399
|
+
let volumeArgs;
|
|
400
|
+
if (fileArg) {
|
|
401
|
+
const absFile = node_path_1.default.resolve(fileArg);
|
|
402
|
+
const fileName = node_path_1.default.basename(absFile);
|
|
403
|
+
volumeArgs = ["-v", `${absFile}:/workspace/${fileName}`];
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
volumeArgs = ["-v", `${workspace}:/workspace`];
|
|
407
|
+
if (!effectiveEphemeral) {
|
|
408
|
+
const persistRoot = node_path_1.default.join(xdgDataDir(), "harness", normalizeCwd(workspace), agentName);
|
|
409
|
+
// Deprecation warning for old .harness/ directory
|
|
410
|
+
const oldHarnessDir = node_path_1.default.join(workspace, ".harness");
|
|
411
|
+
if (node_fs_1.default.existsSync(oldHarnessDir)) {
|
|
412
|
+
console.error(`harness: WARNING: found ${oldHarnessDir}/ — persistence data now lives at ${persistRoot}. To migrate session data, copy the contents of .harness/${agentName}/ to the new location. Otherwise this directory can be safely deleted.`);
|
|
413
|
+
}
|
|
414
|
+
const mounts = adapter.persistMounts?.() ?? [];
|
|
415
|
+
for (const mount of mounts) {
|
|
416
|
+
const hostFullPath = node_path_1.default.join(persistRoot, mount.hostSubpath);
|
|
417
|
+
node_fs_1.default.mkdirSync(hostFullPath, { recursive: true });
|
|
418
|
+
volumeArgs.push("-v", `${hostFullPath}:${mount.containerPath}`);
|
|
419
|
+
}
|
|
420
|
+
// Project-level (agent-independent) XDG config persistence. Tools like
|
|
421
|
+
// jj write to ~/.config; persisting it one level above the per-agent
|
|
422
|
+
// persistRoot lets that config survive across runs and be shared by every
|
|
423
|
+
// agent working in this project. For opencode the more-specific
|
|
424
|
+
// .config/opencode mount above nests inside this one (Docker mounts
|
|
425
|
+
// parents before children), so opencode keeps its own per-agent bucket.
|
|
426
|
+
const xdgConfigPath = node_path_1.default.join(node_path_1.default.dirname(persistRoot), "xdg_config");
|
|
427
|
+
node_fs_1.default.mkdirSync(xdgConfigPath, { recursive: true });
|
|
428
|
+
volumeArgs.push("-v", `${xdgConfigPath}:/home/harness/.config`);
|
|
429
|
+
// Per-agent mise persistence (data: tools/plugins, state: trust settings)
|
|
430
|
+
const miseDataPath = node_path_1.default.join(persistRoot, "mise");
|
|
431
|
+
node_fs_1.default.mkdirSync(miseDataPath, { recursive: true });
|
|
432
|
+
volumeArgs.push("-v", `${miseDataPath}:/home/harness/.local/share/mise`);
|
|
433
|
+
const miseStatePath = node_path_1.default.join(persistRoot, "mise-state");
|
|
434
|
+
node_fs_1.default.mkdirSync(miseStatePath, { recursive: true });
|
|
435
|
+
volumeArgs.push("-v", `${miseStatePath}:/home/harness/.local/state/mise`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!noSkills) {
|
|
439
|
+
const skillDirs = [
|
|
440
|
+
{
|
|
441
|
+
host: node_path_1.default.resolve(node_os_1.default.homedir(), ".agents", "skills"),
|
|
442
|
+
container: "/home/harness/.agents/skills",
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
host: node_path_1.default.resolve(node_os_1.default.homedir(), ".claude", "skills"),
|
|
446
|
+
container: "/home/harness/.claude/skills",
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
for (const sd of skillDirs) {
|
|
450
|
+
if (node_fs_1.default.existsSync(sd.host) && node_fs_1.default.statSync(sd.host).isDirectory()) {
|
|
451
|
+
volumeArgs.push("-v", `${sd.host}:${sd.container}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Mount the user's global context files into the adapter's context directory
|
|
456
|
+
// so cross-agent rules apply inside the container. Mirrors pi's context-files
|
|
457
|
+
// model (AGENTS.md + CLAUDE.md). Each file is skipped when absent (like skills
|
|
458
|
+
// dirs); --no-context-files / -nc disables all of them.
|
|
459
|
+
if (!noContextFiles) {
|
|
460
|
+
const contextDir = adapter.contextDir?.();
|
|
461
|
+
if (contextDir) {
|
|
462
|
+
const contextFiles = [
|
|
463
|
+
{
|
|
464
|
+
host: node_path_1.default.resolve(node_os_1.default.homedir(), ".agents", "AGENTS.md"),
|
|
465
|
+
name: "AGENTS.md",
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
host: node_path_1.default.resolve(node_os_1.default.homedir(), ".claude", "CLAUDE.md"),
|
|
469
|
+
name: "CLAUDE.md",
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
for (const cf of contextFiles) {
|
|
473
|
+
if (node_fs_1.default.existsSync(cf.host) && node_fs_1.default.statSync(cf.host).isFile()) {
|
|
474
|
+
volumeArgs.push("-v", `${cf.host}:${contextDir}/${cf.name}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const userVolumeArgs = [];
|
|
480
|
+
for (const spec of volumeArgList) {
|
|
481
|
+
userVolumeArgs.push("-v", `${node_path_1.default.resolve(spec.split(":")[0])}:${spec.split(":").slice(1).join(":")}`);
|
|
482
|
+
}
|
|
483
|
+
const args = [
|
|
484
|
+
"run",
|
|
485
|
+
"--rm",
|
|
486
|
+
...ttyFlags,
|
|
487
|
+
"--cap-drop=ALL",
|
|
488
|
+
"--cap-add=NET_RAW",
|
|
489
|
+
"--security-opt",
|
|
490
|
+
"no-new-privileges:true",
|
|
491
|
+
"--security-opt",
|
|
492
|
+
`seccomp=${SECCOMP_PROFILE}`,
|
|
493
|
+
...envFileArgs,
|
|
494
|
+
...cloudModeEnv,
|
|
495
|
+
...adapterDockerArgs,
|
|
496
|
+
...volumeArgs,
|
|
497
|
+
...userVolumeArgs,
|
|
498
|
+
"-w",
|
|
499
|
+
"/workspace",
|
|
500
|
+
image,
|
|
501
|
+
...containerCmd,
|
|
502
|
+
];
|
|
503
|
+
const docker = (0, node_child_process_1.spawn)("docker", args, { stdio: "inherit" });
|
|
504
|
+
docker.on("exit", (code) => process.exit(code ?? 1));
|
|
505
|
+
}
|
|
506
|
+
if (!process.stdin.isTTY && promptArg === null) {
|
|
507
|
+
let input = "";
|
|
508
|
+
process.stdin.setEncoding("utf8");
|
|
509
|
+
process.stdin.on("data", (chunk) => {
|
|
510
|
+
input += chunk;
|
|
511
|
+
});
|
|
512
|
+
process.stdin.on("end", () => run(input.trim() ? input : null).catch(() => process.exit(1)));
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
run(promptArg).catch(() => process.exit(1));
|
|
516
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boldblackai/harness",
|
|
3
|
+
"version": "1.8.5",
|
|
4
|
+
"description": "Portable environment for running coding agents in a container",
|
|
5
|
+
"bin": {
|
|
6
|
+
"harness": "bin/harness.js"
|
|
7
|
+
},
|
|
8
|
+
"files": ["bin/"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/boldblackai/harness.git"
|
|
12
|
+
},
|
|
13
|
+
"packageManager": "pnpm@10.33.2",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc && cp -r src/seccomp-profiles bin/seccomp-profiles",
|
|
20
|
+
"prepare": "tsc",
|
|
21
|
+
"lint": "pnpm lint:ts && pnpm lint:md && pnpm lint:sh && pnpm lint:docker && pnpm lint:actions",
|
|
22
|
+
"lint:ts": "biome check .",
|
|
23
|
+
"lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
|
|
24
|
+
"lint:sh": "shellcheck entrypoint.sh entrypoint-opencode.sh entrypoint-hermes.sh",
|
|
25
|
+
"lint:docker": "hadolint Dockerfile Dockerfile.opencode Dockerfile.hermes",
|
|
26
|
+
"lint:actions": "actionlint",
|
|
27
|
+
"format": "biome format --write .",
|
|
28
|
+
"test:e2e:cli": "node --test tests/e2e/cli.test.mjs",
|
|
29
|
+
"test:e2e": "node --test tests/e2e/cli.test.mjs",
|
|
30
|
+
"test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 tests/e2e/cli.test.mjs"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"minimist": "^1.2.8"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "1.9.4",
|
|
37
|
+
"@types/minimist": "^1.2.5",
|
|
38
|
+
"@types/node": "^25.6.0",
|
|
39
|
+
"markdownlint-cli2": "0.17.2",
|
|
40
|
+
"typescript": "^6.0.2"
|
|
41
|
+
}
|
|
42
|
+
}
|