@causyne/agent 1.10.13 → 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 (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  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/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -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/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
@@ -0,0 +1,156 @@
1
+ const MAX_PROVIDER_BYTES = 2 * 1024 * 1024;
2
+ const MAX_RESULTS = 10;
3
+ const SEARCH_TIMEOUT_MS = 15_000;
4
+ function decodeEntities(value) {
5
+ const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
6
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (full, entity) => {
7
+ if (entity.startsWith('#x'))
8
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
9
+ if (entity.startsWith('#'))
10
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
11
+ return named[entity.toLowerCase()] ?? full;
12
+ });
13
+ }
14
+ function cleanMarkup(value) {
15
+ return decodeEntities(value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' '))
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ }
19
+ function publicResultUrl(value) {
20
+ try {
21
+ const parsed = new URL(decodeEntities(value));
22
+ if (!['http:', 'https:'].includes(parsed.protocol))
23
+ return null;
24
+ if (/(^|\.)(bing|duckduckgo)\.com$/i.test(parsed.hostname))
25
+ return null;
26
+ return parsed.href;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export function parseBingRss(xml) {
33
+ const results = [];
34
+ const seen = new Set();
35
+ for (const item of xml.matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)) {
36
+ const body = item[1] ?? '';
37
+ const title = cleanMarkup(body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '');
38
+ const url = publicResultUrl(cleanMarkup(body.match(/<link\b[^>]*>([\s\S]*?)<\/link>/i)?.[1] ?? ''));
39
+ const snippet = cleanMarkup(body.match(/<description\b[^>]*>([\s\S]*?)<\/description>/i)?.[1] ?? '');
40
+ if (!title || !url || seen.has(url))
41
+ continue;
42
+ seen.add(url);
43
+ results.push({ title: title.slice(0, 300), url, snippet: snippet.slice(0, 500), engine: 'bing-rss' });
44
+ if (results.length >= MAX_RESULTS)
45
+ break;
46
+ }
47
+ return results;
48
+ }
49
+ export function parseDuckDuckGoHtml(html) {
50
+ const results = [];
51
+ const seen = new Set();
52
+ const anchors = [...html.matchAll(/<a\b[^>]*class=["'][^"']*result__a[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)];
53
+ for (const [index, anchor] of anchors.entries()) {
54
+ let href = decodeEntities(anchor[1] ?? '');
55
+ try {
56
+ const candidate = new URL(href, 'https://html.duckduckgo.com/');
57
+ if (candidate.hostname.endsWith('duckduckgo.com') && candidate.pathname.startsWith('/l/')) {
58
+ href = candidate.searchParams.get('uddg') ?? href;
59
+ }
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ const url = publicResultUrl(href);
65
+ const title = cleanMarkup(anchor[2] ?? '');
66
+ const tail = html.slice(anchor.index ?? 0, anchors[index + 1]?.index ?? html.length);
67
+ const snippet = cleanMarkup(tail.match(/class=["'][^"']*result__snippet[^"']*["'][^>]*>([\s\S]*?)<\/(?:a|div)>/i)?.[1] ?? '');
68
+ if (!title || !url || seen.has(url))
69
+ continue;
70
+ seen.add(url);
71
+ results.push({ title: title.slice(0, 300), url, snippet: snippet.slice(0, 500), engine: 'duckduckgo-html' });
72
+ if (results.length >= MAX_RESULTS)
73
+ break;
74
+ }
75
+ return results;
76
+ }
77
+ async function readBoundedText(response) {
78
+ const declared = Number(response.headers.get('content-length') ?? '0');
79
+ if (Number.isFinite(declared) && declared > MAX_PROVIDER_BYTES)
80
+ throw new Error('search response is too large');
81
+ if (!response.body)
82
+ return '';
83
+ const reader = response.body.getReader();
84
+ const chunks = [];
85
+ let size = 0;
86
+ try {
87
+ while (true) {
88
+ const { done, value } = await reader.read();
89
+ if (done)
90
+ break;
91
+ size += value.byteLength;
92
+ if (size > MAX_PROVIDER_BYTES) {
93
+ await reader.cancel().catch(() => { });
94
+ throw new Error('search response is too large');
95
+ }
96
+ chunks.push(value);
97
+ }
98
+ }
99
+ finally {
100
+ reader.releaseLock();
101
+ }
102
+ return Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), size).toString('utf8');
103
+ }
104
+ export async function searchPublicWeb(query, options = {}) {
105
+ const cleaned = String(query ?? '').trim();
106
+ if (!cleaned || cleaned.length > 1_000)
107
+ throw new Error('Search query must contain 1-1000 characters');
108
+ const fetchImpl = options.fetchImpl ?? fetch;
109
+ const controller = new AbortController();
110
+ const timeout = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
111
+ const signal = options.signal ?? controller.signal;
112
+ const providers = [
113
+ {
114
+ engine: 'duckduckgo-html',
115
+ url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(cleaned)}`,
116
+ parse: parseDuckDuckGoHtml,
117
+ accept: 'text/html,application/xhtml+xml'
118
+ },
119
+ {
120
+ engine: 'bing-rss',
121
+ url: `https://www.bing.com/search?format=rss&q=${encodeURIComponent(cleaned)}`,
122
+ parse: parseBingRss,
123
+ accept: 'application/rss+xml,application/xml,text/xml'
124
+ }
125
+ ];
126
+ const failures = [];
127
+ try {
128
+ for (const provider of providers) {
129
+ try {
130
+ const response = await fetchImpl(provider.url, {
131
+ method: 'GET',
132
+ redirect: 'error',
133
+ signal,
134
+ headers: {
135
+ Accept: provider.accept,
136
+ 'Accept-Language': 'en-US,en;q=0.8',
137
+ 'User-Agent': 'Causyne-Agent/1.10 (+https://causyne.com)'
138
+ }
139
+ });
140
+ if (!response.ok)
141
+ throw new Error(`HTTP ${response.status}`);
142
+ const results = provider.parse(await readBoundedText(response));
143
+ if (results.length > 0)
144
+ return results;
145
+ failures.push(`${provider.engine}: no results`);
146
+ }
147
+ catch (error) {
148
+ failures.push(`${provider.engine}: ${error instanceof Error ? error.message : String(error)}`);
149
+ }
150
+ }
151
+ }
152
+ finally {
153
+ clearTimeout(timeout);
154
+ }
155
+ throw new Error(`Lightweight search providers exhausted. ${failures.join('; ')}`);
156
+ }
@@ -0,0 +1,156 @@
1
+ const MAX_PROVIDER_BYTES = 2 * 1024 * 1024;
2
+ const MAX_RESULTS = 10;
3
+ const SEARCH_TIMEOUT_MS = 15_000;
4
+
5
+ export interface SearchResult {
6
+ title: string;
7
+ url: string;
8
+ snippet: string;
9
+ engine: string;
10
+ }
11
+
12
+ interface SearchOptions {
13
+ fetchImpl?: typeof fetch;
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ function decodeEntities(value: string): string {
18
+ const named: Record<string, string> = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
19
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (full, entity: string) => {
20
+ if (entity.startsWith('#x')) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
21
+ if (entity.startsWith('#')) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
22
+ return named[entity.toLowerCase()] ?? full;
23
+ });
24
+ }
25
+
26
+ function cleanMarkup(value: string): string {
27
+ return decodeEntities(value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' '))
28
+ .replace(/\s+/g, ' ')
29
+ .trim();
30
+ }
31
+
32
+ function publicResultUrl(value: string): string | null {
33
+ try {
34
+ const parsed = new URL(decodeEntities(value));
35
+ if (!['http:', 'https:'].includes(parsed.protocol)) return null;
36
+ if (/(^|\.)(bing|duckduckgo)\.com$/i.test(parsed.hostname)) return null;
37
+ return parsed.href;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function parseBingRss(xml: string): SearchResult[] {
44
+ const results: SearchResult[] = [];
45
+ const seen = new Set<string>();
46
+ for (const item of xml.matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)) {
47
+ const body = item[1] ?? '';
48
+ const title = cleanMarkup(body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '');
49
+ const url = publicResultUrl(cleanMarkup(body.match(/<link\b[^>]*>([\s\S]*?)<\/link>/i)?.[1] ?? ''));
50
+ const snippet = cleanMarkup(body.match(/<description\b[^>]*>([\s\S]*?)<\/description>/i)?.[1] ?? '');
51
+ if (!title || !url || seen.has(url)) continue;
52
+ seen.add(url);
53
+ results.push({ title: title.slice(0, 300), url, snippet: snippet.slice(0, 500), engine: 'bing-rss' });
54
+ if (results.length >= MAX_RESULTS) break;
55
+ }
56
+ return results;
57
+ }
58
+
59
+ export function parseDuckDuckGoHtml(html: string): SearchResult[] {
60
+ const results: SearchResult[] = [];
61
+ const seen = new Set<string>();
62
+ const anchors = [...html.matchAll(/<a\b[^>]*class=["'][^"']*result__a[^"']*["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)];
63
+ for (const [index, anchor] of anchors.entries()) {
64
+ let href = decodeEntities(anchor[1] ?? '');
65
+ try {
66
+ const candidate = new URL(href, 'https://html.duckduckgo.com/');
67
+ if (candidate.hostname.endsWith('duckduckgo.com') && candidate.pathname.startsWith('/l/')) {
68
+ href = candidate.searchParams.get('uddg') ?? href;
69
+ }
70
+ } catch {
71
+ continue;
72
+ }
73
+ const url = publicResultUrl(href);
74
+ const title = cleanMarkup(anchor[2] ?? '');
75
+ const tail = html.slice(anchor.index ?? 0, anchors[index + 1]?.index ?? html.length);
76
+ const snippet = cleanMarkup(tail.match(/class=["'][^"']*result__snippet[^"']*["'][^>]*>([\s\S]*?)<\/(?:a|div)>/i)?.[1] ?? '');
77
+ if (!title || !url || seen.has(url)) continue;
78
+ seen.add(url);
79
+ results.push({ title: title.slice(0, 300), url, snippet: snippet.slice(0, 500), engine: 'duckduckgo-html' });
80
+ if (results.length >= MAX_RESULTS) break;
81
+ }
82
+ return results;
83
+ }
84
+
85
+ async function readBoundedText(response: Response): Promise<string> {
86
+ const declared = Number(response.headers.get('content-length') ?? '0');
87
+ if (Number.isFinite(declared) && declared > MAX_PROVIDER_BYTES) throw new Error('search response is too large');
88
+ if (!response.body) return '';
89
+ const reader = response.body.getReader();
90
+ const chunks: Uint8Array[] = [];
91
+ let size = 0;
92
+ try {
93
+ while (true) {
94
+ const { done, value } = await reader.read();
95
+ if (done) break;
96
+ size += value.byteLength;
97
+ if (size > MAX_PROVIDER_BYTES) {
98
+ await reader.cancel().catch(() => {});
99
+ throw new Error('search response is too large');
100
+ }
101
+ chunks.push(value);
102
+ }
103
+ } finally {
104
+ reader.releaseLock();
105
+ }
106
+ return Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), size).toString('utf8');
107
+ }
108
+
109
+ export async function searchPublicWeb(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
110
+ const cleaned = String(query ?? '').trim();
111
+ if (!cleaned || cleaned.length > 1_000) throw new Error('Search query must contain 1-1000 characters');
112
+ const fetchImpl = options.fetchImpl ?? fetch;
113
+ const controller = new AbortController();
114
+ const timeout = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
115
+ const signal = options.signal ?? controller.signal;
116
+ const providers = [
117
+ {
118
+ engine: 'duckduckgo-html',
119
+ url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(cleaned)}`,
120
+ parse: parseDuckDuckGoHtml,
121
+ accept: 'text/html,application/xhtml+xml'
122
+ },
123
+ {
124
+ engine: 'bing-rss',
125
+ url: `https://www.bing.com/search?format=rss&q=${encodeURIComponent(cleaned)}`,
126
+ parse: parseBingRss,
127
+ accept: 'application/rss+xml,application/xml,text/xml'
128
+ }
129
+ ];
130
+ const failures: string[] = [];
131
+ try {
132
+ for (const provider of providers) {
133
+ try {
134
+ const response = await fetchImpl(provider.url, {
135
+ method: 'GET',
136
+ redirect: 'error',
137
+ signal,
138
+ headers: {
139
+ Accept: provider.accept,
140
+ 'Accept-Language': 'en-US,en;q=0.8',
141
+ 'User-Agent': 'Causyne-Agent/1.10 (+https://causyne.com)'
142
+ }
143
+ });
144
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
145
+ const results = provider.parse(await readBoundedText(response));
146
+ if (results.length > 0) return results;
147
+ failures.push(`${provider.engine}: no results`);
148
+ } catch (error) {
149
+ failures.push(`${provider.engine}: ${error instanceof Error ? error.message : String(error)}`);
150
+ }
151
+ }
152
+ } finally {
153
+ clearTimeout(timeout);
154
+ }
155
+ throw new Error(`Lightweight search providers exhausted. ${failures.join('; ')}`);
156
+ }
@@ -0,0 +1,28 @@
1
+ // Adapted from camofox-browser's MIT-licensed snapshot windowing design.
2
+ // Copyright (c) 2025 Jo, Inc. See docs/THIRD_PARTY_NOTICES.md.
3
+ export const MAX_SNAPSHOT_CHARS = 80_000;
4
+ export const SNAPSHOT_TAIL_CHARS = 5_000;
5
+ export function windowSnapshot(snapshot, requestedOffset = 0) {
6
+ const text = String(snapshot ?? '');
7
+ const totalChars = text.length;
8
+ if (totalChars <= MAX_SNAPSHOT_CHARS) {
9
+ return { text, truncated: false, totalChars, offset: 0, hasMore: false, nextOffset: null };
10
+ }
11
+ const contentBudget = MAX_SNAPSHOT_CHARS - SNAPSHOT_TAIL_CHARS - 240;
12
+ const maximumOffset = Math.max(0, totalChars - SNAPSHOT_TAIL_CHARS);
13
+ const offset = Math.min(Math.max(0, Math.floor(requestedOffset)), maximumOffset);
14
+ const chunkEnd = Math.min(offset + contentBudget, maximumOffset);
15
+ const hasMore = chunkEnd < maximumOffset;
16
+ const tail = text.slice(-SNAPSHOT_TAIL_CHARS);
17
+ const marker = hasMore
18
+ ? `\n[Snapshot truncated at character ${chunkEnd} of ${totalChars}. Request offset ${chunkEnd} for the next window.]\n`
19
+ : '\n';
20
+ return {
21
+ text: text.slice(offset, chunkEnd) + marker + tail,
22
+ truncated: true,
23
+ totalChars,
24
+ offset,
25
+ hasMore,
26
+ nextOffset: hasMore ? chunkEnd : null
27
+ };
28
+ }
@@ -0,0 +1,40 @@
1
+ // Adapted from camofox-browser's MIT-licensed snapshot windowing design.
2
+ // Copyright (c) 2025 Jo, Inc. See docs/THIRD_PARTY_NOTICES.md.
3
+
4
+ export const MAX_SNAPSHOT_CHARS = 80_000;
5
+ export const SNAPSHOT_TAIL_CHARS = 5_000;
6
+
7
+ export interface SnapshotWindow {
8
+ text: string;
9
+ truncated: boolean;
10
+ totalChars: number;
11
+ offset: number;
12
+ hasMore: boolean;
13
+ nextOffset: number | null;
14
+ }
15
+
16
+ export function windowSnapshot(snapshot: string, requestedOffset = 0): SnapshotWindow {
17
+ const text = String(snapshot ?? '');
18
+ const totalChars = text.length;
19
+ if (totalChars <= MAX_SNAPSHOT_CHARS) {
20
+ return { text, truncated: false, totalChars, offset: 0, hasMore: false, nextOffset: null };
21
+ }
22
+
23
+ const contentBudget = MAX_SNAPSHOT_CHARS - SNAPSHOT_TAIL_CHARS - 240;
24
+ const maximumOffset = Math.max(0, totalChars - SNAPSHOT_TAIL_CHARS);
25
+ const offset = Math.min(Math.max(0, Math.floor(requestedOffset)), maximumOffset);
26
+ const chunkEnd = Math.min(offset + contentBudget, maximumOffset);
27
+ const hasMore = chunkEnd < maximumOffset;
28
+ const tail = text.slice(-SNAPSHOT_TAIL_CHARS);
29
+ const marker = hasMore
30
+ ? `\n[Snapshot truncated at character ${chunkEnd} of ${totalChars}. Request offset ${chunkEnd} for the next window.]\n`
31
+ : '\n';
32
+ return {
33
+ text: text.slice(offset, chunkEnd) + marker + tail,
34
+ truncated: true,
35
+ totalChars,
36
+ offset,
37
+ hasMore,
38
+ nextOffset: hasMore ? chunkEnd : null
39
+ };
40
+ }
@@ -0,0 +1,89 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+ const DNS_CACHE_MS = 5 * 60_000;
4
+ const hostCache = new Map();
5
+ function isPrivateIpv4(address) {
6
+ const parts = address.split('.').map(Number);
7
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255))
8
+ return true;
9
+ const [a = 0, b = 0, c = 0] = parts;
10
+ return a === 0
11
+ || a === 10
12
+ || a === 127
13
+ || (a === 169 && b === 254)
14
+ || (a === 172 && b >= 16 && b <= 31)
15
+ || (a === 192 && b === 168)
16
+ || (a === 192 && b === 0 && (c === 0 || c === 2))
17
+ || (a === 192 && b === 88 && c === 99)
18
+ || (a === 100 && b >= 64 && b <= 127)
19
+ || (a === 198 && (b === 18 || b === 19))
20
+ || (a === 198 && b === 51 && c === 100)
21
+ || (a === 203 && b === 0 && c === 113)
22
+ || a >= 224;
23
+ }
24
+ function isPrivateIpv6(address) {
25
+ const normalized = address.toLowerCase().split('%')[0] ?? '';
26
+ if (normalized === '::' || normalized === '::1')
27
+ return true;
28
+ if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')
29
+ || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')
30
+ || normalized.startsWith('fec') || normalized.startsWith('fed') || normalized.startsWith('fee')
31
+ || normalized.startsWith('fef') || normalized.startsWith('ff') || normalized.startsWith('2001:db8:')
32
+ || normalized === '2001:db8::' || normalized.startsWith('64:ff9b:'))
33
+ return true;
34
+ if (normalized.startsWith('::ffff:')) {
35
+ const mapped = normalized.slice('::ffff:'.length);
36
+ return isIP(mapped) === 4 ? isPrivateIpv4(mapped) : true;
37
+ }
38
+ return false;
39
+ }
40
+ export function isPrivateAddress(address) {
41
+ const version = isIP(address);
42
+ if (version === 4)
43
+ return isPrivateIpv4(address);
44
+ if (version === 6)
45
+ return isPrivateIpv6(address);
46
+ return true;
47
+ }
48
+ function blockedHostname(hostname) {
49
+ const host = hostname.toLowerCase().replace(/\.$/, '');
50
+ return host === 'localhost'
51
+ || host.endsWith('.localhost')
52
+ || host.endsWith('.local')
53
+ || host.endsWith('.internal')
54
+ || host === 'metadata.google.internal';
55
+ }
56
+ async function resolvedAddresses(hostname) {
57
+ const cached = hostCache.get(hostname);
58
+ if (cached && cached.expiresAt > Date.now())
59
+ return cached.addresses;
60
+ const results = await lookup(hostname, { all: true, verbatim: true });
61
+ const addresses = [...new Set(results.map(result => result.address))];
62
+ hostCache.set(hostname, { expiresAt: Date.now() + DNS_CACHE_MS, addresses });
63
+ return addresses;
64
+ }
65
+ export async function assertPublicWebUrl(value) {
66
+ let url;
67
+ try {
68
+ url = new URL(value);
69
+ }
70
+ catch {
71
+ throw new Error('URL must be absolute and valid');
72
+ }
73
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
74
+ throw new Error('Only http and https URLs are allowed');
75
+ }
76
+ if (url.username || url.password)
77
+ throw new Error('URLs containing credentials are not allowed');
78
+ if (blockedHostname(url.hostname))
79
+ throw new Error('Local and internal hostnames are not allowed');
80
+ const literalVersion = isIP(url.hostname);
81
+ const addresses = literalVersion ? [url.hostname] : await resolvedAddresses(url.hostname);
82
+ if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
83
+ throw new Error('Local, private, link-local, multicast, and reserved network addresses are not allowed');
84
+ }
85
+ return url;
86
+ }
87
+ export function clearUrlPolicyCache() {
88
+ hostCache.clear();
89
+ }
@@ -0,0 +1,89 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+
4
+ const DNS_CACHE_MS = 5 * 60_000;
5
+ const hostCache = new Map<string, { expiresAt: number; addresses: string[] }>();
6
+
7
+ function isPrivateIpv4(address: string): boolean {
8
+ const parts = address.split('.').map(Number);
9
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return true;
10
+ const [a = 0, b = 0, c = 0] = parts;
11
+ return a === 0
12
+ || a === 10
13
+ || a === 127
14
+ || (a === 169 && b === 254)
15
+ || (a === 172 && b >= 16 && b <= 31)
16
+ || (a === 192 && b === 168)
17
+ || (a === 192 && b === 0 && (c === 0 || c === 2))
18
+ || (a === 192 && b === 88 && c === 99)
19
+ || (a === 100 && b >= 64 && b <= 127)
20
+ || (a === 198 && (b === 18 || b === 19))
21
+ || (a === 198 && b === 51 && c === 100)
22
+ || (a === 203 && b === 0 && c === 113)
23
+ || a >= 224;
24
+ }
25
+
26
+ function isPrivateIpv6(address: string): boolean {
27
+ const normalized = address.toLowerCase().split('%')[0] ?? '';
28
+ if (normalized === '::' || normalized === '::1') return true;
29
+ if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')
30
+ || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')
31
+ || normalized.startsWith('fec') || normalized.startsWith('fed') || normalized.startsWith('fee')
32
+ || normalized.startsWith('fef') || normalized.startsWith('ff') || normalized.startsWith('2001:db8:')
33
+ || normalized === '2001:db8::' || normalized.startsWith('64:ff9b:')) return true;
34
+ if (normalized.startsWith('::ffff:')) {
35
+ const mapped = normalized.slice('::ffff:'.length);
36
+ return isIP(mapped) === 4 ? isPrivateIpv4(mapped) : true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function isPrivateAddress(address: string): boolean {
42
+ const version = isIP(address);
43
+ if (version === 4) return isPrivateIpv4(address);
44
+ if (version === 6) return isPrivateIpv6(address);
45
+ return true;
46
+ }
47
+
48
+ function blockedHostname(hostname: string): boolean {
49
+ const host = hostname.toLowerCase().replace(/\.$/, '');
50
+ return host === 'localhost'
51
+ || host.endsWith('.localhost')
52
+ || host.endsWith('.local')
53
+ || host.endsWith('.internal')
54
+ || host === 'metadata.google.internal';
55
+ }
56
+
57
+ async function resolvedAddresses(hostname: string): Promise<string[]> {
58
+ const cached = hostCache.get(hostname);
59
+ if (cached && cached.expiresAt > Date.now()) return cached.addresses;
60
+ const results = await lookup(hostname, { all: true, verbatim: true });
61
+ const addresses = [...new Set(results.map(result => result.address))];
62
+ hostCache.set(hostname, { expiresAt: Date.now() + DNS_CACHE_MS, addresses });
63
+ return addresses;
64
+ }
65
+
66
+ export async function assertPublicWebUrl(value: string): Promise<URL> {
67
+ let url: URL;
68
+ try {
69
+ url = new URL(value);
70
+ } catch {
71
+ throw new Error('URL must be absolute and valid');
72
+ }
73
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
74
+ throw new Error('Only http and https URLs are allowed');
75
+ }
76
+ if (url.username || url.password) throw new Error('URLs containing credentials are not allowed');
77
+ if (blockedHostname(url.hostname)) throw new Error('Local and internal hostnames are not allowed');
78
+
79
+ const literalVersion = isIP(url.hostname);
80
+ const addresses = literalVersion ? [url.hostname] : await resolvedAddresses(url.hostname);
81
+ if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
82
+ throw new Error('Local, private, link-local, multicast, and reserved network addresses are not allowed');
83
+ }
84
+ return url;
85
+ }
86
+
87
+ export function clearUrlPolicyCache(): void {
88
+ hostCache.clear();
89
+ }
@@ -7,7 +7,12 @@ import crypto from 'crypto';
7
7
  import * as gitBackend from './gitSnapshot.js';
8
8
  import { unifiedDiff, looksBinary } from './textDiff.js';
9
9
  import { causyneHome } from './utils/paths.js';
10
- import { getActiveSessionId, SESSION_DIR } from './sessionStore.js';
10
+ import {
11
+ getActiveSessionId,
12
+ restoreSessionFromSnapshot,
13
+ sessionTranscriptFile,
14
+ SESSION_DIR
15
+ } from './sessionStore.js';
11
16
 
12
17
  /**
13
18
  * Diagnostics, off by default.
@@ -57,7 +62,7 @@ function activeHistoryFile() {
57
62
  const id = getActiveSessionId();
58
63
  if (id) {
59
64
  const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
60
- if (safe) return path.join(SESSION_DIR, `${safe}.json`);
65
+ if (safe) return sessionTranscriptFile(safe);
61
66
  }
62
67
  } catch (e) { /* fall through to the legacy file */ }
63
68
  return HISTORY_FILE;
@@ -890,10 +895,18 @@ export function undoTask(taskId, options = {}) {
890
895
  history: { hash: writeObject(buffer), size: buffer.length, file: resolved }
891
896
  });
892
897
  }
893
- assertSafeTarget(resolved);
894
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
895
- fs.writeFileSync(resolved, readObject(start.history.hash), { mode: 0o600 });
896
- historyRestored = true;
898
+ const snapshot = readObject(start.history.hash);
899
+ if (path.extname(resolved) === '.jsonl') {
900
+ historyRestored = restoreSessionFromSnapshot(resolved, snapshot);
901
+ if (!historyRestored) throw new Error('conversation journal snapshot could not be projected');
902
+ } else {
903
+ // Older checkpoints still point at the legacy JSON snapshot. Keep that
904
+ // recovery path while new sessions remain append-only.
905
+ assertSafeTarget(resolved);
906
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
907
+ fs.writeFileSync(resolved, snapshot, { mode: 0o600 });
908
+ historyRestored = true;
909
+ }
897
910
  } catch (e) {
898
911
  results.push({ path: target, action: `FAILED: ${e.message}` });
899
912
  }