@cogineai/dearharness 0.1.0
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 +141 -0
- package/dist/cli.js +259 -0
- package/dist/config.js +21 -0
- package/dist/daemon/server.js +225 -0
- package/dist/extensions/builtin/policy-instructions.js +19 -0
- package/dist/extensions/loader.js +58 -0
- package/dist/extensions/types.js +1 -0
- package/dist/harness/install-applicator.js +126 -0
- package/dist/harness/install-apply.js +156 -0
- package/dist/harness/install-plan.js +154 -0
- package/dist/harness/install-runner.js +178 -0
- package/dist/harness/install-verification.js +117 -0
- package/dist/harness/lockfile.js +83 -0
- package/dist/harness/manifest.js +491 -0
- package/dist/harness/source.js +224 -0
- package/dist/harness/transaction.js +77 -0
- package/dist/harness/workspace.js +61 -0
- package/dist/index.js +9 -0
- package/dist/instructions/builder.js +33 -0
- package/dist/instructions/types.js +1 -0
- package/dist/model/config.js +100 -0
- package/dist/model/http.js +128 -0
- package/dist/model/index.js +22 -0
- package/dist/model/openrouter.js +9 -0
- package/dist/model/providers/anthropic.js +104 -0
- package/dist/model/providers/ollama-discovery.js +32 -0
- package/dist/model/providers/ollama.js +70 -0
- package/dist/model/providers/openai-compatible.js +118 -0
- package/dist/model/providers/openai.js +4 -0
- package/dist/model/providers/openrouter.js +79 -0
- package/dist/model/registry.js +108 -0
- package/dist/model/types.js +1 -0
- package/dist/policy/engine.js +30 -0
- package/dist/policy/types.js +1 -0
- package/dist/prompt/system.js +30 -0
- package/dist/protocol/actions.js +88 -0
- package/dist/runtime/assembly.js +54 -0
- package/dist/runtime/events.js +1 -0
- package/dist/runtime/hooks.js +13 -0
- package/dist/runtime/runner.js +193 -0
- package/dist/session/store.js +198 -0
- package/dist/session/types.js +1 -0
- package/dist/skills/loader.js +51 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/bash.js +71 -0
- package/dist/tools/edit.js +61 -0
- package/dist/tools/find.js +67 -0
- package/dist/tools/grep.js +88 -0
- package/dist/tools/ls.js +37 -0
- package/dist/tools/path.js +35 -0
- package/dist/tools/read.js +40 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/types.js +1 -0
- package/dist/workspace/config.js +72 -0
- package/package.json +52 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeInstallPath } from './install-plan.js';
|
|
4
|
+
import { readInstallLockfile } from './lockfile.js';
|
|
5
|
+
function resolveInside(root, relativeInput) {
|
|
6
|
+
if (path.isAbsolute(relativeInput)) {
|
|
7
|
+
throw new Error(`path must be relative: ${relativeInput}`);
|
|
8
|
+
}
|
|
9
|
+
const target = path.resolve(root, relativeInput);
|
|
10
|
+
const relative = path.relative(root, target);
|
|
11
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
12
|
+
throw new Error(`path must stay inside ${root}: ${relativeInput}`);
|
|
13
|
+
}
|
|
14
|
+
return target;
|
|
15
|
+
}
|
|
16
|
+
async function pathExists(target) {
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(target);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function filesMatch(left, right) {
|
|
26
|
+
try {
|
|
27
|
+
return (await fs.readFile(left)).equals(await fs.readFile(right));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function compareLockfileField(issues, field, expected, actual) {
|
|
34
|
+
if (actual === expected)
|
|
35
|
+
return;
|
|
36
|
+
issues.push({ kind: 'lockfile_entry_field_mismatch', field, expected, actual });
|
|
37
|
+
}
|
|
38
|
+
function compareManagedPaths(issues, expected, actual) {
|
|
39
|
+
const normalizedExpected = expected.map(normalizeInstallPath);
|
|
40
|
+
const normalizedActual = actual.map(normalizeInstallPath);
|
|
41
|
+
if (JSON.stringify(normalizedActual) === JSON.stringify(normalizedExpected))
|
|
42
|
+
return;
|
|
43
|
+
issues.push({
|
|
44
|
+
kind: 'lockfile_entry_field_mismatch',
|
|
45
|
+
field: 'managedPaths',
|
|
46
|
+
expected: normalizedExpected,
|
|
47
|
+
actual: normalizedActual,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export async function verifyAppliedInstall(options) {
|
|
51
|
+
const issues = [];
|
|
52
|
+
const lockfileStatus = await readInstallLockfile(options.targetWorkspace);
|
|
53
|
+
if (!lockfileStatus.exists) {
|
|
54
|
+
issues.push({ kind: 'lockfile_missing' });
|
|
55
|
+
}
|
|
56
|
+
for (const error of lockfileStatus.errors) {
|
|
57
|
+
issues.push({ kind: 'lockfile_invalid', error });
|
|
58
|
+
}
|
|
59
|
+
const installedEntry = lockfileStatus.installed.find((entry) => entry.packageId === options.plannedLockfileEntry.packageId);
|
|
60
|
+
if (!installedEntry) {
|
|
61
|
+
issues.push({ kind: 'lockfile_entry_missing', packageId: options.plannedLockfileEntry.packageId });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
compareLockfileField(issues, 'packageType', options.plannedLockfileEntry.packageType, installedEntry.packageType);
|
|
65
|
+
compareLockfileField(issues, 'version', options.plannedLockfileEntry.version, installedEntry.version);
|
|
66
|
+
compareLockfileField(issues, 'manifestUrl', options.plannedLockfileEntry.manifestUrl, installedEntry.manifestUrl);
|
|
67
|
+
compareLockfileField(issues, 'sha256', options.plannedLockfileEntry.sha256, installedEntry.sha256);
|
|
68
|
+
compareLockfileField(issues, 'installedBy', options.plannedLockfileEntry.installedBy, installedEntry.installedBy);
|
|
69
|
+
compareManagedPaths(issues, options.plannedLockfileEntry.managedPaths, installedEntry.managedPaths);
|
|
70
|
+
}
|
|
71
|
+
const managedPaths = [];
|
|
72
|
+
for (const managedPath of options.plannedLockfileEntry.managedPaths) {
|
|
73
|
+
const normalizedPath = normalizeInstallPath(managedPath);
|
|
74
|
+
const ok = await pathExists(resolveInside(options.targetWorkspace, normalizedPath));
|
|
75
|
+
managedPaths.push({ path: normalizedPath, ok });
|
|
76
|
+
if (!ok) {
|
|
77
|
+
issues.push({ kind: 'managed_path_missing', path: normalizedPath });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const change of options.plannedChanges) {
|
|
82
|
+
const targetPath = resolveInside(options.targetWorkspace, change.path);
|
|
83
|
+
if (change.kind === 'delete') {
|
|
84
|
+
const ok = !(await pathExists(targetPath));
|
|
85
|
+
files.push({ path: change.path, kind: change.kind, ok });
|
|
86
|
+
if (!ok) {
|
|
87
|
+
issues.push({ kind: 'planned_delete_still_exists', path: change.path });
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const targetExists = await pathExists(targetPath);
|
|
92
|
+
if (!targetExists) {
|
|
93
|
+
files.push({ path: change.path, kind: change.kind, ok: false });
|
|
94
|
+
issues.push({ kind: 'planned_file_missing', path: change.path, changeKind: change.kind });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const candidatePath = resolveInside(options.candidateWorkspace, change.path);
|
|
98
|
+
const ok = await filesMatch(candidatePath, targetPath);
|
|
99
|
+
files.push({ path: change.path, kind: change.kind, ok });
|
|
100
|
+
if (!ok) {
|
|
101
|
+
issues.push({ kind: 'planned_file_content_mismatch', path: change.path, changeKind: change.kind });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: issues.length === 0,
|
|
106
|
+
lockfile: installedEntry
|
|
107
|
+
? {
|
|
108
|
+
exists: lockfileStatus.exists,
|
|
109
|
+
packageId: installedEntry.packageId,
|
|
110
|
+
managedPaths: installedEntry.managedPaths.map(normalizeInstallPath),
|
|
111
|
+
}
|
|
112
|
+
: null,
|
|
113
|
+
managedPaths,
|
|
114
|
+
files,
|
|
115
|
+
issues,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function isInstalledPackage(value) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
5
|
+
return false;
|
|
6
|
+
const record = value;
|
|
7
|
+
return (typeof record.packageId === 'string' &&
|
|
8
|
+
(record.packageType === 'agent_harness' || record.packageType === 'skill_pack') &&
|
|
9
|
+
typeof record.version === 'string' &&
|
|
10
|
+
typeof record.manifestUrl === 'string' &&
|
|
11
|
+
typeof record.sha256 === 'string' &&
|
|
12
|
+
typeof record.installedAt === 'string' &&
|
|
13
|
+
typeof record.installedBy === 'string' &&
|
|
14
|
+
Array.isArray(record.managedPaths) &&
|
|
15
|
+
record.managedPaths.every((item) => typeof item === 'string'));
|
|
16
|
+
}
|
|
17
|
+
export async function readInstallLockfile(workspace) {
|
|
18
|
+
const lockPath = path.join(workspace, '.dearclaw', 'install-lock.json');
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
|
21
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
22
|
+
return {
|
|
23
|
+
path: lockPath,
|
|
24
|
+
exists: true,
|
|
25
|
+
version: 1,
|
|
26
|
+
installed: [],
|
|
27
|
+
errors: ['install-lock.json must be a JSON object'],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const record = parsed;
|
|
31
|
+
const installed = Array.isArray(record.installed) ? record.installed.filter(isInstalledPackage) : [];
|
|
32
|
+
const errors = [];
|
|
33
|
+
if (record.version !== 1)
|
|
34
|
+
errors.push('install-lock.json version must be 1');
|
|
35
|
+
if (!Array.isArray(record.installed))
|
|
36
|
+
errors.push('install-lock.json installed must be an array');
|
|
37
|
+
if (Array.isArray(record.installed) && installed.length !== record.installed.length) {
|
|
38
|
+
errors.push('install-lock.json contains invalid installed package entries');
|
|
39
|
+
}
|
|
40
|
+
return { path: lockPath, exists: true, version: 1, installed, errors };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') {
|
|
44
|
+
return { path: lockPath, exists: false, version: 1, installed: [], errors: [] };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
path: lockPath,
|
|
48
|
+
exists: true,
|
|
49
|
+
version: 1,
|
|
50
|
+
installed: [],
|
|
51
|
+
errors: [`failed to parse install-lock.json: ${error instanceof Error ? error.message : String(error)}`],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function writeInstallLockfile(workspace, installed) {
|
|
56
|
+
const directory = path.join(workspace, '.dearclaw');
|
|
57
|
+
const lockPath = path.join(directory, 'install-lock.json');
|
|
58
|
+
await fs.mkdir(directory, { recursive: true });
|
|
59
|
+
const tempPath = path.join(directory, `.install-lock.json.${process.pid}.${Date.now()}.tmp`);
|
|
60
|
+
try {
|
|
61
|
+
await fs.writeFile(tempPath, `${JSON.stringify({ version: 1, installed }, null, 2)}\n`);
|
|
62
|
+
await fs.rename(tempPath, lockPath);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await fs.rm(tempPath, { force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function upsertInstallLockfileEntry(workspace, lockfile, entry, installedAt = new Date().toISOString()) {
|
|
69
|
+
if (lockfile.errors.length > 0) {
|
|
70
|
+
throw new Error(`cannot write install-lock.json with existing errors: ${lockfile.errors.join('; ')}`);
|
|
71
|
+
}
|
|
72
|
+
const installedEntry = {
|
|
73
|
+
...entry,
|
|
74
|
+
installedAt,
|
|
75
|
+
};
|
|
76
|
+
const installed = [
|
|
77
|
+
...lockfile.installed.filter((candidate) => candidate.packageId !== entry.packageId),
|
|
78
|
+
installedEntry,
|
|
79
|
+
];
|
|
80
|
+
installed.sort((left, right) => left.packageId.localeCompare(right.packageId));
|
|
81
|
+
await writeInstallLockfile(workspace, installed);
|
|
82
|
+
return installedEntry;
|
|
83
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MANIFEST_FILE = 'dearharness.manifest.json';
|
|
4
|
+
const PACKAGE_TYPES = ['agent_harness', 'skill_pack'];
|
|
5
|
+
const AGENT_ROLES = ['primary', 'specialist', 'support'];
|
|
6
|
+
const ROUTING_MODES = ['primary_entry', 'internal_only', 'external_binding'];
|
|
7
|
+
const WORKSPACE_FILE_ROLES = [
|
|
8
|
+
'agents',
|
|
9
|
+
'soul',
|
|
10
|
+
'user',
|
|
11
|
+
'tools',
|
|
12
|
+
'identity',
|
|
13
|
+
'memory',
|
|
14
|
+
'heartbeat',
|
|
15
|
+
'bootstrap',
|
|
16
|
+
'knowledge',
|
|
17
|
+
'other',
|
|
18
|
+
];
|
|
19
|
+
const MERGE_POLICIES = ['append_managed_block', 'preserve_existing', 'replace_if_absent', 'replace_managed_file'];
|
|
20
|
+
const SKILL_INSTALL_SCOPES = ['agent_workspace', 'current_workspace', 'managed_global'];
|
|
21
|
+
const SKILL_ACTIVATION_MODES = ['auto_discover', 'explicit_user_instruction', 'append_tools_guidance'];
|
|
22
|
+
const PLUGIN_INSTALL_POLICIES = ['manual', 'preinstalled_required', 'unsupported'];
|
|
23
|
+
const CONFIG_OPERATION_TYPES = [
|
|
24
|
+
'upsert_agent',
|
|
25
|
+
'set_agent_skills',
|
|
26
|
+
'bind_route',
|
|
27
|
+
'enable_agent_to_agent',
|
|
28
|
+
'merge_mcp_server',
|
|
29
|
+
];
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function isOneOf(values, value) {
|
|
34
|
+
return typeof value === 'string' && values.includes(value);
|
|
35
|
+
}
|
|
36
|
+
function isPackageType(value) {
|
|
37
|
+
return isOneOf(PACKAGE_TYPES, value);
|
|
38
|
+
}
|
|
39
|
+
function isValidId(value) {
|
|
40
|
+
return /^[a-z0-9][a-z0-9._-]*$/.test(value);
|
|
41
|
+
}
|
|
42
|
+
function isSafeRelativeInput(value) {
|
|
43
|
+
if (value.length === 0 || value.includes('\0'))
|
|
44
|
+
return false;
|
|
45
|
+
const normalized = value.replace(/\\/g, '/');
|
|
46
|
+
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized))
|
|
47
|
+
return false;
|
|
48
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
49
|
+
return parts.length > 0 && !parts.includes('..');
|
|
50
|
+
}
|
|
51
|
+
function safeResolve(root, relativeInput) {
|
|
52
|
+
if (!isSafeRelativeInput(relativeInput) || path.isAbsolute(relativeInput))
|
|
53
|
+
return null;
|
|
54
|
+
const target = path.resolve(root, relativeInput);
|
|
55
|
+
const relative = path.relative(root, target);
|
|
56
|
+
if (relative.startsWith('..') || path.isAbsolute(relative))
|
|
57
|
+
return null;
|
|
58
|
+
return target;
|
|
59
|
+
}
|
|
60
|
+
function isPrivateWorkspaceTarget(target) {
|
|
61
|
+
const [head] = target.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
62
|
+
return head === 'USER.md' || head === 'MEMORY.md' || head === 'memory';
|
|
63
|
+
}
|
|
64
|
+
function readStringArray(value, label, errors) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
errors.push(`${label} must be an array when present`);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const result = [];
|
|
72
|
+
for (const [index, item] of value.entries()) {
|
|
73
|
+
if (typeof item !== 'string' || item.length === 0) {
|
|
74
|
+
errors.push(`${label}[${index}] must be a non-empty string`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
result.push(item);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
function createResult(root, manifestPath, errors, overrides = {}) {
|
|
82
|
+
return {
|
|
83
|
+
ok: errors.length === 0,
|
|
84
|
+
sourcePath: root,
|
|
85
|
+
manifestPath,
|
|
86
|
+
package: null,
|
|
87
|
+
agents: [],
|
|
88
|
+
workspaceFiles: [],
|
|
89
|
+
skills: [],
|
|
90
|
+
configOperations: [],
|
|
91
|
+
pluginRequirements: [],
|
|
92
|
+
...overrides,
|
|
93
|
+
errors,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function readPackage(record, errors) {
|
|
97
|
+
const value = record.package;
|
|
98
|
+
if (!isRecord(value)) {
|
|
99
|
+
errors.push('package must be an object');
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const id = value.id;
|
|
103
|
+
const type = value.type;
|
|
104
|
+
const version = value.version;
|
|
105
|
+
const displayName = value.display_name;
|
|
106
|
+
if (typeof id !== 'string' || !isValidId(id))
|
|
107
|
+
errors.push('package.id must be a lowercase package id');
|
|
108
|
+
if (!isPackageType(type))
|
|
109
|
+
errors.push(`package.type must be one of: ${PACKAGE_TYPES.join(', ')}`);
|
|
110
|
+
if (typeof version !== 'string' || version.length === 0)
|
|
111
|
+
errors.push('package.version must be a non-empty string');
|
|
112
|
+
if (typeof displayName !== 'string' || displayName.length === 0) {
|
|
113
|
+
errors.push('package.display_name must be a non-empty string');
|
|
114
|
+
}
|
|
115
|
+
if (typeof id !== 'string' || !isValidId(id) || !isPackageType(type) || typeof version !== 'string' || typeof displayName !== 'string') {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return { id, type, version, displayName };
|
|
119
|
+
}
|
|
120
|
+
function readComponents(record, errors) {
|
|
121
|
+
const components = record.components;
|
|
122
|
+
if (components === undefined)
|
|
123
|
+
return null;
|
|
124
|
+
if (!isRecord(components)) {
|
|
125
|
+
errors.push('components must be an object when present');
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return components;
|
|
129
|
+
}
|
|
130
|
+
async function pathIsFile(target) {
|
|
131
|
+
try {
|
|
132
|
+
return (await fs.stat(target)).isFile();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function readAgents(components, errors) {
|
|
139
|
+
const agents = components?.agents;
|
|
140
|
+
if (agents === undefined)
|
|
141
|
+
return [];
|
|
142
|
+
if (!Array.isArray(agents)) {
|
|
143
|
+
errors.push('components.agents must be an array when present');
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const summaries = [];
|
|
147
|
+
for (const [index, rawAgent] of agents.entries()) {
|
|
148
|
+
if (!isRecord(rawAgent)) {
|
|
149
|
+
errors.push(`components.agents[${index}] must be an object`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const id = rawAgent.id;
|
|
153
|
+
const name = rawAgent.name;
|
|
154
|
+
const role = rawAgent.role;
|
|
155
|
+
const workspace = rawAgent.workspace;
|
|
156
|
+
const agentDir = rawAgent.agent_dir;
|
|
157
|
+
if (typeof id !== 'string' || !isValidId(id))
|
|
158
|
+
errors.push(`components.agents[${index}].id must be a lowercase agent id`);
|
|
159
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
160
|
+
errors.push(`components.agents[${index}].name must be a non-empty string`);
|
|
161
|
+
if (!isOneOf(AGENT_ROLES, role))
|
|
162
|
+
errors.push(`components.agents[${index}].role must be one of: ${AGENT_ROLES.join(', ')}`);
|
|
163
|
+
if (typeof workspace !== 'string' || !isSafeRelativeInput(workspace)) {
|
|
164
|
+
errors.push(`components.agents[${index}].workspace must be a safe relative path`);
|
|
165
|
+
}
|
|
166
|
+
if (agentDir !== undefined && (typeof agentDir !== 'string' || !isSafeRelativeInput(agentDir))) {
|
|
167
|
+
errors.push(`components.agents[${index}].agent_dir must be a safe relative path`);
|
|
168
|
+
}
|
|
169
|
+
const skills = readStringArray(rawAgent.skills, `components.agents[${index}].skills`, errors);
|
|
170
|
+
let routing;
|
|
171
|
+
if (rawAgent.routing !== undefined) {
|
|
172
|
+
if (!isRecord(rawAgent.routing)) {
|
|
173
|
+
errors.push(`components.agents[${index}].routing must be an object when present`);
|
|
174
|
+
}
|
|
175
|
+
else if (!isOneOf(ROUTING_MODES, rawAgent.routing.mode)) {
|
|
176
|
+
errors.push(`components.agents[${index}].routing.mode must be one of: ${ROUTING_MODES.join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
routing = { mode: rawAgent.routing.mode };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const model = rawAgent.model;
|
|
183
|
+
if (model !== undefined && !isRecord(model)) {
|
|
184
|
+
errors.push(`components.agents[${index}].model must be an object when present`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof id !== 'string' ||
|
|
187
|
+
!isValidId(id) ||
|
|
188
|
+
typeof name !== 'string' ||
|
|
189
|
+
name.length === 0 ||
|
|
190
|
+
!isOneOf(AGENT_ROLES, role) ||
|
|
191
|
+
typeof workspace !== 'string' ||
|
|
192
|
+
!isSafeRelativeInput(workspace) ||
|
|
193
|
+
(agentDir !== undefined && (typeof agentDir !== 'string' || !isSafeRelativeInput(agentDir)))) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
summaries.push({
|
|
197
|
+
id,
|
|
198
|
+
name,
|
|
199
|
+
role,
|
|
200
|
+
workspace,
|
|
201
|
+
...(typeof agentDir === 'string' && isSafeRelativeInput(agentDir) ? { agentDir } : {}),
|
|
202
|
+
skills,
|
|
203
|
+
...(routing ? { routing } : {}),
|
|
204
|
+
...(isRecord(model) ? { model } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return summaries;
|
|
208
|
+
}
|
|
209
|
+
async function readWorkspaceFiles(root, components, errors) {
|
|
210
|
+
const workspaceFiles = components?.workspace_files;
|
|
211
|
+
if (workspaceFiles === undefined)
|
|
212
|
+
return [];
|
|
213
|
+
if (!Array.isArray(workspaceFiles)) {
|
|
214
|
+
errors.push('components.workspace_files must be an array when present');
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
const summaries = [];
|
|
218
|
+
for (const [index, rawFile] of workspaceFiles.entries()) {
|
|
219
|
+
if (!isRecord(rawFile)) {
|
|
220
|
+
errors.push(`components.workspace_files[${index}] must be an object`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const id = rawFile.id;
|
|
224
|
+
const agentId = rawFile.agent_id;
|
|
225
|
+
const role = rawFile.role;
|
|
226
|
+
const source = rawFile.source;
|
|
227
|
+
const target = rawFile.target;
|
|
228
|
+
const mergePolicy = rawFile.merge_policy;
|
|
229
|
+
if (typeof id !== 'string' || !isValidId(id)) {
|
|
230
|
+
errors.push(`components.workspace_files[${index}].id must be a lowercase workspace file id`);
|
|
231
|
+
}
|
|
232
|
+
if (agentId !== undefined && (typeof agentId !== 'string' || !isValidId(agentId))) {
|
|
233
|
+
errors.push(`components.workspace_files[${index}].agent_id must be a lowercase agent id when present`);
|
|
234
|
+
}
|
|
235
|
+
if (!isOneOf(WORKSPACE_FILE_ROLES, role)) {
|
|
236
|
+
errors.push(`components.workspace_files[${index}].role must be one of: ${WORKSPACE_FILE_ROLES.join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
if (typeof source !== 'string' || source.length === 0) {
|
|
239
|
+
errors.push(`declared workspace file ${typeof id === 'string' ? id : index} source must be a non-empty string`);
|
|
240
|
+
}
|
|
241
|
+
if (typeof target !== 'string' || !isSafeRelativeInput(target)) {
|
|
242
|
+
errors.push(`declared workspace file ${typeof id === 'string' ? id : index} target must be a safe relative path`);
|
|
243
|
+
}
|
|
244
|
+
else if (isPrivateWorkspaceTarget(target)) {
|
|
245
|
+
errors.push(`workspace file ${typeof id === 'string' ? id : index} targets private OpenClaw content`);
|
|
246
|
+
}
|
|
247
|
+
if (!isOneOf(MERGE_POLICIES, mergePolicy)) {
|
|
248
|
+
errors.push(`components.workspace_files[${index}].merge_policy must be one of: ${MERGE_POLICIES.join(', ')}`);
|
|
249
|
+
}
|
|
250
|
+
if (typeof id !== 'string' ||
|
|
251
|
+
!isValidId(id) ||
|
|
252
|
+
!isOneOf(WORKSPACE_FILE_ROLES, role) ||
|
|
253
|
+
typeof source !== 'string' ||
|
|
254
|
+
source.length === 0 ||
|
|
255
|
+
typeof target !== 'string' ||
|
|
256
|
+
!isSafeRelativeInput(target) ||
|
|
257
|
+
isPrivateWorkspaceTarget(target) ||
|
|
258
|
+
!isOneOf(MERGE_POLICIES, mergePolicy)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const sourcePath = safeResolve(root, source);
|
|
262
|
+
if (!sourcePath) {
|
|
263
|
+
errors.push(`declared workspace file ${id} source must stay inside the harness source directory`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!(await pathIsFile(sourcePath))) {
|
|
267
|
+
errors.push(`declared workspace file ${id} missing source file ${source}`);
|
|
268
|
+
}
|
|
269
|
+
summaries.push({
|
|
270
|
+
id,
|
|
271
|
+
...(typeof agentId === 'string' && isValidId(agentId) ? { agentId } : {}),
|
|
272
|
+
role,
|
|
273
|
+
source,
|
|
274
|
+
target,
|
|
275
|
+
mergePolicy,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return summaries;
|
|
279
|
+
}
|
|
280
|
+
function readSkillActivation(index, rawSkill, errors) {
|
|
281
|
+
const activation = rawSkill.activation;
|
|
282
|
+
if (activation === undefined)
|
|
283
|
+
return undefined;
|
|
284
|
+
if (!isRecord(activation)) {
|
|
285
|
+
errors.push(`components.skills[${index}].activation must be an object when present`);
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
const mode = activation.mode;
|
|
289
|
+
if (!isOneOf(SKILL_ACTIVATION_MODES, mode)) {
|
|
290
|
+
errors.push(`components.skills[${index}].activation.mode must be one of: ${SKILL_ACTIVATION_MODES.join(', ')}`);
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const usageHint = activation.usage_hint;
|
|
294
|
+
const toolsMdAppend = activation.tools_md_append;
|
|
295
|
+
if (usageHint !== undefined && typeof usageHint !== 'string') {
|
|
296
|
+
errors.push(`components.skills[${index}].activation.usage_hint must be a string when present`);
|
|
297
|
+
}
|
|
298
|
+
if (toolsMdAppend !== undefined && typeof toolsMdAppend !== 'string') {
|
|
299
|
+
errors.push(`components.skills[${index}].activation.tools_md_append must be a string when present`);
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
mode,
|
|
303
|
+
...(typeof usageHint === 'string' ? { usageHint } : {}),
|
|
304
|
+
...(typeof toolsMdAppend === 'string' ? { toolsMdAppend } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async function readSkills(root, components, errors) {
|
|
308
|
+
const skills = components?.skills;
|
|
309
|
+
if (skills === undefined)
|
|
310
|
+
return [];
|
|
311
|
+
if (!Array.isArray(skills)) {
|
|
312
|
+
errors.push('components.skills must be an array when present');
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
const summaries = [];
|
|
316
|
+
for (const [index, rawSkill] of skills.entries()) {
|
|
317
|
+
if (!isRecord(rawSkill)) {
|
|
318
|
+
errors.push(`components.skills[${index}] must be an object`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const id = rawSkill.id;
|
|
322
|
+
const source = rawSkill.source;
|
|
323
|
+
const entry = rawSkill.entry;
|
|
324
|
+
const installScope = rawSkill.install_scope;
|
|
325
|
+
if (typeof id !== 'string' || !isValidId(id)) {
|
|
326
|
+
errors.push(`components.skills[${index}].id must be a lowercase skill id`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (typeof source !== 'string' || source.length === 0) {
|
|
330
|
+
errors.push(`declared skill ${id} source must be a non-empty string`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
334
|
+
errors.push(`declared skill ${id} entry must be a non-empty string`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (installScope !== undefined && !isOneOf(SKILL_INSTALL_SCOPES, installScope)) {
|
|
338
|
+
errors.push(`declared skill ${id} install_scope must be one of: ${SKILL_INSTALL_SCOPES.join(', ')}`);
|
|
339
|
+
}
|
|
340
|
+
const agentIds = readStringArray(rawSkill.agent_ids, `components.skills[${index}].agent_ids`, errors);
|
|
341
|
+
const activation = readSkillActivation(index, rawSkill, errors);
|
|
342
|
+
const sourcePath = safeResolve(root, source);
|
|
343
|
+
if (!sourcePath) {
|
|
344
|
+
errors.push(`declared skill ${id} source must stay inside the harness source directory`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const entryPath = safeResolve(sourcePath, entry);
|
|
348
|
+
if (!entryPath || !(await pathIsFile(entryPath))) {
|
|
349
|
+
errors.push(`declared skill ${id} missing entry file ${entry}`);
|
|
350
|
+
}
|
|
351
|
+
summaries.push({
|
|
352
|
+
id,
|
|
353
|
+
source,
|
|
354
|
+
entry,
|
|
355
|
+
...(isOneOf(SKILL_INSTALL_SCOPES, installScope) ? { installScope } : {}),
|
|
356
|
+
agentIds,
|
|
357
|
+
...(activation ? { activation } : {}),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return summaries;
|
|
361
|
+
}
|
|
362
|
+
function readPluginRequirements(record, errors) {
|
|
363
|
+
const requirements = record.requirements;
|
|
364
|
+
if (requirements === undefined)
|
|
365
|
+
return [];
|
|
366
|
+
if (!isRecord(requirements)) {
|
|
367
|
+
errors.push('requirements must be an object when present');
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
const plugins = requirements.plugins;
|
|
371
|
+
if (plugins === undefined)
|
|
372
|
+
return [];
|
|
373
|
+
if (!Array.isArray(plugins)) {
|
|
374
|
+
errors.push('requirements.plugins must be an array when present');
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
const summaries = [];
|
|
378
|
+
for (const [index, rawPlugin] of plugins.entries()) {
|
|
379
|
+
if (!isRecord(rawPlugin)) {
|
|
380
|
+
errors.push(`requirements.plugins[${index}] must be an object`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const id = rawPlugin.id;
|
|
384
|
+
const required = rawPlugin.required;
|
|
385
|
+
const installPolicy = rawPlugin.install_policy;
|
|
386
|
+
const reason = rawPlugin.reason;
|
|
387
|
+
if (typeof id !== 'string' || !isValidId(id))
|
|
388
|
+
errors.push(`requirements.plugins[${index}].id must be a lowercase plugin id`);
|
|
389
|
+
if (typeof required !== 'boolean')
|
|
390
|
+
errors.push(`requirements.plugins[${index}].required must be a boolean`);
|
|
391
|
+
if (!isOneOf(PLUGIN_INSTALL_POLICIES, installPolicy)) {
|
|
392
|
+
errors.push(`requirements.plugins[${index}].install_policy must be one of: ${PLUGIN_INSTALL_POLICIES.join(', ')}`);
|
|
393
|
+
}
|
|
394
|
+
if (reason !== undefined && typeof reason !== 'string') {
|
|
395
|
+
errors.push(`requirements.plugins[${index}].reason must be a string when present`);
|
|
396
|
+
}
|
|
397
|
+
if (typeof id !== 'string' || !isValidId(id) || typeof required !== 'boolean' || !isOneOf(PLUGIN_INSTALL_POLICIES, installPolicy)) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
summaries.push({
|
|
401
|
+
id,
|
|
402
|
+
required,
|
|
403
|
+
installPolicy,
|
|
404
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return summaries;
|
|
408
|
+
}
|
|
409
|
+
function readConfigOperations(record, errors) {
|
|
410
|
+
const install = record.install;
|
|
411
|
+
if (install === undefined)
|
|
412
|
+
return [];
|
|
413
|
+
if (!isRecord(install)) {
|
|
414
|
+
errors.push('install must be an object when present');
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
if (install.adapter !== undefined && typeof install.adapter !== 'string') {
|
|
418
|
+
errors.push('install.adapter must be a string when present');
|
|
419
|
+
}
|
|
420
|
+
const operations = install.config_operations;
|
|
421
|
+
if (operations === undefined)
|
|
422
|
+
return [];
|
|
423
|
+
if (!Array.isArray(operations)) {
|
|
424
|
+
errors.push('install.config_operations must be an array when present');
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
const summaries = [];
|
|
428
|
+
for (const [index, rawOperation] of operations.entries()) {
|
|
429
|
+
if (!isRecord(rawOperation)) {
|
|
430
|
+
errors.push(`install.config_operations[${index}] must be an object`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const op = rawOperation.op;
|
|
434
|
+
if (!isOneOf(CONFIG_OPERATION_TYPES, op)) {
|
|
435
|
+
errors.push(typeof op === 'string' ? `unknown config operation ${op}` : `install.config_operations[${index}].op must be one of: ${CONFIG_OPERATION_TYPES.join(', ')}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const agentId = rawOperation.agent_id;
|
|
439
|
+
if (agentId !== undefined && (typeof agentId !== 'string' || !isValidId(agentId))) {
|
|
440
|
+
errors.push(`install.config_operations[${index}].agent_id must be a lowercase agent id when present`);
|
|
441
|
+
}
|
|
442
|
+
const optional = rawOperation.optional;
|
|
443
|
+
if (optional !== undefined && typeof optional !== 'boolean') {
|
|
444
|
+
errors.push(`install.config_operations[${index}].optional must be a boolean when present`);
|
|
445
|
+
}
|
|
446
|
+
summaries.push({
|
|
447
|
+
op,
|
|
448
|
+
...(typeof agentId === 'string' && isValidId(agentId) ? { agentId } : {}),
|
|
449
|
+
optional: optional === true,
|
|
450
|
+
raw: rawOperation,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return summaries;
|
|
454
|
+
}
|
|
455
|
+
export async function validateHarnessSource(sourcePath) {
|
|
456
|
+
const root = path.resolve(sourcePath);
|
|
457
|
+
const manifestPath = path.join(root, MANIFEST_FILE);
|
|
458
|
+
const errors = [];
|
|
459
|
+
let parsed;
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
if (error.code === 'ENOENT') {
|
|
465
|
+
return createResult(root, manifestPath, [`missing ${MANIFEST_FILE}`]);
|
|
466
|
+
}
|
|
467
|
+
return createResult(root, manifestPath, [
|
|
468
|
+
`failed to parse ${MANIFEST_FILE}: ${error instanceof Error ? error.message : String(error)}`,
|
|
469
|
+
]);
|
|
470
|
+
}
|
|
471
|
+
if (!isRecord(parsed)) {
|
|
472
|
+
return createResult(root, manifestPath, [`${MANIFEST_FILE} must contain a JSON object`]);
|
|
473
|
+
}
|
|
474
|
+
if (parsed.manifest_version !== 1)
|
|
475
|
+
errors.push('manifest_version must be 1');
|
|
476
|
+
const packageSummary = readPackage(parsed, errors);
|
|
477
|
+
const components = readComponents(parsed, errors);
|
|
478
|
+
const agents = readAgents(components, errors);
|
|
479
|
+
const workspaceFiles = await readWorkspaceFiles(root, components, errors);
|
|
480
|
+
const skills = await readSkills(root, components, errors);
|
|
481
|
+
const pluginRequirements = readPluginRequirements(parsed, errors);
|
|
482
|
+
const configOperations = readConfigOperations(parsed, errors);
|
|
483
|
+
return createResult(root, manifestPath, errors, {
|
|
484
|
+
package: packageSummary,
|
|
485
|
+
agents,
|
|
486
|
+
workspaceFiles,
|
|
487
|
+
skills,
|
|
488
|
+
configOperations,
|
|
489
|
+
pluginRequirements,
|
|
490
|
+
});
|
|
491
|
+
}
|