@causyne/agent 1.10.13 → 1.10.18

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 (76) hide show
  1. package/docs/ARCHITECTURE.md +118 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +46 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +123 -21
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/browserRuntime.js +88 -0
  12. package/src/browser/browserRuntime.ts +107 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/cliMode.js +13 -1
  23. package/src/commands.js +37 -8
  24. package/src/config.d.ts +2 -0
  25. package/src/decisionPolicy.js +68 -0
  26. package/src/decisionPolicy.ts +106 -0
  27. package/src/hooks.js +121 -107
  28. package/src/hooks.ts +193 -0
  29. package/src/implementationPlan.js +50 -0
  30. package/src/implementationPlan.ts +59 -0
  31. package/src/mcp/mcpClient.js +520 -132
  32. package/src/mcp/mcpClient.ts +584 -0
  33. package/src/mcp/mcpRegistry.js +399 -185
  34. package/src/mcp/mcpRegistry.ts +451 -0
  35. package/src/planCache.js +473 -450
  36. package/src/planCache.ts +840 -0
  37. package/src/plugins/pluginManager.js +40 -5
  38. package/src/plugins/pluginSkills.js +84 -0
  39. package/src/plugins/pluginSkills.ts +106 -0
  40. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  41. package/src/provisioning/capabilityProvisioner.js +5 -0
  42. package/src/provisioning/capabilityTools.js +2 -1
  43. package/src/sandbox.js +32 -2
  44. package/src/sessionJournal.js +471 -0
  45. package/src/sessionJournal.ts +496 -0
  46. package/src/sessionStore.js +3 -279
  47. package/src/skillsManager.js +18 -0
  48. package/src/subagentJournal.js +215 -0
  49. package/src/subagentJournal.ts +254 -0
  50. package/src/taskShape.js +179 -198
  51. package/src/taskShape.ts +259 -0
  52. package/src/toolPacks.js +7 -5
  53. package/src/tools/browserAutomator.js +45 -241
  54. package/src/tools/browserAutomator.ts +57 -0
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/registry.js +4 -3
  57. package/src/tools/requestDecision.js +87 -0
  58. package/src/tools/requestDecision.ts +107 -0
  59. package/src/tools/subagentOrchestrator.js +73 -12
  60. package/src/tools/updatePlan.js +37 -84
  61. package/src/tools/updatePlan.ts +48 -0
  62. package/src/tools/webFetch.js +183 -68
  63. package/src/tools/webFetch.ts +185 -0
  64. package/src/tools/webSearch.js +50 -75
  65. package/src/tools/webSearch.ts +56 -0
  66. package/src/tui/ask.js +41 -1
  67. package/src/tui/chatRenderer.js +28 -0
  68. package/src/tui/dialogs.js +91 -0
  69. package/src/tui/prompt.js +32 -3
  70. package/src/tui/toolDisplay.js +0 -1
  71. package/src/types/contracts.js +8 -0
  72. package/src/types/contracts.ts +263 -0
  73. package/src/utils/paths.d.ts +3 -0
  74. package/src/version.d.ts +1 -0
  75. package/src/mcp/jsonRpc.js +0 -165
  76. package/src/tools/todoWrite.js +0 -75
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { causyneHome } from './utils/paths.js';
4
+ const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
5
+ const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
6
+ const MAX_PROFILES = 64;
7
+ const MAX_PROFILE_BYTES = 24 * 1024;
8
+ const MAX_INSTRUCTIONS = 16 * 1024;
9
+ const MAX_TOOLS = 64;
10
+ export function agentProfilesRoot() {
11
+ return path.join(causyneHome(), 'agents');
12
+ }
13
+ function cleanLine(value, maximum) {
14
+ return String(value ?? '')
15
+ // eslint-disable-next-line no-control-regex
16
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
17
+ .replace(/\s+/g, ' ')
18
+ .trim()
19
+ .slice(0, maximum);
20
+ }
21
+ function cleanInstructions(value) {
22
+ return String(value ?? '')
23
+ // Preserve newlines and tabs while excluding prompt-control bytes.
24
+ // eslint-disable-next-line no-control-regex
25
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
26
+ .trim();
27
+ }
28
+ function boundedInteger(value, fallback, minimum, maximum) {
29
+ if (value === undefined)
30
+ return fallback;
31
+ if (!Number.isInteger(value))
32
+ return null;
33
+ return Math.max(minimum, Math.min(Number(value), maximum));
34
+ }
35
+ function parseProfile(file, expectedName) {
36
+ try {
37
+ const stat = fs.lstatSync(file);
38
+ if (!stat.isFile() || stat.isSymbolicLink())
39
+ return { ok: false, error: 'profile must be a regular file' };
40
+ if (stat.size > MAX_PROFILE_BYTES)
41
+ return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
42
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
43
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
44
+ return { ok: false, error: 'profile must be a JSON object' };
45
+ }
46
+ const value = raw;
47
+ if (value.schemaVersion !== 1)
48
+ return { ok: false, error: 'schemaVersion must be 1' };
49
+ if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
50
+ return { ok: false, error: 'profile name must match its lowercase file name' };
51
+ }
52
+ const description = cleanLine(value.description, 400);
53
+ if (!description)
54
+ return { ok: false, error: 'description is required' };
55
+ const instructions = cleanInstructions(value.instructions);
56
+ if (!instructions)
57
+ return { ok: false, error: 'instructions are required' };
58
+ if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
59
+ return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
60
+ }
61
+ if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
62
+ return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
63
+ }
64
+ const toolScope = [];
65
+ for (const rawTool of value.toolScope) {
66
+ if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
67
+ return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
68
+ }
69
+ const normalized = rawTool.replace(/\./g, '_');
70
+ if (!toolScope.includes(normalized))
71
+ toolScope.push(normalized);
72
+ }
73
+ const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
74
+ if (maxSteps === null)
75
+ return { ok: false, error: 'maxSteps must be an integer' };
76
+ const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
77
+ if (maxTokens === null)
78
+ return { ok: false, error: 'maxTokens must be an integer' };
79
+ return {
80
+ ok: true,
81
+ profile: {
82
+ schemaVersion: 1,
83
+ name: expectedName,
84
+ description,
85
+ instructions,
86
+ toolScope,
87
+ maxSteps,
88
+ maxTokens,
89
+ path: file
90
+ }
91
+ };
92
+ }
93
+ catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ return { ok: false, error: message };
96
+ }
97
+ }
98
+ export function loadAgentProfile(name) {
99
+ const id = String(name ?? '');
100
+ if (!PROFILE_ID.test(id))
101
+ return { ok: false, error: 'invalid profile name' };
102
+ return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
103
+ }
104
+ export function listAgentProfiles() {
105
+ const profiles = [];
106
+ const problems = [];
107
+ const root = agentProfilesRoot();
108
+ try {
109
+ if (!fs.existsSync(root))
110
+ return { profiles, problems };
111
+ const entries = fs.readdirSync(root, { withFileTypes: true })
112
+ .filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
113
+ .slice(0, MAX_PROFILES + 1);
114
+ if (entries.length > MAX_PROFILES)
115
+ problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
116
+ for (const entry of entries.slice(0, MAX_PROFILES)) {
117
+ const name = entry.name.slice(0, -5);
118
+ if (!entry.isFile() || !PROFILE_ID.test(name)) {
119
+ problems.push(`${entry.name}: invalid profile file`);
120
+ continue;
121
+ }
122
+ const loaded = parseProfile(path.join(root, entry.name), name);
123
+ if (loaded.ok)
124
+ profiles.push(loaded.profile);
125
+ else
126
+ problems.push(`${entry.name}: ${loaded.error}`);
127
+ }
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ problems.push(`could not read profiles: ${message}`);
132
+ }
133
+ return { profiles, problems };
134
+ }
135
+ export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { causyneHome } from './utils/paths.js';
4
+
5
+ const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
7
+ const MAX_PROFILES = 64;
8
+ const MAX_PROFILE_BYTES = 24 * 1024;
9
+ const MAX_INSTRUCTIONS = 16 * 1024;
10
+ const MAX_TOOLS = 64;
11
+
12
+ export interface AgentProfile {
13
+ schemaVersion: 1;
14
+ name: string;
15
+ description: string;
16
+ instructions: string;
17
+ toolScope: string[];
18
+ maxSteps: number;
19
+ maxTokens: number;
20
+ path: string;
21
+ }
22
+
23
+ export type AgentProfileResult =
24
+ | { ok: true; profile: AgentProfile }
25
+ | { ok: false; error: string };
26
+
27
+ export function agentProfilesRoot(): string {
28
+ return path.join(causyneHome(), 'agents');
29
+ }
30
+
31
+ function cleanLine(value: unknown, maximum: number): string {
32
+ return String(value ?? '')
33
+ // eslint-disable-next-line no-control-regex
34
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
35
+ .replace(/\s+/g, ' ')
36
+ .trim()
37
+ .slice(0, maximum);
38
+ }
39
+
40
+ function cleanInstructions(value: unknown): string {
41
+ return String(value ?? '')
42
+ // Preserve newlines and tabs while excluding prompt-control bytes.
43
+ // eslint-disable-next-line no-control-regex
44
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
45
+ .trim();
46
+ }
47
+
48
+ function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number | null {
49
+ if (value === undefined) return fallback;
50
+ if (!Number.isInteger(value)) return null;
51
+ return Math.max(minimum, Math.min(Number(value), maximum));
52
+ }
53
+
54
+ function parseProfile(file: string, expectedName: string): AgentProfileResult {
55
+ try {
56
+ const stat = fs.lstatSync(file);
57
+ if (!stat.isFile() || stat.isSymbolicLink()) return { ok: false, error: 'profile must be a regular file' };
58
+ if (stat.size > MAX_PROFILE_BYTES) return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
59
+ const raw: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
61
+ return { ok: false, error: 'profile must be a JSON object' };
62
+ }
63
+ const value = raw as Record<string, unknown>;
64
+ if (value.schemaVersion !== 1) return { ok: false, error: 'schemaVersion must be 1' };
65
+ if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
66
+ return { ok: false, error: 'profile name must match its lowercase file name' };
67
+ }
68
+ const description = cleanLine(value.description, 400);
69
+ if (!description) return { ok: false, error: 'description is required' };
70
+ const instructions = cleanInstructions(value.instructions);
71
+ if (!instructions) return { ok: false, error: 'instructions are required' };
72
+ if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
73
+ return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
74
+ }
75
+ if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
76
+ return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
77
+ }
78
+ const toolScope: string[] = [];
79
+ for (const rawTool of value.toolScope) {
80
+ if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
81
+ return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
82
+ }
83
+ const normalized = rawTool.replace(/\./g, '_');
84
+ if (!toolScope.includes(normalized)) toolScope.push(normalized);
85
+ }
86
+ const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
87
+ if (maxSteps === null) return { ok: false, error: 'maxSteps must be an integer' };
88
+ const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
89
+ if (maxTokens === null) return { ok: false, error: 'maxTokens must be an integer' };
90
+
91
+ return {
92
+ ok: true,
93
+ profile: {
94
+ schemaVersion: 1,
95
+ name: expectedName,
96
+ description,
97
+ instructions,
98
+ toolScope,
99
+ maxSteps,
100
+ maxTokens,
101
+ path: file
102
+ }
103
+ };
104
+ } catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ return { ok: false, error: message };
107
+ }
108
+ }
109
+
110
+ export function loadAgentProfile(name: unknown): AgentProfileResult {
111
+ const id = String(name ?? '');
112
+ if (!PROFILE_ID.test(id)) return { ok: false, error: 'invalid profile name' };
113
+ return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
114
+ }
115
+
116
+ export function listAgentProfiles(): { profiles: AgentProfile[]; problems: string[] } {
117
+ const profiles: AgentProfile[] = [];
118
+ const problems: string[] = [];
119
+ const root = agentProfilesRoot();
120
+ try {
121
+ if (!fs.existsSync(root)) return { profiles, problems };
122
+ const entries = fs.readdirSync(root, { withFileTypes: true })
123
+ .filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
124
+ .slice(0, MAX_PROFILES + 1);
125
+ if (entries.length > MAX_PROFILES) problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
126
+ for (const entry of entries.slice(0, MAX_PROFILES)) {
127
+ const name = entry.name.slice(0, -5);
128
+ if (!entry.isFile() || !PROFILE_ID.test(name)) {
129
+ problems.push(`${entry.name}: invalid profile file`);
130
+ continue;
131
+ }
132
+ const loaded = parseProfile(path.join(root, entry.name), name);
133
+ if (loaded.ok) profiles.push(loaded.profile);
134
+ else problems.push(`${entry.name}: ${loaded.error}`);
135
+ }
136
+ } catch (error) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ problems.push(`could not read profiles: ${message}`);
139
+ }
140
+ return { profiles, problems };
141
+ }
142
+
143
+ export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };
@@ -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,88 @@
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(`Causyne Browser 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 resolveBrowserRuntime() {
65
+ const configured = process.env.CAUSYNE_BROWSER_EXECUTABLE?.trim();
66
+ const candidates = configured ? [path.resolve(configured)] : candidateExecutables();
67
+ const executablePath = candidates.find(existsSync);
68
+ if (!executablePath) {
69
+ throw new Error('Causyne Browser runtime is not installed. Causyne does not download browser binaries automatically. ' +
70
+ 'Install a supported browser runtime separately, then set CAUSYNE_BROWSER_EXECUTABLE to its executable path.');
71
+ }
72
+ assertExecutable(executablePath);
73
+ const resolvedExecutable = realpathSync(executablePath);
74
+ const installDirectory = findInstallDirectory(resolvedExecutable);
75
+ if (!installDirectory) {
76
+ throw new Error(`Causyne Browser runtime at ${resolvedExecutable} is not a complete distribution. ` +
77
+ 'Keep version.json beside its platform bundle and point CAUSYNE_BROWSER_EXECUTABLE at that bundle.');
78
+ }
79
+ const resourceDirectory = resourceDirectoryFor(resolvedExecutable);
80
+ if (!existsSync(path.join(resourceDirectory, 'properties.json'))) {
81
+ throw new Error(`Causyne Browser runtime metadata was not found in ${resourceDirectory}.`);
82
+ }
83
+ return {
84
+ installDirectory,
85
+ resourceDirectory,
86
+ source: configured ? 'environment' : 'system'
87
+ };
88
+ }
@@ -0,0 +1,107 @@
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 PreparedBrowserRuntime {
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(`Causyne Browser 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 resolveBrowserRuntime(): PreparedBrowserRuntime {
78
+ const configured = process.env.CAUSYNE_BROWSER_EXECUTABLE?.trim();
79
+ const candidates = configured ? [path.resolve(configured)] : candidateExecutables();
80
+ const executablePath = candidates.find(existsSync);
81
+ if (!executablePath) {
82
+ throw new Error(
83
+ 'Causyne Browser runtime is not installed. Causyne does not download browser binaries automatically. ' +
84
+ 'Install a supported browser runtime separately, then set CAUSYNE_BROWSER_EXECUTABLE to its executable path.'
85
+ );
86
+ }
87
+
88
+ assertExecutable(executablePath);
89
+ const resolvedExecutable = realpathSync(executablePath);
90
+ const installDirectory = findInstallDirectory(resolvedExecutable);
91
+ if (!installDirectory) {
92
+ throw new Error(
93
+ `Causyne Browser runtime at ${resolvedExecutable} is not a complete distribution. ` +
94
+ 'Keep version.json beside its platform bundle and point CAUSYNE_BROWSER_EXECUTABLE at that bundle.'
95
+ );
96
+ }
97
+ const resourceDirectory = resourceDirectoryFor(resolvedExecutable);
98
+ if (!existsSync(path.join(resourceDirectory, 'properties.json'))) {
99
+ throw new Error(`Causyne Browser runtime metadata was not found in ${resourceDirectory}.`);
100
+ }
101
+
102
+ return {
103
+ installDirectory,
104
+ resourceDirectory,
105
+ source: configured ? 'environment' : 'system'
106
+ };
107
+ }