@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.
- package/docs/ARCHITECTURE.md +118 -17
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +46 -10
- package/package.json +10 -4
- package/src/agentLoop.js +123 -21
- 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/browser/browserRuntime.js +88 -0
- package/src/browser/browserRuntime.ts +107 -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/cliMode.js +13 -1
- package/src/commands.js +37 -8
- package/src/config.d.ts +2 -0
- package/src/decisionPolicy.js +68 -0
- package/src/decisionPolicy.ts +106 -0
- package/src/hooks.js +121 -107
- package/src/hooks.ts +193 -0
- package/src/implementationPlan.js +50 -0
- package/src/implementationPlan.ts +59 -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 +473 -450
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +40 -5
- 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 +32 -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 +7 -5
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/registry.js +4 -3
- package/src/tools/requestDecision.js +87 -0
- package/src/tools/requestDecision.ts +107 -0
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/updatePlan.js +37 -84
- package/src/tools/updatePlan.ts +48 -0
- 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/tui/ask.js +41 -1
- package/src/tui/chatRenderer.js +28 -0
- package/src/tui/dialogs.js +91 -0
- package/src/tui/prompt.js +32 -3
- package/src/tui/toolDisplay.js +0 -1
- 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/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 };
|
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,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
|
+
}
|