@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.
- package/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/benchmarks/planCache.js +159 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +53 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
package/src/approvalGrants.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|