@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,58 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { resolveWorkspacePath } from '../tools/path.js';
|
|
3
|
+
import { policyInstructionsExtension } from './builtin/policy-instructions.js';
|
|
4
|
+
const BUILTIN_EXTENSIONS = {
|
|
5
|
+
'policy-instructions': policyInstructionsExtension
|
|
6
|
+
};
|
|
7
|
+
async function importExtension(specifier, cwd) {
|
|
8
|
+
if (specifier.startsWith('builtin:')) {
|
|
9
|
+
const builtin = BUILTIN_EXTENSIONS[specifier.slice('builtin:'.length)];
|
|
10
|
+
if (!builtin) {
|
|
11
|
+
throw new Error(`Unknown built-in extension: ${specifier}`);
|
|
12
|
+
}
|
|
13
|
+
return builtin;
|
|
14
|
+
}
|
|
15
|
+
if (specifier.startsWith('.')) {
|
|
16
|
+
const { targetRealPath } = await resolveWorkspacePath(cwd, specifier);
|
|
17
|
+
try {
|
|
18
|
+
const mod = await import(pathToFileURL(targetRealPath).href);
|
|
19
|
+
return (mod.default ?? mod.extension);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`Failed to load extension ${specifier}: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Unsupported extension specifier in Phase 2: ${specifier}`);
|
|
26
|
+
}
|
|
27
|
+
export async function loadExtensions(cwd, specifiers) {
|
|
28
|
+
const loaded = [];
|
|
29
|
+
const names = new Set();
|
|
30
|
+
for (const specifier of specifiers) {
|
|
31
|
+
const extension = await importExtension(specifier, cwd);
|
|
32
|
+
if (!extension || typeof extension.name !== 'string') {
|
|
33
|
+
throw new Error(`Extension ${specifier} must export a named CliqExtension`);
|
|
34
|
+
}
|
|
35
|
+
if (extension.instructionSources !== undefined && !Array.isArray(extension.instructionSources)) {
|
|
36
|
+
throw new Error(`Extension ${specifier} has invalid instructionSources, expected array`);
|
|
37
|
+
}
|
|
38
|
+
if ((extension.instructionSources ?? []).some((source) => typeof source !== 'function')) {
|
|
39
|
+
throw new Error(`Extension ${specifier} has invalid instructionSource item, expected function`);
|
|
40
|
+
}
|
|
41
|
+
if (extension.hooks !== undefined && !Array.isArray(extension.hooks)) {
|
|
42
|
+
throw new Error(`Extension ${specifier} has invalid hooks, expected array`);
|
|
43
|
+
}
|
|
44
|
+
if ((extension.hooks ?? []).some((hook) => !hook || typeof hook !== 'object' || Array.isArray(hook))) {
|
|
45
|
+
throw new Error(`Extension ${specifier} has invalid hook item, expected object`);
|
|
46
|
+
}
|
|
47
|
+
if (names.has(extension.name)) {
|
|
48
|
+
throw new Error(`duplicate extension name: ${extension.name}`);
|
|
49
|
+
}
|
|
50
|
+
names.add(extension.name);
|
|
51
|
+
loaded.push({
|
|
52
|
+
name: extension.name,
|
|
53
|
+
instructionSources: extension.instructionSources ?? [],
|
|
54
|
+
hooks: extension.hooks ?? []
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return loaded;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectPlannedManagedPaths, isPathOwnedByPackage, normalizeInstallPath, } from './install-plan.js';
|
|
4
|
+
function resolveInside(root, relativeInput) {
|
|
5
|
+
if (path.isAbsolute(relativeInput)) {
|
|
6
|
+
throw new Error(`path must be relative: ${relativeInput}`);
|
|
7
|
+
}
|
|
8
|
+
const target = path.resolve(root, relativeInput);
|
|
9
|
+
const relative = path.relative(root, target);
|
|
10
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
11
|
+
throw new Error(`path must stay inside ${root}: ${relativeInput}`);
|
|
12
|
+
}
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
async function pathExists(target) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(target);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function escapeRegExp(value) {
|
|
25
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
function managedBlock(packageId, resourceId, content) {
|
|
28
|
+
const key = `${packageId}/${resourceId}`;
|
|
29
|
+
return [
|
|
30
|
+
`<!-- dearharness:begin ${key} -->`,
|
|
31
|
+
content.trimEnd(),
|
|
32
|
+
`<!-- dearharness:end ${key} -->`,
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
36
|
+
function upsertManagedBlock(existing, packageId, resourceId, content) {
|
|
37
|
+
const key = `${packageId}/${resourceId}`;
|
|
38
|
+
const begin = `<!-- dearharness:begin ${key} -->`;
|
|
39
|
+
const end = `<!-- dearharness:end ${key} -->`;
|
|
40
|
+
const block = managedBlock(packageId, resourceId, content);
|
|
41
|
+
const pattern = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`);
|
|
42
|
+
if (pattern.test(existing)) {
|
|
43
|
+
return existing.replace(pattern, block);
|
|
44
|
+
}
|
|
45
|
+
const prefix = existing.trimEnd();
|
|
46
|
+
return prefix.length > 0 ? `${prefix}\n\n${block}` : block;
|
|
47
|
+
}
|
|
48
|
+
async function projectWorkspaceFileToCandidate(source, candidateWorkspace, packageId, lockfile) {
|
|
49
|
+
for (const workspaceFile of source.validation.workspaceFiles) {
|
|
50
|
+
const sourcePath = resolveInside(source.rootPath, workspaceFile.source);
|
|
51
|
+
const targetPath = resolveInside(candidateWorkspace, workspaceFile.target);
|
|
52
|
+
const targetRelativePath = normalizeInstallPath(workspaceFile.target);
|
|
53
|
+
if (workspaceFile.mergePolicy === 'append_managed_block') {
|
|
54
|
+
const sourceContent = await fs.readFile(sourcePath, 'utf8');
|
|
55
|
+
const existingContent = (await pathExists(targetPath)) ? await fs.readFile(targetPath, 'utf8') : '';
|
|
56
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
57
|
+
await fs.writeFile(targetPath, upsertManagedBlock(existingContent, packageId, workspaceFile.id, sourceContent));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (workspaceFile.mergePolicy === 'replace_managed_file') {
|
|
61
|
+
if ((await pathExists(targetPath)) && !isPathOwnedByPackage(lockfile, targetRelativePath, packageId)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
65
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (await pathExists(targetPath)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
72
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function copyDeclaredSkillsToCandidate(source, candidateWorkspace) {
|
|
76
|
+
if (!source.validation.ok) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const skill of source.validation.skills) {
|
|
80
|
+
const sourcePath = resolveInside(source.rootPath, skill.source);
|
|
81
|
+
const targetPath = resolveInside(candidateWorkspace, path.join('skills', skill.id));
|
|
82
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
83
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
84
|
+
await fs.cp(sourcePath, targetPath, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export const deterministicInstallApplicator = async ({ source, targetWorkspace, candidateWorkspace, lockfile, }) => {
|
|
88
|
+
if (!source.validation.ok) {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
mode: 'deterministic',
|
|
92
|
+
status: 'skipped',
|
|
93
|
+
summary: 'Skipped deterministic projection because source validation failed.',
|
|
94
|
+
managedPaths: [],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
const packageId = source.validation.package?.id ?? 'unknown-package';
|
|
99
|
+
await copyDeclaredSkillsToCandidate(source, candidateWorkspace);
|
|
100
|
+
await projectWorkspaceFileToCandidate(source, candidateWorkspace, packageId, lockfile);
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
mode: 'deterministic',
|
|
104
|
+
status: 'applied',
|
|
105
|
+
summary: 'Projected declared skills and workspace files into the candidate workspace.',
|
|
106
|
+
managedPaths: await collectPlannedManagedPaths(source.validation, targetWorkspace, lockfile),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
};
|
|
110
|
+
function normalizeManagedPaths(paths) {
|
|
111
|
+
if (!paths)
|
|
112
|
+
return [];
|
|
113
|
+
return paths.map(normalizeInstallPath).filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
export async function runInstallApplicators(context, applicators) {
|
|
116
|
+
const steps = [];
|
|
117
|
+
for (const applicator of applicators) {
|
|
118
|
+
for (const step of await applicator(context)) {
|
|
119
|
+
steps.push({
|
|
120
|
+
...step,
|
|
121
|
+
managedPaths: normalizeManagedPaths(step.managedPaths),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return steps;
|
|
126
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { verifyAppliedInstall } from './install-verification.js';
|
|
4
|
+
import { upsertInstallLockfileEntry } from './lockfile.js';
|
|
5
|
+
export class InstallApplyError extends Error {
|
|
6
|
+
rollback;
|
|
7
|
+
constructor(error, rollback) {
|
|
8
|
+
const message = rollback.ok
|
|
9
|
+
? `install apply failed and rollback succeeded: ${renderError(error)}`
|
|
10
|
+
: `install apply failed and rollback had errors: ${renderError(error)}`;
|
|
11
|
+
super(message, { cause: error });
|
|
12
|
+
this.name = 'InstallApplyError';
|
|
13
|
+
this.rollback = rollback;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function renderError(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
function resolveInside(root, relativeInput) {
|
|
20
|
+
if (path.isAbsolute(relativeInput)) {
|
|
21
|
+
throw new Error(`path must be relative: ${relativeInput}`);
|
|
22
|
+
}
|
|
23
|
+
const target = path.resolve(root, relativeInput);
|
|
24
|
+
const relative = path.relative(root, target);
|
|
25
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
26
|
+
throw new Error(`path must stay inside ${root}: ${relativeInput}`);
|
|
27
|
+
}
|
|
28
|
+
return target;
|
|
29
|
+
}
|
|
30
|
+
async function pathExists(target) {
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(target);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function uniquePaths(paths) {
|
|
40
|
+
return [...new Set(paths)].sort((left, right) => left.localeCompare(right));
|
|
41
|
+
}
|
|
42
|
+
async function collectExistingParentDirectories(root, relativePath) {
|
|
43
|
+
const directories = new Set();
|
|
44
|
+
let current = path.dirname(relativePath);
|
|
45
|
+
while (current && current !== '.') {
|
|
46
|
+
if (await pathExists(resolveInside(root, current))) {
|
|
47
|
+
directories.add(current);
|
|
48
|
+
}
|
|
49
|
+
current = path.dirname(current);
|
|
50
|
+
}
|
|
51
|
+
return directories;
|
|
52
|
+
}
|
|
53
|
+
async function createSnapshot(targetWorkspace, backupRoot, relativePath) {
|
|
54
|
+
const targetPath = resolveInside(targetWorkspace, relativePath);
|
|
55
|
+
const backupPath = resolveInside(backupRoot, relativePath);
|
|
56
|
+
const existed = await pathExists(targetPath);
|
|
57
|
+
const existingParentDirectories = await collectExistingParentDirectories(targetWorkspace, relativePath);
|
|
58
|
+
if (existed) {
|
|
59
|
+
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
|
60
|
+
await fs.copyFile(targetPath, backupPath);
|
|
61
|
+
}
|
|
62
|
+
return { path: relativePath, existed, backupPath, existingParentDirectories };
|
|
63
|
+
}
|
|
64
|
+
async function createApplySnapshots(targetWorkspace, backupRoot, plannedChanges) {
|
|
65
|
+
const targetPaths = uniquePaths([
|
|
66
|
+
...plannedChanges.map((change) => change.path),
|
|
67
|
+
'.dearclaw/install-lock.json',
|
|
68
|
+
]);
|
|
69
|
+
const snapshots = [];
|
|
70
|
+
for (const targetPath of targetPaths) {
|
|
71
|
+
snapshots.push(await createSnapshot(targetWorkspace, backupRoot, targetPath));
|
|
72
|
+
}
|
|
73
|
+
return snapshots;
|
|
74
|
+
}
|
|
75
|
+
async function removeEmptyParents(root, relativePath, existingParentDirectories) {
|
|
76
|
+
let current = path.dirname(relativePath);
|
|
77
|
+
while (current && current !== '.') {
|
|
78
|
+
if (existingParentDirectories.has(current)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await fs.rmdir(resolveInside(root, current));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const code = error.code;
|
|
86
|
+
if (code !== 'ENOENT' && code !== 'ENOTEMPTY') {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
current = path.dirname(current);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function restoreSnapshot(targetWorkspace, snapshot) {
|
|
95
|
+
const targetPath = resolveInside(targetWorkspace, snapshot.path);
|
|
96
|
+
if (snapshot.existed) {
|
|
97
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
98
|
+
await fs.copyFile(snapshot.backupPath, targetPath);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
102
|
+
await removeEmptyParents(targetWorkspace, snapshot.path, snapshot.existingParentDirectories);
|
|
103
|
+
}
|
|
104
|
+
async function rollbackSnapshots(targetWorkspace, snapshots) {
|
|
105
|
+
const restoredPaths = [];
|
|
106
|
+
const errors = [];
|
|
107
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
108
|
+
try {
|
|
109
|
+
await restoreSnapshot(targetWorkspace, snapshot);
|
|
110
|
+
restoredPaths.push(snapshot.path);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
errors.push({ path: snapshot.path, error: renderError(error) });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { ok: errors.length === 0, restoredPaths, errors };
|
|
117
|
+
}
|
|
118
|
+
async function applyWorkspaceTransaction(transaction, plannedChanges, copyFile) {
|
|
119
|
+
for (const change of plannedChanges) {
|
|
120
|
+
const targetPath = resolveInside(transaction.targetWorkspace, change.path);
|
|
121
|
+
if (change.kind === 'delete') {
|
|
122
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const sourcePath = resolveInside(transaction.candidateWorkspace, change.path);
|
|
126
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
127
|
+
await copyFile(sourcePath, targetPath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function applyInstallWithRollback(options) {
|
|
131
|
+
const backupRoot = path.join(options.transaction.stagingPath, 'apply-backup');
|
|
132
|
+
const snapshots = await createApplySnapshots(options.transaction.targetWorkspace, backupRoot, options.plannedChanges);
|
|
133
|
+
const copyFile = options.applyCopyFile ?? fs.copyFile;
|
|
134
|
+
const writeLockfileEntry = options.writeLockfileEntry ?? upsertInstallLockfileEntry;
|
|
135
|
+
try {
|
|
136
|
+
await applyWorkspaceTransaction(options.transaction, options.plannedChanges, copyFile);
|
|
137
|
+
await writeLockfileEntry(options.transaction.targetWorkspace, options.lockfile, options.plannedLockfileEntry);
|
|
138
|
+
const verification = await verifyAppliedInstall({
|
|
139
|
+
targetWorkspace: options.transaction.targetWorkspace,
|
|
140
|
+
candidateWorkspace: options.transaction.candidateWorkspace,
|
|
141
|
+
plannedChanges: options.plannedChanges,
|
|
142
|
+
plannedLockfileEntry: options.plannedLockfileEntry,
|
|
143
|
+
});
|
|
144
|
+
if (!verification.ok) {
|
|
145
|
+
throw new Error(`post-apply verification failed: ${JSON.stringify(verification.issues)}`);
|
|
146
|
+
}
|
|
147
|
+
return { verification };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
const rollback = await rollbackSnapshots(options.transaction.targetWorkspace, snapshots);
|
|
151
|
+
throw new InstallApplyError(error, rollback);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await fs.rm(backupRoot, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function normalizeInstallPath(value) {
|
|
4
|
+
return value.replace(/\\/g, '/').split('/').filter(Boolean).join('/');
|
|
5
|
+
}
|
|
6
|
+
function pathsOverlap(left, right) {
|
|
7
|
+
if (!left || !right)
|
|
8
|
+
return false;
|
|
9
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
10
|
+
}
|
|
11
|
+
function uniqueSorted(values) {
|
|
12
|
+
return [...new Set(values.map(normalizeInstallPath).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
13
|
+
}
|
|
14
|
+
function resolveInside(root, relativeInput) {
|
|
15
|
+
if (path.isAbsolute(relativeInput)) {
|
|
16
|
+
throw new Error(`path must be relative: ${relativeInput}`);
|
|
17
|
+
}
|
|
18
|
+
const target = path.resolve(root, relativeInput);
|
|
19
|
+
const relative = path.relative(root, target);
|
|
20
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
21
|
+
throw new Error(`path must stay inside ${root}: ${relativeInput}`);
|
|
22
|
+
}
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
async function pathExists(target) {
|
|
26
|
+
try {
|
|
27
|
+
await fs.access(target);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function findManagedOwner(lockfile, targetPath) {
|
|
35
|
+
const normalizedTarget = normalizeInstallPath(targetPath);
|
|
36
|
+
for (const installed of lockfile.installed) {
|
|
37
|
+
for (const managedPath of installed.managedPaths) {
|
|
38
|
+
if (pathsOverlap(normalizeInstallPath(managedPath), normalizedTarget)) {
|
|
39
|
+
return { packageId: installed.packageId, managedPath: normalizeInstallPath(managedPath) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function isPathOwnedByPackage(lockfile, targetPath, packageId) {
|
|
46
|
+
return findManagedOwner(lockfile, targetPath)?.packageId === packageId;
|
|
47
|
+
}
|
|
48
|
+
export async function collectPlannedManagedPaths(validation, targetWorkspace, lockfile) {
|
|
49
|
+
const packageSummary = validation.package;
|
|
50
|
+
if (!validation.ok || !packageSummary)
|
|
51
|
+
return [];
|
|
52
|
+
const managedPaths = validation.skills.map((skill) => `skills/${skill.id}`);
|
|
53
|
+
for (const workspaceFile of validation.workspaceFiles) {
|
|
54
|
+
const targetPath = normalizeInstallPath(workspaceFile.target);
|
|
55
|
+
const targetExists = await pathExists(resolveInside(targetWorkspace, targetPath));
|
|
56
|
+
const ownedByPackage = isPathOwnedByPackage(lockfile, targetPath, packageSummary.id);
|
|
57
|
+
if (workspaceFile.mergePolicy === 'append_managed_block') {
|
|
58
|
+
managedPaths.push(targetPath);
|
|
59
|
+
}
|
|
60
|
+
else if (!targetExists || ownedByPackage) {
|
|
61
|
+
managedPaths.push(targetPath);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return uniqueSorted(managedPaths);
|
|
65
|
+
}
|
|
66
|
+
export async function buildPlannedLockfileEntry(source, targetWorkspace, lockfile) {
|
|
67
|
+
const packageSummary = source.validation.package;
|
|
68
|
+
if (!source.validation.ok || !packageSummary)
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
packageId: packageSummary.id,
|
|
72
|
+
packageType: packageSummary.type,
|
|
73
|
+
version: packageSummary.version,
|
|
74
|
+
manifestUrl: source.input,
|
|
75
|
+
sha256: source.sha256,
|
|
76
|
+
installedBy: 'dearharness',
|
|
77
|
+
managedPaths: await collectPlannedManagedPaths(source.validation, targetWorkspace, lockfile),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function addConflict(conflicts, seen, conflict) {
|
|
81
|
+
const key = JSON.stringify(conflict);
|
|
82
|
+
if (seen.has(key))
|
|
83
|
+
return;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
conflicts.push(conflict);
|
|
86
|
+
}
|
|
87
|
+
export async function detectInstallPlanConflicts(source, targetWorkspace, lockfile) {
|
|
88
|
+
const conflicts = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const error of lockfile.errors) {
|
|
91
|
+
addConflict(conflicts, seen, { kind: 'lockfile_invalid', path: lockfile.path, error });
|
|
92
|
+
}
|
|
93
|
+
const packageSummary = source.validation.package;
|
|
94
|
+
if (!source.validation.ok || !packageSummary) {
|
|
95
|
+
return conflicts;
|
|
96
|
+
}
|
|
97
|
+
for (const operation of source.validation.configOperations) {
|
|
98
|
+
if (operation.optional)
|
|
99
|
+
continue;
|
|
100
|
+
addConflict(conflicts, seen, {
|
|
101
|
+
kind: 'config_operation_not_applied',
|
|
102
|
+
op: operation.op,
|
|
103
|
+
...(operation.agentId ? { agentId: operation.agentId } : {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const pluginRequirement of source.validation.pluginRequirements) {
|
|
107
|
+
if (!pluginRequirement.required)
|
|
108
|
+
continue;
|
|
109
|
+
addConflict(conflicts, seen, {
|
|
110
|
+
kind: 'required_plugin_not_verified',
|
|
111
|
+
pluginId: pluginRequirement.id,
|
|
112
|
+
installPolicy: pluginRequirement.installPolicy,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
for (const plannedPath of await collectPlannedManagedPaths(source.validation, targetWorkspace, lockfile)) {
|
|
116
|
+
const owner = findManagedOwner(lockfile, plannedPath);
|
|
117
|
+
if (owner && owner.packageId !== packageSummary.id) {
|
|
118
|
+
addConflict(conflicts, seen, {
|
|
119
|
+
kind: 'target_managed_by_other_package',
|
|
120
|
+
path: plannedPath,
|
|
121
|
+
ownerPackageId: owner.packageId,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const skill of source.validation.skills) {
|
|
126
|
+
const targetPath = `skills/${skill.id}`;
|
|
127
|
+
const owner = findManagedOwner(lockfile, targetPath);
|
|
128
|
+
if (owner)
|
|
129
|
+
continue;
|
|
130
|
+
if (await pathExists(resolveInside(targetWorkspace, targetPath))) {
|
|
131
|
+
addConflict(conflicts, seen, { kind: 'target_exists_unmanaged', path: targetPath });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const workspaceFile of source.validation.workspaceFiles) {
|
|
135
|
+
if (workspaceFile.mergePolicy !== 'replace_managed_file')
|
|
136
|
+
continue;
|
|
137
|
+
const targetPath = normalizeInstallPath(workspaceFile.target);
|
|
138
|
+
const owner = findManagedOwner(lockfile, targetPath);
|
|
139
|
+
if (owner) {
|
|
140
|
+
if (owner.packageId !== packageSummary.id) {
|
|
141
|
+
addConflict(conflicts, seen, {
|
|
142
|
+
kind: 'target_managed_by_other_package',
|
|
143
|
+
path: targetPath,
|
|
144
|
+
ownerPackageId: owner.packageId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (await pathExists(resolveInside(targetWorkspace, targetPath))) {
|
|
150
|
+
addConflict(conflicts, seen, { kind: 'replace_managed_file_without_owner', path: targetPath });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return conflicts;
|
|
154
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { deterministicInstallApplicator, runInstallApplicators, } from './install-applicator.js';
|
|
4
|
+
import { applyInstallWithRollback } from './install-apply.js';
|
|
5
|
+
import { buildPlannedLockfileEntry, detectInstallPlanConflicts, findManagedOwner, } from './install-plan.js';
|
|
6
|
+
import { readInstallLockfile } from './lockfile.js';
|
|
7
|
+
import { materializeHarnessSource } from './source.js';
|
|
8
|
+
import { createWorkspaceTransaction, diffWorkspaceTransaction, } from './transaction.js';
|
|
9
|
+
function resolveInside(root, relativeInput) {
|
|
10
|
+
if (path.isAbsolute(relativeInput)) {
|
|
11
|
+
throw new Error(`path must be relative: ${relativeInput}`);
|
|
12
|
+
}
|
|
13
|
+
const target = path.resolve(root, relativeInput);
|
|
14
|
+
const relative = path.relative(root, target);
|
|
15
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
16
|
+
throw new Error(`path must stay inside ${root}: ${relativeInput}`);
|
|
17
|
+
}
|
|
18
|
+
return target;
|
|
19
|
+
}
|
|
20
|
+
async function pathExists(target) {
|
|
21
|
+
try {
|
|
22
|
+
await fs.access(target);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function normalizeManagedPath(value) {
|
|
30
|
+
if (value.includes('\0') || path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
31
|
+
throw new Error(`managed path must be relative: ${value}`);
|
|
32
|
+
}
|
|
33
|
+
const parts = value.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
34
|
+
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
|
|
35
|
+
throw new Error(`managed path must stay inside target workspace: ${value}`);
|
|
36
|
+
}
|
|
37
|
+
return parts.join('/');
|
|
38
|
+
}
|
|
39
|
+
function uniqueSortedManagedPaths(values) {
|
|
40
|
+
return [...new Set(values.map(normalizeManagedPath))].sort((left, right) => left.localeCompare(right));
|
|
41
|
+
}
|
|
42
|
+
function collectApplicatorManagedPaths(steps) {
|
|
43
|
+
return uniqueSortedManagedPaths(steps.flatMap((step) => step.managedPaths ?? []));
|
|
44
|
+
}
|
|
45
|
+
function isCoveredByBasePath(targetPath, basePaths) {
|
|
46
|
+
return basePaths.some((basePath) => targetPath === basePath || targetPath.startsWith(`${basePath}/`));
|
|
47
|
+
}
|
|
48
|
+
function isCoveredByManagedPath(targetPath, managedPaths) {
|
|
49
|
+
return managedPaths.some((managedPath) => targetPath === managedPath || targetPath.startsWith(`${managedPath}/`));
|
|
50
|
+
}
|
|
51
|
+
function mergeManagedPaths(entry, applicatorManagedPaths) {
|
|
52
|
+
return {
|
|
53
|
+
...entry,
|
|
54
|
+
managedPaths: uniqueSortedManagedPaths([...entry.managedPaths, ...applicatorManagedPaths]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function addConflict(conflicts, seen, conflict) {
|
|
58
|
+
const key = JSON.stringify(conflict);
|
|
59
|
+
if (seen.has(key))
|
|
60
|
+
return;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
conflicts.push(conflict);
|
|
63
|
+
}
|
|
64
|
+
async function detectApplicatorManagedPathConflicts(packageId, managedPaths, targetWorkspace, lockfile) {
|
|
65
|
+
const conflicts = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const managedPath of managedPaths) {
|
|
68
|
+
const owner = findManagedOwner(lockfile, managedPath);
|
|
69
|
+
if (owner) {
|
|
70
|
+
if (owner.packageId !== packageId) {
|
|
71
|
+
addConflict(conflicts, seen, {
|
|
72
|
+
kind: 'target_managed_by_other_package',
|
|
73
|
+
path: managedPath,
|
|
74
|
+
ownerPackageId: owner.packageId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (await pathExists(resolveInside(targetWorkspace, managedPath))) {
|
|
80
|
+
addConflict(conflicts, seen, { kind: 'target_exists_unmanaged', path: managedPath });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return conflicts;
|
|
84
|
+
}
|
|
85
|
+
function detectUnownedPlannedChangeConflicts(plannedChanges, plannedLockfileEntry) {
|
|
86
|
+
if (!plannedLockfileEntry)
|
|
87
|
+
return [];
|
|
88
|
+
const conflicts = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const change of plannedChanges) {
|
|
91
|
+
if (isCoveredByManagedPath(change.path, plannedLockfileEntry.managedPaths)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
addConflict(conflicts, seen, { kind: 'planned_change_without_ownership', path: change.path });
|
|
95
|
+
}
|
|
96
|
+
return conflicts;
|
|
97
|
+
}
|
|
98
|
+
function describePlannedConfigOperations(source) {
|
|
99
|
+
return source.validation.configOperations.map((operation) => ({
|
|
100
|
+
kind: 'openclaw_config_operation',
|
|
101
|
+
op: operation.op,
|
|
102
|
+
...(operation.agentId ? { agentId: operation.agentId } : {}),
|
|
103
|
+
optional: operation.optional,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
export async function executeHarnessInstall(options) {
|
|
107
|
+
const materialize = options.materializeHarnessSource ?? materializeHarnessSource;
|
|
108
|
+
const source = await materialize(options.source, { expectedSha256: options.expectedSha256 });
|
|
109
|
+
try {
|
|
110
|
+
const transaction = await createWorkspaceTransaction(options.targetWorkspace);
|
|
111
|
+
try {
|
|
112
|
+
const lockfile = await readInstallLockfile(transaction.targetWorkspace);
|
|
113
|
+
const baseConflicts = await detectInstallPlanConflicts(source, transaction.targetWorkspace, lockfile);
|
|
114
|
+
const applicators = [
|
|
115
|
+
deterministicInstallApplicator,
|
|
116
|
+
...(source.validation.ok ? options.additionalApplicators ?? [] : []),
|
|
117
|
+
];
|
|
118
|
+
const applicatorSteps = await runInstallApplicators({
|
|
119
|
+
source,
|
|
120
|
+
targetWorkspace: transaction.targetWorkspace,
|
|
121
|
+
candidateWorkspace: transaction.candidateWorkspace,
|
|
122
|
+
lockfile,
|
|
123
|
+
}, applicators);
|
|
124
|
+
const basePlannedLockfileEntry = await buildPlannedLockfileEntry(source, transaction.targetWorkspace, lockfile);
|
|
125
|
+
const applicatorManagedPaths = collectApplicatorManagedPaths(applicatorSteps);
|
|
126
|
+
const baseManagedPaths = basePlannedLockfileEntry?.managedPaths ?? [];
|
|
127
|
+
const additionalManagedPaths = applicatorManagedPaths.filter((managedPath) => !isCoveredByBasePath(managedPath, baseManagedPaths));
|
|
128
|
+
const packageId = source.validation.package?.id;
|
|
129
|
+
const applicatorConflicts = source.validation.ok && packageId
|
|
130
|
+
? await detectApplicatorManagedPathConflicts(packageId, additionalManagedPaths, transaction.targetWorkspace, lockfile)
|
|
131
|
+
: [];
|
|
132
|
+
const plannedChanges = await diffWorkspaceTransaction(transaction);
|
|
133
|
+
const plannedLockfileEntry = basePlannedLockfileEntry
|
|
134
|
+
? mergeManagedPaths(basePlannedLockfileEntry, applicatorManagedPaths)
|
|
135
|
+
: null;
|
|
136
|
+
const ownershipConflicts = detectUnownedPlannedChangeConflicts(plannedChanges, plannedLockfileEntry);
|
|
137
|
+
const conflicts = [...baseConflicts, ...applicatorConflicts, ...ownershipConflicts];
|
|
138
|
+
const canApply = source.validation.ok && conflicts.length === 0 && plannedLockfileEntry !== null;
|
|
139
|
+
let applied = false;
|
|
140
|
+
let verification = null;
|
|
141
|
+
if (options.mode === 'apply' && canApply && plannedLockfileEntry) {
|
|
142
|
+
const applyResult = await applyInstallWithRollback({
|
|
143
|
+
transaction,
|
|
144
|
+
plannedChanges,
|
|
145
|
+
lockfile,
|
|
146
|
+
plannedLockfileEntry,
|
|
147
|
+
});
|
|
148
|
+
verification = applyResult.verification;
|
|
149
|
+
applied = true;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
command: 'install',
|
|
153
|
+
dryRun: options.mode === 'dry_run',
|
|
154
|
+
applied,
|
|
155
|
+
targetWorkspace: transaction.targetWorkspace,
|
|
156
|
+
source: {
|
|
157
|
+
input: source.input,
|
|
158
|
+
kind: source.kind,
|
|
159
|
+
sha256: source.sha256,
|
|
160
|
+
},
|
|
161
|
+
validation: source.validation,
|
|
162
|
+
applicatorSteps,
|
|
163
|
+
plannedChanges,
|
|
164
|
+
plannedOperations: describePlannedConfigOperations(source),
|
|
165
|
+
conflicts,
|
|
166
|
+
canApply,
|
|
167
|
+
plannedLockfileEntry,
|
|
168
|
+
verification,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
await transaction.cleanup();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await source.cleanup();
|
|
177
|
+
}
|
|
178
|
+
}
|