@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 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
- V0 scope:
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
- V1 work has started with remote `.zip` and `.tar.gz` bundle source staging, dry-run install planning, conflict reporting, guarded file apply, and post-apply verification. DearHarness still does not claim full OpenClaw config mutation, publication, package signing, dependency solving, or OpenClaw plugin installation.
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
- node dist/index.js inspect --target-workspace ~/.openclaw/workspace
57
+ dearharness inspect --target-workspace ~/.openclaw/workspace
35
58
  ```
36
59
 
37
60
  Read local install status:
38
61
 
39
62
  ```bash
40
- node dist/index.js status --target-workspace ~/.openclaw/workspace
63
+ dearharness status --target-workspace ~/.openclaw/workspace
41
64
  ```
42
65
 
43
66
  Validate a Harness source directory:
44
67
 
45
68
  ```bash
46
- node dist/index.js validate ./my-harness
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
- node dist/index.js install https://example.com/my-harness.zip --target-workspace ~/.openclaw/workspace --dry-run
53
- node dist/index.js install https://example.com/my-harness.tar.gz --target-workspace ~/.openclaw/workspace --dry-run
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
- node dist/index.js install https://example.com/my-harness.zip --target-workspace ~/.openclaw/workspace --apply
60
- node dist/index.js install https://example.com/my-harness.tar.gz --target-workspace ~/.openclaw/workspace --apply
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 V0 daemon:
86
+ Run the private daemon:
64
87
 
65
88
  ```bash
66
- DEARHARNESS_DAEMON_TOKEN=dev-token node dist/index.js daemon --host 127.0.0.1 --port 18790
89
+ DEARHARNESS_DAEMON_TOKEN=dev-token dearharness daemon --host 127.0.0.1 --port 18790
67
90
  ```
68
91
 
69
- Submit a V0 daemon task:
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
- ## V0 Task Types
147
+ ## Daemon Task Types
125
148
 
126
149
  The daemon currently accepts synchronous task requests:
127
150
 
@@ -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, unzipSync } from 'fflate';
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
- async function extractZipToStaging(archive, stagingPath) {
63
- const entries = unzipSync(archive);
64
- for (const [entryName, data] of Object.entries(entries)) {
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
- if (entryName.replace(/\\/g, '/').endsWith('/')) {
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 inflated;
318
+ async function extractTarGzToStaging(archive, stagingPath, maxDecompressedBytes) {
319
+ let tarBuffer;
147
320
  try {
148
- inflated = gunzipSync(archive);
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.0",
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
- "build": "tsc -p tsconfig.json",
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\""
@@ -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
- }