@cogineai/dearharness 0.1.0 → 0.1.1
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/README.md +36 -13
- package/dist/harness/source.js +188 -11
- package/package.json +4 -2
- package/dist/model/openrouter.js +0 -9
package/README.md
CHANGED
|
@@ -8,65 +8,88 @@ It is derived from `cogine-ai/cliq-agent`, but it is not a generic coding agent.
|
|
|
8
8
|
|
|
9
9
|
This repository is a standalone prototype. The main DearClaw platform repository may later consume or import stable pieces from it.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The current public npm package is `@cogineai/dearharness@0.1.0`.
|
|
12
|
+
|
|
13
|
+
Implemented baseline:
|
|
12
14
|
|
|
13
15
|
- local CLI identity
|
|
14
16
|
- OpenClaw workspace inspection
|
|
15
17
|
- install-lock status reading
|
|
16
18
|
- local Harness manifest validation
|
|
17
19
|
- minimal private daemon task API
|
|
20
|
+
- remote `.zip` and `.tar.gz` bundle source staging
|
|
21
|
+
- dry-run install planning and conflict reporting
|
|
22
|
+
- guarded file apply with rollback and post-apply verification
|
|
23
|
+
- daemon `install_harness` tasks that use the same guarded install runner as the CLI
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
DearHarness still does not claim full OpenClaw config mutation, DearStore registry or publish API integration, package signing, dependency solving, or OpenClaw plugin installation.
|
|
20
26
|
|
|
21
27
|
## Quick Start
|
|
22
28
|
|
|
23
29
|
Requirements: Node.js 20 or newer.
|
|
24
30
|
|
|
31
|
+
Run the published CLI without installing it globally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx -y @cogineai/dearharness help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or install it globally:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install -g @cogineai/dearharness
|
|
41
|
+
dearharness help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For local source development:
|
|
45
|
+
|
|
25
46
|
```bash
|
|
26
47
|
npm install
|
|
27
48
|
npm run build
|
|
28
49
|
node dist/index.js help
|
|
29
50
|
```
|
|
30
51
|
|
|
52
|
+
The examples below use the installed `dearharness` binary. In a source checkout, replace `dearharness` with `node dist/index.js`.
|
|
53
|
+
|
|
31
54
|
Inspect an OpenClaw-like workspace:
|
|
32
55
|
|
|
33
56
|
```bash
|
|
34
|
-
|
|
57
|
+
dearharness inspect --target-workspace ~/.openclaw/workspace
|
|
35
58
|
```
|
|
36
59
|
|
|
37
60
|
Read local install status:
|
|
38
61
|
|
|
39
62
|
```bash
|
|
40
|
-
|
|
63
|
+
dearharness status --target-workspace ~/.openclaw/workspace
|
|
41
64
|
```
|
|
42
65
|
|
|
43
66
|
Validate a Harness source directory:
|
|
44
67
|
|
|
45
68
|
```bash
|
|
46
|
-
|
|
69
|
+
dearharness validate ./my-harness
|
|
47
70
|
```
|
|
48
71
|
|
|
49
72
|
Download and validate a remote Harness bundle without applying changes. The install runner accepts either a `.zip` or `.tar.gz` archive URL:
|
|
50
73
|
|
|
51
74
|
```bash
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
dearharness install https://example.com/my-harness.zip --target-workspace ~/.openclaw/workspace --dry-run
|
|
76
|
+
dearharness install https://example.com/my-harness.tar.gz --target-workspace ~/.openclaw/workspace --dry-run
|
|
54
77
|
```
|
|
55
78
|
|
|
56
79
|
Apply a conflict-free plan explicitly:
|
|
57
80
|
|
|
58
81
|
```bash
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
dearharness install https://example.com/my-harness.zip --target-workspace ~/.openclaw/workspace --apply
|
|
83
|
+
dearharness install https://example.com/my-harness.tar.gz --target-workspace ~/.openclaw/workspace --apply
|
|
61
84
|
```
|
|
62
85
|
|
|
63
|
-
Run the
|
|
86
|
+
Run the private daemon:
|
|
64
87
|
|
|
65
88
|
```bash
|
|
66
|
-
DEARHARNESS_DAEMON_TOKEN=dev-token
|
|
89
|
+
DEARHARNESS_DAEMON_TOKEN=dev-token dearharness daemon --host 127.0.0.1 --port 18790
|
|
67
90
|
```
|
|
68
91
|
|
|
69
|
-
Submit
|
|
92
|
+
Submit an inspect task:
|
|
70
93
|
|
|
71
94
|
```bash
|
|
72
95
|
curl -sS \
|
|
@@ -121,7 +144,7 @@ If DearHarness needs a generally useful runtime capability, prefer opening an up
|
|
|
121
144
|
|
|
122
145
|
Extensions are intentionally limited to hooks and instruction contributions. They do not register new model-callable top-level actions.
|
|
123
146
|
|
|
124
|
-
##
|
|
147
|
+
## Daemon Task Types
|
|
125
148
|
|
|
126
149
|
The daemon currently accepts synchronous task requests:
|
|
127
150
|
|
package/dist/harness/source.js
CHANGED
|
@@ -2,11 +2,22 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { gunzipSync,
|
|
5
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
6
6
|
import { validateHarnessSource } from './manifest.js';
|
|
7
7
|
const DEFAULT_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_MAX_DECOMPRESSED_BYTES = DEFAULT_MAX_ARCHIVE_BYTES;
|
|
8
9
|
const MANIFEST_FILE = 'dearharness.manifest.json';
|
|
9
10
|
const TAR_BLOCK_SIZE = 512;
|
|
11
|
+
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
12
|
+
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
13
|
+
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
14
|
+
const ZIP_MAX_END_OF_CENTRAL_DIRECTORY_SEARCH = 65_558;
|
|
15
|
+
const ZIP_GENERAL_PURPOSE_ENCRYPTED = 0x1;
|
|
16
|
+
const ZIP_COMPRESSION_STORED = 0;
|
|
17
|
+
const ZIP_COMPRESSION_DEFLATE = 8;
|
|
18
|
+
const ZIP_UINT32_MAX = 0xffffffff;
|
|
19
|
+
const ZIP_UINT16_MAX = 0xffff;
|
|
20
|
+
const CRC32_TABLE = buildCrc32Table();
|
|
10
21
|
function detectSourceKind(input) {
|
|
11
22
|
let url;
|
|
12
23
|
try {
|
|
@@ -59,14 +70,176 @@ function safeArchiveEntryPath(root, entryName) {
|
|
|
59
70
|
}
|
|
60
71
|
return target;
|
|
61
72
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
function decompressedSizeLimitError(maxDecompressedBytes) {
|
|
74
|
+
return new Error(`harness source archive exceeds ${maxDecompressedBytes} bytes when decompressed`);
|
|
75
|
+
}
|
|
76
|
+
function buildCrc32Table() {
|
|
77
|
+
const table = new Uint32Array(256);
|
|
78
|
+
for (let index = 0; index < table.length; index++) {
|
|
79
|
+
let value = index;
|
|
80
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
81
|
+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
82
|
+
}
|
|
83
|
+
table[index] = value >>> 0;
|
|
84
|
+
}
|
|
85
|
+
return table;
|
|
86
|
+
}
|
|
87
|
+
function crc32(data) {
|
|
88
|
+
let value = 0xffffffff;
|
|
89
|
+
for (const byte of data) {
|
|
90
|
+
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
|
|
91
|
+
}
|
|
92
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
function invalidZipError(message) {
|
|
95
|
+
return new Error(`invalid harness source zip archive: ${message}`);
|
|
96
|
+
}
|
|
97
|
+
function findZipEndOfCentralDirectory(archive) {
|
|
98
|
+
for (let offset = archive.byteLength - 22; offset >= 0 && archive.byteLength - offset <= ZIP_MAX_END_OF_CENTRAL_DIRECTORY_SEARCH; offset--) {
|
|
99
|
+
if (archive.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
100
|
+
return offset;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw invalidZipError('missing end of central directory');
|
|
104
|
+
}
|
|
105
|
+
function parseZipCentralDirectory(archive) {
|
|
106
|
+
const directoryEnd = findZipEndOfCentralDirectory(archive);
|
|
107
|
+
const diskNumber = archive.readUInt16LE(directoryEnd + 4);
|
|
108
|
+
const centralDirectoryDisk = archive.readUInt16LE(directoryEnd + 6);
|
|
109
|
+
const entriesOnDisk = archive.readUInt16LE(directoryEnd + 8);
|
|
110
|
+
const entryCount = archive.readUInt16LE(directoryEnd + 10);
|
|
111
|
+
const centralDirectorySize = archive.readUInt32LE(directoryEnd + 12);
|
|
112
|
+
const centralDirectoryOffset = archive.readUInt32LE(directoryEnd + 16);
|
|
113
|
+
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) {
|
|
114
|
+
throw invalidZipError('multi-disk zip archives are not supported');
|
|
115
|
+
}
|
|
116
|
+
if (entryCount === ZIP_UINT16_MAX ||
|
|
117
|
+
centralDirectorySize === ZIP_UINT32_MAX ||
|
|
118
|
+
centralDirectoryOffset === ZIP_UINT32_MAX) {
|
|
119
|
+
throw invalidZipError('Zip64 archives are not supported');
|
|
120
|
+
}
|
|
121
|
+
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
|
|
122
|
+
throw invalidZipError('central directory exceeds archive bounds');
|
|
123
|
+
}
|
|
124
|
+
const entries = [];
|
|
125
|
+
let offset = centralDirectoryOffset;
|
|
126
|
+
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize;
|
|
127
|
+
for (let index = 0; index < entryCount; index++) {
|
|
128
|
+
if (offset + 46 > centralDirectoryEnd || archive.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
129
|
+
throw invalidZipError('malformed central directory entry');
|
|
130
|
+
}
|
|
131
|
+
const flags = archive.readUInt16LE(offset + 8);
|
|
132
|
+
const compressionMethod = archive.readUInt16LE(offset + 10);
|
|
133
|
+
const entryCrc32 = archive.readUInt32LE(offset + 16);
|
|
134
|
+
const compressedSize = archive.readUInt32LE(offset + 20);
|
|
135
|
+
const uncompressedSize = archive.readUInt32LE(offset + 24);
|
|
136
|
+
const nameLength = archive.readUInt16LE(offset + 28);
|
|
137
|
+
const extraLength = archive.readUInt16LE(offset + 30);
|
|
138
|
+
const commentLength = archive.readUInt16LE(offset + 32);
|
|
139
|
+
const localHeaderOffset = archive.readUInt32LE(offset + 42);
|
|
140
|
+
const entryEnd = offset + 46 + nameLength + extraLength + commentLength;
|
|
141
|
+
if (entryEnd > centralDirectoryEnd) {
|
|
142
|
+
throw invalidZipError('central directory entry exceeds archive bounds');
|
|
143
|
+
}
|
|
144
|
+
if (compressedSize === ZIP_UINT32_MAX ||
|
|
145
|
+
uncompressedSize === ZIP_UINT32_MAX ||
|
|
146
|
+
localHeaderOffset === ZIP_UINT32_MAX) {
|
|
147
|
+
throw invalidZipError('Zip64 entries are not supported');
|
|
148
|
+
}
|
|
149
|
+
const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
|
150
|
+
entries.push({
|
|
151
|
+
name,
|
|
152
|
+
flags,
|
|
153
|
+
compressionMethod,
|
|
154
|
+
crc32: entryCrc32,
|
|
155
|
+
compressedSize,
|
|
156
|
+
uncompressedSize,
|
|
157
|
+
localHeaderOffset,
|
|
158
|
+
});
|
|
159
|
+
offset = entryEnd;
|
|
160
|
+
}
|
|
161
|
+
if (offset !== centralDirectoryEnd) {
|
|
162
|
+
throw invalidZipError('central directory has trailing bytes');
|
|
163
|
+
}
|
|
164
|
+
return entries;
|
|
165
|
+
}
|
|
166
|
+
function zipEntryCompressedData(archive, entry) {
|
|
167
|
+
const headerOffset = entry.localHeaderOffset;
|
|
168
|
+
if (headerOffset + 30 > archive.byteLength ||
|
|
169
|
+
archive.readUInt32LE(headerOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
|
170
|
+
throw invalidZipError(`missing local file header for ${entry.name}`);
|
|
171
|
+
}
|
|
172
|
+
const localFlags = archive.readUInt16LE(headerOffset + 6);
|
|
173
|
+
const localCompressionMethod = archive.readUInt16LE(headerOffset + 8);
|
|
174
|
+
const nameLength = archive.readUInt16LE(headerOffset + 26);
|
|
175
|
+
const extraLength = archive.readUInt16LE(headerOffset + 28);
|
|
176
|
+
const dataStart = headerOffset + 30 + nameLength + extraLength;
|
|
177
|
+
const dataEnd = dataStart + entry.compressedSize;
|
|
178
|
+
if (localCompressionMethod !== entry.compressionMethod) {
|
|
179
|
+
throw invalidZipError(`zip entry compression method mismatch: ${entry.name}`);
|
|
180
|
+
}
|
|
181
|
+
if ((localFlags & ZIP_GENERAL_PURPOSE_ENCRYPTED) !== 0 || (entry.flags & ZIP_GENERAL_PURPOSE_ENCRYPTED) !== 0) {
|
|
182
|
+
throw invalidZipError(`encrypted zip entries are not supported: ${entry.name}`);
|
|
183
|
+
}
|
|
184
|
+
if (dataStart > archive.byteLength || dataEnd > archive.byteLength) {
|
|
185
|
+
throw invalidZipError(`zip entry data exceeds archive bounds: ${entry.name}`);
|
|
186
|
+
}
|
|
187
|
+
return archive.subarray(dataStart, dataEnd);
|
|
188
|
+
}
|
|
189
|
+
function extractZipEntryData(entry, compressed, remainingBudget, maxDecompressedBytes) {
|
|
190
|
+
if (entry.compressionMethod === ZIP_COMPRESSION_STORED) {
|
|
191
|
+
if (entry.compressedSize !== entry.uncompressedSize) {
|
|
192
|
+
throw new Error(`zip entry size mismatch: ${entry.name}`);
|
|
193
|
+
}
|
|
194
|
+
return Buffer.from(compressed);
|
|
195
|
+
}
|
|
196
|
+
if (entry.compressionMethod !== ZIP_COMPRESSION_DEFLATE) {
|
|
197
|
+
throw invalidZipError(`unsupported zip entry compression method ${entry.compressionMethod}: ${entry.name}`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return inflateRawSync(compressed, { maxOutputLength: remainingBudget });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const code = error.code;
|
|
204
|
+
if (code === 'ERR_BUFFER_TOO_LARGE') {
|
|
205
|
+
throw decompressedSizeLimitError(maxDecompressedBytes);
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`failed to decompress harness source zip entry ${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function extractZipToStaging(archive, stagingPath, maxDecompressedBytes) {
|
|
211
|
+
let extractedTotal = 0;
|
|
212
|
+
for (const entry of parseZipCentralDirectory(archive)) {
|
|
213
|
+
const entryName = entry.name;
|
|
65
214
|
const target = safeArchiveEntryPath(stagingPath, entryName);
|
|
66
|
-
|
|
215
|
+
const normalizedName = entryName.replace(/\\/g, '/');
|
|
216
|
+
if (entry.uncompressedSize > maxDecompressedBytes - extractedTotal) {
|
|
217
|
+
throw decompressedSizeLimitError(maxDecompressedBytes);
|
|
218
|
+
}
|
|
219
|
+
if (normalizedName.endsWith('/')) {
|
|
220
|
+
const compressed = zipEntryCompressedData(archive, entry);
|
|
221
|
+
const data = extractZipEntryData(entry, compressed, maxDecompressedBytes - extractedTotal, maxDecompressedBytes);
|
|
222
|
+
if (data.byteLength !== 0 || entry.uncompressedSize !== 0) {
|
|
223
|
+
throw new Error(`zip entry size mismatch: ${entry.name}`);
|
|
224
|
+
}
|
|
225
|
+
if (crc32(data) !== entry.crc32) {
|
|
226
|
+
throw new Error(`zip entry crc mismatch: ${entry.name}`);
|
|
227
|
+
}
|
|
67
228
|
await fs.mkdir(target, { recursive: true });
|
|
68
229
|
continue;
|
|
69
230
|
}
|
|
231
|
+
const compressed = zipEntryCompressedData(archive, entry);
|
|
232
|
+
const data = extractZipEntryData(entry, compressed, maxDecompressedBytes - extractedTotal, maxDecompressedBytes);
|
|
233
|
+
if (data.byteLength !== entry.uncompressedSize) {
|
|
234
|
+
throw new Error(`zip entry size mismatch: ${entry.name}`);
|
|
235
|
+
}
|
|
236
|
+
if (crc32(data) !== entry.crc32) {
|
|
237
|
+
throw new Error(`zip entry crc mismatch: ${entry.name}`);
|
|
238
|
+
}
|
|
239
|
+
extractedTotal += data.byteLength;
|
|
240
|
+
if (extractedTotal > maxDecompressedBytes) {
|
|
241
|
+
throw decompressedSizeLimitError(maxDecompressedBytes);
|
|
242
|
+
}
|
|
70
243
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
71
244
|
await fs.writeFile(target, data);
|
|
72
245
|
}
|
|
@@ -142,15 +315,18 @@ function parseTarEntries(archive) {
|
|
|
142
315
|
}
|
|
143
316
|
return entries;
|
|
144
317
|
}
|
|
145
|
-
async function extractTarGzToStaging(archive, stagingPath) {
|
|
146
|
-
let
|
|
318
|
+
async function extractTarGzToStaging(archive, stagingPath, maxDecompressedBytes) {
|
|
319
|
+
let tarBuffer;
|
|
147
320
|
try {
|
|
148
|
-
|
|
321
|
+
tarBuffer = gunzipSync(archive, { maxOutputLength: maxDecompressedBytes });
|
|
149
322
|
}
|
|
150
323
|
catch (error) {
|
|
324
|
+
const code = error.code;
|
|
325
|
+
if (code === 'ERR_BUFFER_TOO_LARGE') {
|
|
326
|
+
throw decompressedSizeLimitError(maxDecompressedBytes);
|
|
327
|
+
}
|
|
151
328
|
throw new Error(`failed to decompress harness source gzip stream: ${error instanceof Error ? error.message : String(error)}`);
|
|
152
329
|
}
|
|
153
|
-
const tarBuffer = Buffer.from(inflated);
|
|
154
330
|
const entries = parseTarEntries(tarBuffer);
|
|
155
331
|
for (const entry of entries) {
|
|
156
332
|
const target = safeArchiveEntryPath(stagingPath, entry.name);
|
|
@@ -197,11 +373,12 @@ export async function materializeHarnessSource(input, options = {}) {
|
|
|
197
373
|
if (options.expectedSha256 && options.expectedSha256.toLowerCase() !== sha256) {
|
|
198
374
|
throw new Error(`harness source sha256 mismatch: expected ${options.expectedSha256}, got ${sha256}`);
|
|
199
375
|
}
|
|
376
|
+
const maxDecompressedBytes = options.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
|
|
200
377
|
if (kind === 'zip-url') {
|
|
201
|
-
await extractZipToStaging(archive, stagingPath);
|
|
378
|
+
await extractZipToStaging(archive, stagingPath, maxDecompressedBytes);
|
|
202
379
|
}
|
|
203
380
|
else {
|
|
204
|
-
await extractTarGzToStaging(archive, stagingPath);
|
|
381
|
+
await extractTarGzToStaging(archive, stagingPath, maxDecompressedBytes);
|
|
205
382
|
}
|
|
206
383
|
const rootPath = await findHarnessRoot(stagingPath);
|
|
207
384
|
const validation = await validateHarnessSource(rootPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cogineai/dearharness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "OpenClaw Harness operator CLI and instance-side daemon prototype.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"node": ">=20"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"
|
|
39
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
40
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
41
|
+
"prepack": "npm run build",
|
|
40
42
|
"start": "node dist/index.js",
|
|
41
43
|
"dev": "tsx src/index.ts",
|
|
42
44
|
"test": "node --test --import tsx \"src/**/*.test.ts\""
|
package/dist/model/openrouter.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_MODEL_CONFIG } from './registry.js';
|
|
2
|
-
import { createOpenRouterClient as createOpenRouterProviderClient } from './providers/openrouter.js';
|
|
3
|
-
export function createOpenRouterClient(config) {
|
|
4
|
-
const resolved = config ?? {
|
|
5
|
-
...DEFAULT_MODEL_CONFIG,
|
|
6
|
-
apiKey: process.env.OPENROUTER_API_KEY
|
|
7
|
-
};
|
|
8
|
-
return createOpenRouterProviderClient(resolved);
|
|
9
|
-
}
|