@causyne/agent 1.10.12 → 1.10.17

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.
Files changed (68) hide show
  1. package/LICENSE +1 -1
  2. package/docs/ARCHITECTURE.md +136 -7
  3. package/docs/SECURITY_REVIEW.md +13 -1
  4. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  5. package/package.json +11 -4
  6. package/src/agentLoop.js +161 -93
  7. package/src/agentProfiles.js +135 -0
  8. package/src/agentProfiles.ts +143 -0
  9. package/src/approvalGrants.js +17 -35
  10. package/src/approvalGrants.ts +54 -0
  11. package/src/auditLog.d.ts +3 -0
  12. package/src/benchmarks/planCache.js +159 -0
  13. package/src/browser/camofoxRuntime.js +93 -0
  14. package/src/browser/camofoxRuntime.ts +112 -0
  15. package/src/browser/causyneBrowser.js +495 -0
  16. package/src/browser/causyneBrowser.ts +594 -0
  17. package/src/browser/searchProviders.js +156 -0
  18. package/src/browser/searchProviders.ts +156 -0
  19. package/src/browser/snapshotWindow.js +28 -0
  20. package/src/browser/snapshotWindow.ts +40 -0
  21. package/src/browser/urlPolicy.js +89 -0
  22. package/src/browser/urlPolicy.ts +89 -0
  23. package/src/checkpoints.js +19 -6
  24. package/src/commands.js +120 -8
  25. package/src/config.d.ts +2 -0
  26. package/src/cortex/autoCondenser.js +1 -1
  27. package/src/hooks.js +150 -0
  28. package/src/hooks.ts +193 -0
  29. package/src/mcp/mcpClient.js +520 -132
  30. package/src/mcp/mcpClient.ts +584 -0
  31. package/src/mcp/mcpRegistry.js +399 -185
  32. package/src/mcp/mcpRegistry.ts +451 -0
  33. package/src/planCache.js +529 -225
  34. package/src/planCache.ts +840 -0
  35. package/src/plugins/pluginManager.js +460 -0
  36. package/src/plugins/pluginRunner.js +50 -0
  37. package/src/plugins/pluginSkills.js +84 -0
  38. package/src/plugins/pluginSkills.ts +106 -0
  39. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  40. package/src/provisioning/capabilityProvisioner.js +5 -0
  41. package/src/provisioning/capabilityTools.js +2 -1
  42. package/src/sandbox.js +53 -2
  43. package/src/sessionJournal.js +471 -0
  44. package/src/sessionJournal.ts +496 -0
  45. package/src/sessionStore.js +3 -279
  46. package/src/skillsManager.js +18 -0
  47. package/src/subagentJournal.js +215 -0
  48. package/src/subagentJournal.ts +254 -0
  49. package/src/taskShape.js +179 -198
  50. package/src/taskShape.ts +259 -0
  51. package/src/toolPacks.js +6 -3
  52. package/src/tools/browserAutomator.js +45 -241
  53. package/src/tools/browserAutomator.ts +57 -0
  54. package/src/tools/deferredMcpSearch.js +2 -2
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/pluginManager.js +61 -0
  57. package/src/tools/registry.js +47 -53
  58. package/src/tools/subagentOrchestrator.js +73 -12
  59. package/src/tools/toolForge.js +47 -95
  60. package/src/tools/webFetch.js +183 -68
  61. package/src/tools/webFetch.ts +185 -0
  62. package/src/tools/webSearch.js +50 -75
  63. package/src/tools/webSearch.ts +56 -0
  64. package/src/types/contracts.js +8 -0
  65. package/src/types/contracts.ts +263 -0
  66. package/src/utils/paths.d.ts +3 -0
  67. package/src/version.d.ts +1 -0
  68. package/src/mcp/jsonRpc.js +0 -165
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // THE SCOPE IS THE PROCESS, AND THAT IS THE WHOLE SAFETY MODEL.
10
10
  // This is a module-level Set. It is never written to config.json, never
11
-
11
+ // serialized, and never restored. When the process ends, every standing
12
12
  // approval is gone — the same contract opencode states to the user in its own
13
13
  // words: "This will allow … until OpenCode is restarted."
14
14
  //
@@ -17,50 +17,32 @@
17
17
  // a session the user has forgotten they configured. If that is ever wanted it
18
18
  // should be an explicit, listed, revocable config entry — not a quiet
19
19
  // side-effect of a keypress in a modal.
20
-
21
20
  /** Tool names approved for the rest of this process. Deliberately in memory. */
22
21
  const standingApprovals = new Set();
23
-
24
- /**
25
- * Record a standing approval for one tool.
26
- *
27
- * @param {string} toolName Canonical tool name, e.g. 'sysRun'.
28
- */
22
+ function normalizeToolName(toolName) {
23
+ return String(toolName || '').trim();
24
+ }
25
+ /** Record a standing approval for one canonical tool name. */
29
26
  export function grantAlways(toolName) {
30
- const name = String(toolName || '').trim();
31
- if (name) standingApprovals.add(name);
27
+ const name = normalizeToolName(toolName);
28
+ if (name)
29
+ standingApprovals.add(name);
32
30
  }
33
-
34
- /**
35
- * True when this tool may run without asking again.
36
- *
37
- * @param {string} toolName
38
- * @returns {boolean}
39
- */
31
+ /** True when this tool may run without asking again. */
40
32
  export function isAlwaysAllowed(toolName) {
41
- return standingApprovals.has(String(toolName || '').trim());
33
+ return standingApprovals.has(normalizeToolName(toolName));
42
34
  }
43
-
44
35
  /** Every standing approval, for `/approvals` and for the audit trail. */
45
36
  export function listAlwaysAllowed() {
46
- return [...standingApprovals].sort();
37
+ return [...standingApprovals].sort();
47
38
  }
48
-
49
- /**
50
- * Take them all back.
51
- *
52
- * A grant with no way out is a trap, so this is reachable from `/approvals
53
- * clear` as well as from a restart.
54
- *
55
- * @returns {number} how many were revoked
56
- */
39
+ /** Take every standing approval back and return how many were revoked. */
57
40
  export function revokeAllAlways() {
58
- const count = standingApprovals.size;
59
- standingApprovals.clear();
60
- return count;
41
+ const count = standingApprovals.size;
42
+ standingApprovals.clear();
43
+ return count;
61
44
  }
62
-
63
- /** Revoke one. @returns {boolean} true when there was something to revoke. */
45
+ /** Revoke one standing approval. */
64
46
  export function revokeAlways(toolName) {
65
- return standingApprovals.delete(String(toolName || '').trim());
47
+ return standingApprovals.delete(normalizeToolName(toolName));
66
48
  }
@@ -0,0 +1,54 @@
1
+ // "Allow always" — standing approvals, for the length of this process only.
2
+ //
3
+ // WHY THIS EXISTS. Being asked to approve the same tool for the twentieth time
4
+ // in one task does not make anyone safer; it makes them stop reading and start
5
+ // hammering the approve key, which is the exact failure the gate exists to
6
+ // prevent. A gate people are numb to is not a gate. So the honest answer is to
7
+ // let someone who has decided a tool is fine say so once, deliberately.
8
+ //
9
+ // THE SCOPE IS THE PROCESS, AND THAT IS THE WHOLE SAFETY MODEL.
10
+ // This is a module-level Set. It is never written to config.json, never
11
+ // serialized, and never restored. When the process ends, every standing
12
+ // approval is gone — the same contract opencode states to the user in its own
13
+ // words: "This will allow … until OpenCode is restarted."
14
+ //
15
+ // Persisting these to disk would be a different feature with a different risk
16
+ // profile: a permission granted in a hurry six weeks ago, silently applying to
17
+ // a session the user has forgotten they configured. If that is ever wanted it
18
+ // should be an explicit, listed, revocable config entry — not a quiet
19
+ // side-effect of a keypress in a modal.
20
+
21
+ /** Tool names approved for the rest of this process. Deliberately in memory. */
22
+ const standingApprovals = new Set<string>();
23
+
24
+ function normalizeToolName(toolName: unknown): string {
25
+ return String(toolName || '').trim();
26
+ }
27
+
28
+ /** Record a standing approval for one canonical tool name. */
29
+ export function grantAlways(toolName: unknown): void {
30
+ const name = normalizeToolName(toolName);
31
+ if (name) standingApprovals.add(name);
32
+ }
33
+
34
+ /** True when this tool may run without asking again. */
35
+ export function isAlwaysAllowed(toolName: unknown): boolean {
36
+ return standingApprovals.has(normalizeToolName(toolName));
37
+ }
38
+
39
+ /** Every standing approval, for `/approvals` and for the audit trail. */
40
+ export function listAlwaysAllowed(): string[] {
41
+ return [...standingApprovals].sort();
42
+ }
43
+
44
+ /** Take every standing approval back and return how many were revoked. */
45
+ export function revokeAllAlways(): number {
46
+ const count = standingApprovals.size;
47
+ standingApprovals.clear();
48
+ return count;
49
+ }
50
+
51
+ /** Revoke one standing approval. */
52
+ export function revokeAlways(toolName: unknown): boolean {
53
+ return standingApprovals.delete(normalizeToolName(toolName));
54
+ }
@@ -0,0 +1,3 @@
1
+ export const auditLog: {
2
+ log(event: Record<string, unknown>): void;
3
+ };
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+
8
+ // Never inspect or mutate a user's real plan cache. utils/paths.js resolves the
9
+ // state root at module initialization, so set it before importing planCache.js.
10
+ const benchmarkHome = fs.mkdtempSync(path.join(os.tmpdir(), 'causyne-plan-cache-bench-'));
11
+ process.env.CAUSYNE_HOME = benchmarkHome;
12
+
13
+ const {
14
+ forgetPlans,
15
+ lookupPlan,
16
+ planStats,
17
+ prewarmPlans
18
+ } = await import('../planCache.js');
19
+
20
+ const sizes = [1, 10, 20, 50, 100, 200];
21
+ const hitIterations = 500;
22
+ // Production deliberately cools down after a sustained low hit rate. Staying
23
+ // below that threshold measures a real miss scan instead of the cheap bypass.
24
+ const missIterations = 16;
25
+
26
+ function sample(index) {
27
+ const key = `taskkey${String(index).padStart(3, '0')}`;
28
+ return {
29
+ prompt: `benchmark workflow ${key} inspect validate artifact`,
30
+ archetype: 'benchmark',
31
+ steps: [
32
+ { tool: 'fs_search', action: 'inspect', ok: true },
33
+ { tool: 'sys_run', action: 'validate', ok: true }
34
+ ]
35
+ };
36
+ }
37
+
38
+ function percentile(values, fraction) {
39
+ const sorted = [...values].sort((left, right) => left - right);
40
+ if (sorted.length === 0) return 0;
41
+ return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))];
42
+ }
43
+
44
+ function measure(iterations, operation) {
45
+ const values = [];
46
+ for (let index = 0; index < iterations; index++) {
47
+ const startedAt = performance.now();
48
+ operation(index);
49
+ values.push(performance.now() - startedAt);
50
+ }
51
+ const total = values.reduce((sum, value) => sum + value, 0);
52
+ return {
53
+ iterations,
54
+ averageMs: total / values.length,
55
+ medianMs: percentile(values, 0.5),
56
+ p95Ms: percentile(values, 0.95),
57
+ maxMs: Math.max(...values)
58
+ };
59
+ }
60
+
61
+ function fixed(value) {
62
+ return Number(value.toFixed(4));
63
+ }
64
+
65
+ function compactMeasurement(measurement) {
66
+ return {
67
+ iterations: measurement.iterations,
68
+ averageMs: fixed(measurement.averageMs),
69
+ medianMs: fixed(measurement.medianMs),
70
+ p95Ms: fixed(measurement.p95Ms),
71
+ maxMs: fixed(measurement.maxMs)
72
+ };
73
+ }
74
+
75
+ function prewarmInBatches(seeds) {
76
+ const combined = { accepted: 0, rejected: 0, errors: [] };
77
+ for (let offset = 0; offset < seeds.length; offset += 100) {
78
+ const batch = prewarmPlans(seeds.slice(offset, offset + 100));
79
+ combined.accepted += batch.accepted;
80
+ combined.rejected += batch.rejected;
81
+ combined.errors.push(...batch.errors);
82
+ }
83
+ return combined;
84
+ }
85
+
86
+ const results = [];
87
+
88
+ try {
89
+ for (const size of sizes) {
90
+ forgetPlans();
91
+ const seeds = Array.from({ length: size }, (_, index) => sample(index));
92
+ const prewarmStartedAt = performance.now();
93
+ const prewarm = prewarmInBatches(seeds);
94
+ const prewarmMs = performance.now() - prewarmStartedAt;
95
+ const target = sample(Math.floor(size / 2));
96
+
97
+ // Warm filesystem and in-memory index before measuring lookup paths.
98
+ lookupPlan({ prompt: target.prompt, archetype: target.archetype, allowFuzzy: true });
99
+
100
+ const exact = measure(hitIterations, () => {
101
+ const match = lookupPlan({
102
+ prompt: target.prompt,
103
+ archetype: target.archetype,
104
+ allowFuzzy: true
105
+ });
106
+ if (!match || match.matchType !== 'exact') {
107
+ throw new Error(`Exact lookup failed at cache size ${size}`);
108
+ }
109
+ });
110
+
111
+ const fuzzyPrompt = target.prompt.replace('validate artifact', 'validate repaired artifact');
112
+ const fuzzy = measure(hitIterations, () => {
113
+ const match = lookupPlan({
114
+ prompt: fuzzyPrompt,
115
+ archetype: target.archetype,
116
+ allowFuzzy: true
117
+ });
118
+ if (!match || match.matchType !== 'fuzzy') {
119
+ throw new Error(`Fuzzy lookup failed at cache size ${size}`);
120
+ }
121
+ });
122
+
123
+ const miss = measure(missIterations, (index) => {
124
+ const match = lookupPlan({
125
+ prompt: `unrelated cold miss ${size} ${index} quantum orchard telescope`,
126
+ archetype: 'different-archetype',
127
+ allowFuzzy: true
128
+ });
129
+ if (match) throw new Error(`Unexpected cache hit at cache size ${size}`);
130
+ });
131
+
132
+ results.push({
133
+ cacheSize: size,
134
+ acceptedSeeds: prewarm.accepted,
135
+ prewarmMs: fixed(prewarmMs),
136
+ exact: compactMeasurement(exact),
137
+ fuzzy: compactMeasurement(fuzzy),
138
+ miss: compactMeasurement(miss),
139
+ telemetry: planStats()
140
+ });
141
+ }
142
+
143
+ const report = {
144
+ benchmark: 'Causyne Agent plan cache lookup',
145
+ generatedAt: new Date().toISOString(),
146
+ runtime: { node: process.version, platform: process.platform, arch: process.arch },
147
+ method: {
148
+ cacheSizes: sizes,
149
+ hitIterations,
150
+ missIterations,
151
+ note: 'Synthetic microbenchmark. It measures Causyne cache operations only; it does not reproduce paper task accuracy, model cost, or end-to-end agent latency.'
152
+ },
153
+ results
154
+ };
155
+
156
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
157
+ } finally {
158
+ fs.rmSync(benchmarkHome, { recursive: true, force: true });
159
+ }
@@ -0,0 +1,93 @@
1
+ import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function assertExecutable(candidate) {
5
+ const stat = statSync(candidate);
6
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
7
+ throw new Error(`Camoufox executable is not a file: ${candidate}`);
8
+ }
9
+ if (process.platform !== 'win32')
10
+ accessSync(candidate, constants.X_OK);
11
+ }
12
+ function candidateExecutables() {
13
+ const home = os.homedir();
14
+ if (process.platform === 'darwin') {
15
+ return [
16
+ path.join(home, 'Library', 'Caches', 'camoufox', 'Camoufox.app', 'Contents', 'MacOS', 'camoufox'),
17
+ '/Applications/Camoufox.app/Contents/MacOS/camoufox',
18
+ path.join(home, 'Applications', 'Camoufox.app', 'Contents', 'MacOS', 'camoufox')
19
+ ];
20
+ }
21
+ if (process.platform === 'win32') {
22
+ const localAppData = process.env.LOCALAPPDATA;
23
+ const programFiles = process.env.ProgramFiles;
24
+ return [
25
+ ...(localAppData ? [path.join(localAppData, 'camoufox', 'camoufox.exe')] : []),
26
+ ...(localAppData ? [path.join(localAppData, 'Camoufox', 'camoufox.exe')] : []),
27
+ ...(programFiles ? [path.join(programFiles, 'Camoufox', 'camoufox.exe')] : [])
28
+ ];
29
+ }
30
+ return [
31
+ path.join(home, '.cache', 'camoufox', 'camoufox-bin'),
32
+ '/opt/camoufox/camoufox-bin',
33
+ path.join(home, '.local', 'share', 'camoufox', 'camoufox-bin'),
34
+ '/usr/local/lib/camoufox/camoufox-bin'
35
+ ];
36
+ }
37
+ function expectedExecutable(installDirectory) {
38
+ if (process.platform === 'darwin') {
39
+ return path.join(installDirectory, 'Camoufox.app', 'Contents', 'MacOS', 'camoufox');
40
+ }
41
+ if (process.platform === 'win32')
42
+ return path.join(installDirectory, 'camoufox.exe');
43
+ return path.join(installDirectory, 'camoufox-bin');
44
+ }
45
+ function findInstallDirectory(executablePath) {
46
+ let current = path.dirname(executablePath);
47
+ for (let depth = 0; depth < 7; depth += 1) {
48
+ if (existsSync(path.join(current, 'version.json')) && existsSync(expectedExecutable(current))) {
49
+ return current;
50
+ }
51
+ const parent = path.dirname(current);
52
+ if (parent === current)
53
+ break;
54
+ current = parent;
55
+ }
56
+ return null;
57
+ }
58
+ function resourceDirectoryFor(executablePath) {
59
+ if (process.platform === 'darwin') {
60
+ return path.resolve(path.dirname(executablePath), '..', 'Resources');
61
+ }
62
+ return path.dirname(executablePath);
63
+ }
64
+ export function resolveCamoufoxRuntime() {
65
+ const configured = process.env.CAUSYNE_CAMOFOX_EXECUTABLE?.trim();
66
+ const configuredHome = process.env.CAMOUFOX_INSTALL_DIR?.trim();
67
+ const candidates = configured
68
+ ? [path.resolve(configured)]
69
+ : configuredHome
70
+ ? [expectedExecutable(path.resolve(configuredHome))]
71
+ : candidateExecutables();
72
+ const executablePath = candidates.find(existsSync);
73
+ if (!executablePath) {
74
+ throw new Error('Camoufox is not installed. Causyne does not download browser binaries automatically. ' +
75
+ 'Install a trusted Camoufox build separately, then set CAUSYNE_CAMOFOX_EXECUTABLE to its executable path.');
76
+ }
77
+ assertExecutable(executablePath);
78
+ const resolvedExecutable = realpathSync(executablePath);
79
+ const installDirectory = findInstallDirectory(resolvedExecutable);
80
+ if (!installDirectory) {
81
+ throw new Error(`Camoufox at ${resolvedExecutable} is not inside a complete distribution. ` +
82
+ 'Keep the distribution version.json beside its platform bundle and point CAUSYNE_CAMOFOX_EXECUTABLE at that bundle.');
83
+ }
84
+ const resourceDirectory = resourceDirectoryFor(resolvedExecutable);
85
+ if (!existsSync(path.join(resourceDirectory, 'properties.json'))) {
86
+ throw new Error(`Camoufox properties.json was not found in ${resourceDirectory}.`);
87
+ }
88
+ return {
89
+ installDirectory,
90
+ resourceDirectory,
91
+ source: configured || configuredHome ? 'environment' : 'system'
92
+ };
93
+ }
@@ -0,0 +1,112 @@
1
+ import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // Adapted from Jo Inc's MIT-licensed Camofox Browser external executable
6
+ // discovery at commit 2c0036a8b18bf9cd983376751ff4d52dfd9add0f. Causyne
7
+ // keeps this integration layer in TypeScript and never downloads a browser.
8
+
9
+ export interface PreparedCamoufoxRuntime {
10
+ installDirectory: string;
11
+ resourceDirectory: string;
12
+ source: 'environment' | 'system';
13
+ }
14
+
15
+ function assertExecutable(candidate: string): void {
16
+ const stat = statSync(candidate);
17
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
18
+ throw new Error(`Camoufox executable is not a file: ${candidate}`);
19
+ }
20
+ if (process.platform !== 'win32') accessSync(candidate, constants.X_OK);
21
+ }
22
+
23
+ function candidateExecutables(): string[] {
24
+ const home = os.homedir();
25
+ if (process.platform === 'darwin') {
26
+ return [
27
+ path.join(home, 'Library', 'Caches', 'camoufox', 'Camoufox.app', 'Contents', 'MacOS', 'camoufox'),
28
+ '/Applications/Camoufox.app/Contents/MacOS/camoufox',
29
+ path.join(home, 'Applications', 'Camoufox.app', 'Contents', 'MacOS', 'camoufox')
30
+ ];
31
+ }
32
+ if (process.platform === 'win32') {
33
+ const localAppData = process.env.LOCALAPPDATA;
34
+ const programFiles = process.env.ProgramFiles;
35
+ return [
36
+ ...(localAppData ? [path.join(localAppData, 'camoufox', 'camoufox.exe')] : []),
37
+ ...(localAppData ? [path.join(localAppData, 'Camoufox', 'camoufox.exe')] : []),
38
+ ...(programFiles ? [path.join(programFiles, 'Camoufox', 'camoufox.exe')] : [])
39
+ ];
40
+ }
41
+ return [
42
+ path.join(home, '.cache', 'camoufox', 'camoufox-bin'),
43
+ '/opt/camoufox/camoufox-bin',
44
+ path.join(home, '.local', 'share', 'camoufox', 'camoufox-bin'),
45
+ '/usr/local/lib/camoufox/camoufox-bin'
46
+ ];
47
+ }
48
+
49
+ function expectedExecutable(installDirectory: string): string {
50
+ if (process.platform === 'darwin') {
51
+ return path.join(installDirectory, 'Camoufox.app', 'Contents', 'MacOS', 'camoufox');
52
+ }
53
+ if (process.platform === 'win32') return path.join(installDirectory, 'camoufox.exe');
54
+ return path.join(installDirectory, 'camoufox-bin');
55
+ }
56
+
57
+ function findInstallDirectory(executablePath: string): string | null {
58
+ let current = path.dirname(executablePath);
59
+ for (let depth = 0; depth < 7; depth += 1) {
60
+ if (existsSync(path.join(current, 'version.json')) && existsSync(expectedExecutable(current))) {
61
+ return current;
62
+ }
63
+ const parent = path.dirname(current);
64
+ if (parent === current) break;
65
+ current = parent;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function resourceDirectoryFor(executablePath: string): string {
71
+ if (process.platform === 'darwin') {
72
+ return path.resolve(path.dirname(executablePath), '..', 'Resources');
73
+ }
74
+ return path.dirname(executablePath);
75
+ }
76
+
77
+ export function resolveCamoufoxRuntime(): PreparedCamoufoxRuntime {
78
+ const configured = process.env.CAUSYNE_CAMOFOX_EXECUTABLE?.trim();
79
+ const configuredHome = process.env.CAMOUFOX_INSTALL_DIR?.trim();
80
+ const candidates = configured
81
+ ? [path.resolve(configured)]
82
+ : configuredHome
83
+ ? [expectedExecutable(path.resolve(configuredHome))]
84
+ : candidateExecutables();
85
+ const executablePath = candidates.find(existsSync);
86
+ if (!executablePath) {
87
+ throw new Error(
88
+ 'Camoufox is not installed. Causyne does not download browser binaries automatically. ' +
89
+ 'Install a trusted Camoufox build separately, then set CAUSYNE_CAMOFOX_EXECUTABLE to its executable path.'
90
+ );
91
+ }
92
+
93
+ assertExecutable(executablePath);
94
+ const resolvedExecutable = realpathSync(executablePath);
95
+ const installDirectory = findInstallDirectory(resolvedExecutable);
96
+ if (!installDirectory) {
97
+ throw new Error(
98
+ `Camoufox at ${resolvedExecutable} is not inside a complete distribution. ` +
99
+ 'Keep the distribution version.json beside its platform bundle and point CAUSYNE_CAMOFOX_EXECUTABLE at that bundle.'
100
+ );
101
+ }
102
+ const resourceDirectory = resourceDirectoryFor(resolvedExecutable);
103
+ if (!existsSync(path.join(resourceDirectory, 'properties.json'))) {
104
+ throw new Error(`Camoufox properties.json was not found in ${resourceDirectory}.`);
105
+ }
106
+
107
+ return {
108
+ installDirectory,
109
+ resourceDirectory,
110
+ source: configured || configuredHome ? 'environment' : 'system'
111
+ };
112
+ }