surf-cli 2.8.0 → 2.10.0
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/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const net = require("net");
|
|
2
|
+
|
|
3
|
+
function parseListenEndpoint(value) {
|
|
4
|
+
const v6 = typeof value === "string" && value.match(/^\[([^\]]+)\]:(\d+)$/);
|
|
5
|
+
const v4 = typeof value === "string" && value.match(/^([^:]+):(\d+)$/);
|
|
6
|
+
const host = v6 ? v6[1] : v4?.[1];
|
|
7
|
+
const port = Number(v6 ? v6[2] : v4?.[2]);
|
|
8
|
+
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("SURF_LISTEN must be a Tailnet IP and port 1..65535");
|
|
9
|
+
if (v6) {
|
|
10
|
+
if (net.isIP(host) !== 6) throw new Error("SURF_LISTEN must use a Tailscale IPv6 address");
|
|
11
|
+
const canonical = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
|
12
|
+
if (!canonical.startsWith("fd7a:115c:a1e0:")) throw new Error("SURF_LISTEN must use a Tailscale IPv6 address");
|
|
13
|
+
return { host: canonical, port, display: `[${canonical}]:${port}` };
|
|
14
|
+
}
|
|
15
|
+
const parts = host.split(".").map(Number);
|
|
16
|
+
if (net.isIP(host) !== 4 || parts[0] !== 100 || parts[1] < 64 || parts[1] > 127) throw new Error("SURF_LISTEN must use a Tailscale IPv4 address");
|
|
17
|
+
return { host, port, display: `${host}:${port}` };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { parseListenEndpoint };
|
package/native/mcp-server.cjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const net = require("net");
|
|
3
2
|
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
4
3
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
4
|
const { z } = require("zod");
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
const
|
|
5
|
+
const { formatSocketError } = require("./socket-path.cjs");
|
|
6
|
+
const { selectEndpoint } = require("./endpoint.cjs");
|
|
7
|
+
const { openClientTransport } = require("./client-transport.cjs");
|
|
8
|
+
const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
|
|
9
|
+
const { prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
|
|
9
10
|
|
|
10
11
|
const TOOL_SCHEMAS = {
|
|
11
12
|
navigate: {
|
|
@@ -263,63 +264,56 @@ const TOOL_SCHEMAS = {
|
|
|
263
264
|
query: z.string().describe("Question about the page"),
|
|
264
265
|
mode: z.enum(["find", "summary", "extract"]).optional().describe("Query mode")
|
|
265
266
|
}
|
|
267
|
+
},
|
|
268
|
+
chatgpt: {
|
|
269
|
+
desc: "Ask ChatGPT through the browser session",
|
|
270
|
+
schema: {
|
|
271
|
+
query: z.string().describe("Question or prompt"),
|
|
272
|
+
model: z.string().optional().describe("ChatGPT model"),
|
|
273
|
+
"with-page": z.boolean().optional().describe("Include current page context"),
|
|
274
|
+
file: z.string().optional().describe("One attachment path"),
|
|
275
|
+
timeout: z.number().optional().describe("Timeout in seconds")
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
gemini: {
|
|
279
|
+
desc: "Ask Gemini or generate/edit one image",
|
|
280
|
+
schema: {
|
|
281
|
+
query: z.string().optional().describe("Question or image prompt"),
|
|
282
|
+
model: z.string().optional().describe("Gemini model"),
|
|
283
|
+
"with-page": z.boolean().optional().describe("Include current page context"),
|
|
284
|
+
file: z.string().optional().describe("One attachment path"),
|
|
285
|
+
"edit-image": z.string().optional().describe("One image input path"),
|
|
286
|
+
"generate-image": z.string().optional().describe("One generated image output path"),
|
|
287
|
+
output: z.string().optional().describe("Edited image output path"),
|
|
288
|
+
youtube: z.string().optional().describe("YouTube URL"),
|
|
289
|
+
"aspect-ratio": z.string().optional().describe("Image aspect ratio"),
|
|
290
|
+
timeout: z.number().optional().describe("Timeout in seconds")
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"network.export": {
|
|
294
|
+
desc: "Export captured network requests",
|
|
295
|
+
schema: {
|
|
296
|
+
output: z.string().optional().describe("Output file path"),
|
|
297
|
+
jsonl: z.boolean().optional().describe("Write JSONL"),
|
|
298
|
+
har: z.boolean().optional().describe("Write HAR 1.2")
|
|
299
|
+
}
|
|
266
300
|
}
|
|
267
301
|
};
|
|
268
302
|
|
|
269
|
-
function sendSocketRequest(tool, args = {}) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const timeout = setTimeout(() => {
|
|
284
|
-
settled = true;
|
|
285
|
-
sock.destroy();
|
|
286
|
-
reject(new Error("Request timeout"));
|
|
287
|
-
}, REQUEST_TIMEOUT);
|
|
288
|
-
|
|
289
|
-
sock.on("data", (d) => {
|
|
290
|
-
buf += d.toString();
|
|
291
|
-
const lines = buf.split("\n");
|
|
292
|
-
buf = lines.pop();
|
|
293
|
-
for (const line of lines) {
|
|
294
|
-
if (!line.trim()) continue;
|
|
295
|
-
try {
|
|
296
|
-
settled = true;
|
|
297
|
-
clearTimeout(timeout);
|
|
298
|
-
const resp = JSON.parse(line);
|
|
299
|
-
sock.end();
|
|
300
|
-
resolve(resp);
|
|
301
|
-
} catch {
|
|
302
|
-
settled = true;
|
|
303
|
-
clearTimeout(timeout);
|
|
304
|
-
sock.end();
|
|
305
|
-
reject(new Error("Invalid JSON response"));
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
sock.on("error", (e) => {
|
|
311
|
-
settled = true;
|
|
312
|
-
clearTimeout(timeout);
|
|
313
|
-
reject(new Error(formatSocketError(e)));
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
sock.on("close", () => {
|
|
317
|
-
clearTimeout(timeout);
|
|
318
|
-
if (!settled) {
|
|
319
|
-
reject(new Error(`Socket closed unexpectedly\nAttempted socket: ${SOCKET_PATH}`));
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
});
|
|
303
|
+
async function sendSocketRequest(tool, args = {}, endpoint = selectEndpoint([]).endpoint) {
|
|
304
|
+
const requestTimeoutMs = resolveRequestDeadlineMs(tool, args);
|
|
305
|
+
const transport = await openClientTransport(endpoint, { requestTimeoutMs });
|
|
306
|
+
try {
|
|
307
|
+
const prepared = endpoint.kind === "remote" ? prepareRemoteTool(tool, args) : (() => { const normalized = validateLocalToolPaths(tool, args); return { args: normalized, uploads: [], downloads: [] }; })();
|
|
308
|
+
return await transport.request({
|
|
309
|
+
type: "tool_request",
|
|
310
|
+
method: "execute_tool",
|
|
311
|
+
params: { tool, args: prepared.args },
|
|
312
|
+
id: "mcp-" + Date.now() + "-" + Math.random(),
|
|
313
|
+
}, requestTimeoutMs, prepared);
|
|
314
|
+
} finally {
|
|
315
|
+
await transport.close();
|
|
316
|
+
}
|
|
323
317
|
}
|
|
324
318
|
|
|
325
319
|
function formatResult(resp) {
|
|
@@ -351,7 +345,8 @@ function formatResult(resp) {
|
|
|
351
345
|
}
|
|
352
346
|
|
|
353
347
|
class PiChromeMcpServer {
|
|
354
|
-
constructor() {
|
|
348
|
+
constructor(endpoint = selectEndpoint([]).endpoint) {
|
|
349
|
+
this.endpoint = endpoint;
|
|
355
350
|
this.server = new McpServer({
|
|
356
351
|
name: "surf",
|
|
357
352
|
version: "1.0.0"
|
|
@@ -373,7 +368,7 @@ class PiChromeMcpServer {
|
|
|
373
368
|
schemaObj,
|
|
374
369
|
async (args) => {
|
|
375
370
|
try {
|
|
376
|
-
const resp = await sendSocketRequest(name, args);
|
|
371
|
+
const resp = await sendSocketRequest(name, args, this.endpoint);
|
|
377
372
|
return formatResult(resp);
|
|
378
373
|
} catch (err) {
|
|
379
374
|
return {
|
|
@@ -392,7 +387,7 @@ class PiChromeMcpServer {
|
|
|
392
387
|
"page://current",
|
|
393
388
|
async (uri) => {
|
|
394
389
|
try {
|
|
395
|
-
const resp = await sendSocketRequest("page.read", {});
|
|
390
|
+
const resp = await sendSocketRequest("page.read", {}, this.endpoint);
|
|
396
391
|
const text = resp.result?.content?.[0]?.text || "No content";
|
|
397
392
|
return {
|
|
398
393
|
contents: [{
|
|
@@ -418,7 +413,7 @@ class PiChromeMcpServer {
|
|
|
418
413
|
"tabs://list",
|
|
419
414
|
async (uri) => {
|
|
420
415
|
try {
|
|
421
|
-
const resp = await sendSocketRequest("tab.list", {});
|
|
416
|
+
const resp = await sendSocketRequest("tab.list", {}, this.endpoint);
|
|
422
417
|
const text = resp.result?.content?.[0]?.text || "[]";
|
|
423
418
|
return {
|
|
424
419
|
contents: [{
|
|
@@ -444,7 +439,7 @@ class PiChromeMcpServer {
|
|
|
444
439
|
"console://messages",
|
|
445
440
|
async (uri) => {
|
|
446
441
|
try {
|
|
447
|
-
const resp = await sendSocketRequest("console", {});
|
|
442
|
+
const resp = await sendSocketRequest("console", {}, this.endpoint);
|
|
448
443
|
const text = resp.result?.content?.[0]?.text || "No messages";
|
|
449
444
|
return {
|
|
450
445
|
contents: [{
|
|
@@ -470,7 +465,7 @@ class PiChromeMcpServer {
|
|
|
470
465
|
"network://requests",
|
|
471
466
|
async (uri) => {
|
|
472
467
|
try {
|
|
473
|
-
const resp = await sendSocketRequest("network", {});
|
|
468
|
+
const resp = await sendSocketRequest("network", {}, this.endpoint);
|
|
474
469
|
const text = resp.result?.content?.[0]?.text || "No requests";
|
|
475
470
|
return {
|
|
476
471
|
contents: [{
|
|
@@ -511,4 +506,4 @@ if (require.main === module) {
|
|
|
511
506
|
});
|
|
512
507
|
}
|
|
513
508
|
|
|
514
|
-
module.exports = { PiChromeMcpServer };
|
|
509
|
+
module.exports = { PiChromeMcpServer, TOOL_SCHEMAS };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { version: PACKAGE_VERSION } = require("../package.json");
|
|
4
|
+
const { atomicWriteFile } = require("./private-state.cjs");
|
|
5
|
+
|
|
6
|
+
const MAX_NETWORK_EXPORT_FILE_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
const INTERNAL_FIELDS = new Set(["_requestId", "_responseReceived", "_loadingFinished"]);
|
|
8
|
+
|
|
9
|
+
function publicEntry(entry) {
|
|
10
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
11
|
+
throw new Error("network export entries must be objects");
|
|
12
|
+
}
|
|
13
|
+
const result = Object.create(null);
|
|
14
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
15
|
+
if (!INTERNAL_FIELDS.has(key)) result[key] = value;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function headerList(headers) {
|
|
21
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return [];
|
|
22
|
+
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function headerValue(headers, name) {
|
|
26
|
+
if (!headers || typeof headers !== "object") return "";
|
|
27
|
+
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
|
|
28
|
+
return match ? String(match[1]) : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function queryList(url) {
|
|
32
|
+
try {
|
|
33
|
+
return [...new URL(url).searchParams.entries()].map(([name, value]) => ({ name, value }));
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function harEntry(entry) {
|
|
40
|
+
const requestBody = entry.requestBody;
|
|
41
|
+
const responseBody = entry.responseBody;
|
|
42
|
+
const requestHeaders = entry.requestHeaders;
|
|
43
|
+
const responseHeaders = entry.responseHeaders;
|
|
44
|
+
const duration = Number.isFinite(entry.duration) ? Math.max(0, entry.duration) : 0;
|
|
45
|
+
const ttfb = Number.isFinite(entry.ttfb) ? Math.max(0, entry.ttfb) : duration;
|
|
46
|
+
return {
|
|
47
|
+
startedDateTime: new Date(Number(entry.ts) || Date.now()).toISOString(),
|
|
48
|
+
time: duration,
|
|
49
|
+
request: {
|
|
50
|
+
method: entry.method || "GET",
|
|
51
|
+
url: entry.url || "",
|
|
52
|
+
httpVersion: "HTTP/1.1",
|
|
53
|
+
headers: headerList(requestHeaders),
|
|
54
|
+
queryString: queryList(entry.url || ""),
|
|
55
|
+
cookies: [],
|
|
56
|
+
headersSize: -1,
|
|
57
|
+
bodySize: Number.isFinite(entry.requestBodySize) ? entry.requestBodySize : requestBody ? Buffer.byteLength(String(requestBody)) : -1,
|
|
58
|
+
...(requestBody !== undefined ? { postData: { mimeType: headerValue(requestHeaders, "content-type") || "application/octet-stream", text: String(requestBody) } } : {}),
|
|
59
|
+
},
|
|
60
|
+
response: {
|
|
61
|
+
status: Number.isFinite(entry.status) ? entry.status : 0,
|
|
62
|
+
statusText: entry.statusText || "",
|
|
63
|
+
httpVersion: "HTTP/1.1",
|
|
64
|
+
headers: headerList(responseHeaders),
|
|
65
|
+
cookies: [],
|
|
66
|
+
content: {
|
|
67
|
+
size: Number.isFinite(entry.responseBodySize) ? entry.responseBodySize : responseBody ? Buffer.byteLength(String(responseBody)) : 0,
|
|
68
|
+
mimeType: entry.mimeType || "",
|
|
69
|
+
...(responseBody !== undefined ? { text: String(responseBody) } : {}),
|
|
70
|
+
...(entry.responseBodyEncoding === "base64" ? { encoding: "base64" } : {}),
|
|
71
|
+
_surfBodyCapture: entry.bodyCapture || { mode: "none", complete: responseBody === undefined ? false : true },
|
|
72
|
+
},
|
|
73
|
+
redirectURL: "",
|
|
74
|
+
headersSize: -1,
|
|
75
|
+
bodySize: Number.isFinite(entry.responseBodySize) ? entry.responseBodySize : responseBody ? Buffer.byteLength(String(responseBody)) : -1,
|
|
76
|
+
},
|
|
77
|
+
cache: {},
|
|
78
|
+
timings: { send: 0, wait: ttfb, receive: Math.max(0, duration - ttfb) },
|
|
79
|
+
...(entry.comment ? { comment: String(entry.comment) } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function serializeNetworkExport(entries, format = "json") {
|
|
84
|
+
if (!Array.isArray(entries)) throw new Error("network export entries must be an array");
|
|
85
|
+
if (format !== "json" && format !== "jsonl" && format !== "har") throw new Error(`unsupported network export format: ${format}`);
|
|
86
|
+
const publicEntries = entries.map(publicEntry);
|
|
87
|
+
if (format === "jsonl") return `${publicEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
88
|
+
if (format === "har") {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
log: {
|
|
91
|
+
version: "1.2",
|
|
92
|
+
creator: { name: "surf-cli", version: PACKAGE_VERSION },
|
|
93
|
+
entries: publicEntries.map(harEntry),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return JSON.stringify(publicEntries, null, 2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeNetworkExport(outputPath, entries, format = "json") {
|
|
101
|
+
if (typeof outputPath !== "string" || !path.isAbsolute(outputPath)) throw new Error("network export output must be an absolute path");
|
|
102
|
+
const content = serializeNetworkExport(entries, format);
|
|
103
|
+
const bytes = Buffer.byteLength(content);
|
|
104
|
+
if (bytes > MAX_NETWORK_EXPORT_FILE_BYTES) throw new Error("network export exceeds the 256 MiB file limit");
|
|
105
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
106
|
+
atomicWriteFile(outputPath, content, { encoding: "utf8" });
|
|
107
|
+
return { path: outputPath, format, count: entries.length, bytes };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
INTERNAL_FIELDS,
|
|
112
|
+
MAX_NETWORK_EXPORT_FILE_BYTES,
|
|
113
|
+
publicEntry,
|
|
114
|
+
serializeNetworkExport,
|
|
115
|
+
writeNetworkExport,
|
|
116
|
+
};
|
package/native/network-store.cjs
CHANGED
|
@@ -11,11 +11,17 @@ const fs = require("fs");
|
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const crypto = require("crypto");
|
|
13
13
|
const readline = require("readline");
|
|
14
|
+
const {
|
|
15
|
+
appendPrivateJsonLine,
|
|
16
|
+
assertNotSymlink,
|
|
17
|
+
atomicWriteFile,
|
|
18
|
+
atomicWriteJson,
|
|
19
|
+
ensurePrivateDir,
|
|
20
|
+
privateStatePath,
|
|
21
|
+
} = require("./private-state.cjs");
|
|
14
22
|
|
|
15
23
|
// Configuration
|
|
16
|
-
const DEFAULT_BASE =
|
|
17
|
-
? require("path").join(require("os").tmpdir(), "surf")
|
|
18
|
-
: "/tmp/surf";
|
|
24
|
+
const DEFAULT_BASE = privateStatePath("network");
|
|
19
25
|
const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
20
26
|
const DEFAULT_MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
|
21
27
|
const AUTO_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
|
|
@@ -23,36 +29,26 @@ const AUTO_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
|
|
|
23
29
|
// Lock file for concurrent access
|
|
24
30
|
let writeLock = Promise.resolve();
|
|
25
31
|
|
|
26
|
-
// Runtime override for base path (set via CLI --network-path)
|
|
27
|
-
let runtimeBasePath = null;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Set base path at runtime (from CLI --network-path flag)
|
|
31
|
-
*/
|
|
32
|
-
function setBasePath(newPath) {
|
|
33
|
-
runtimeBasePath = newPath;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
32
|
/**
|
|
37
33
|
* Get base path for network storage
|
|
38
|
-
* Priority:
|
|
34
|
+
* Priority: SURF_NETWORK_PATH env var > default
|
|
39
35
|
*/
|
|
40
|
-
function getBasePath() {
|
|
41
|
-
return
|
|
36
|
+
function getBasePath(basePath) {
|
|
37
|
+
return basePath || process.env.SURF_NETWORK_PATH || DEFAULT_BASE;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/**
|
|
45
41
|
* Get path to requests.jsonl
|
|
46
42
|
*/
|
|
47
|
-
function getRequestsPath() {
|
|
48
|
-
return path.join(getBasePath(), "requests.jsonl");
|
|
43
|
+
function getRequestsPath(basePath) {
|
|
44
|
+
return path.join(getBasePath(basePath), "requests.jsonl");
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
/**
|
|
52
48
|
* Get path to bodies directory
|
|
53
49
|
*/
|
|
54
|
-
function getBodiesPath() {
|
|
55
|
-
return path.join(getBasePath(), "bodies");
|
|
50
|
+
function getBodiesPath(basePath) {
|
|
51
|
+
return path.join(getBasePath(basePath), "bodies");
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
/**
|
|
@@ -65,16 +61,11 @@ function getMetaPath() {
|
|
|
65
61
|
/**
|
|
66
62
|
* Ensure all required directories exist
|
|
67
63
|
*/
|
|
68
|
-
function ensureDirectories() {
|
|
69
|
-
const base = getBasePath();
|
|
70
|
-
const bodies = getBodiesPath();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
fs.mkdirSync(base, { recursive: true });
|
|
74
|
-
}
|
|
75
|
-
if (!fs.existsSync(bodies)) {
|
|
76
|
-
fs.mkdirSync(bodies, { recursive: true });
|
|
77
|
-
}
|
|
64
|
+
function ensureDirectories(basePath) {
|
|
65
|
+
const base = getBasePath(basePath);
|
|
66
|
+
const bodies = getBodiesPath(basePath);
|
|
67
|
+
ensurePrivateDir(base, base);
|
|
68
|
+
ensurePrivateDir(bodies, base);
|
|
78
69
|
}
|
|
79
70
|
|
|
80
71
|
/**
|
|
@@ -84,6 +75,7 @@ function readMeta() {
|
|
|
84
75
|
const metaPath = getMetaPath();
|
|
85
76
|
try {
|
|
86
77
|
if (fs.existsSync(metaPath)) {
|
|
78
|
+
assertNotSymlink(metaPath, false);
|
|
87
79
|
return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
|
88
80
|
}
|
|
89
81
|
} catch (err) {
|
|
@@ -98,7 +90,7 @@ function readMeta() {
|
|
|
98
90
|
function writeMeta(meta) {
|
|
99
91
|
const metaPath = getMetaPath();
|
|
100
92
|
ensureDirectories();
|
|
101
|
-
|
|
93
|
+
atomicWriteJson(metaPath, meta, { root: getBasePath() });
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
/**
|
|
@@ -124,7 +116,7 @@ function storeBody(content, isRequest = false) {
|
|
|
124
116
|
|
|
125
117
|
// Only write if doesn't exist (dedup)
|
|
126
118
|
if (!fs.existsSync(bodyPath)) {
|
|
127
|
-
|
|
119
|
+
atomicWriteFile(bodyPath, buffer, { root: getBasePath() });
|
|
128
120
|
}
|
|
129
121
|
|
|
130
122
|
return hash;
|
|
@@ -142,6 +134,7 @@ function readBody(hash, isRequest = false) {
|
|
|
142
134
|
|
|
143
135
|
try {
|
|
144
136
|
if (fs.existsSync(bodyPath)) {
|
|
137
|
+
assertNotSymlink(bodyPath, false);
|
|
145
138
|
return fs.readFileSync(bodyPath);
|
|
146
139
|
}
|
|
147
140
|
} catch (err) {
|
|
@@ -186,10 +179,7 @@ async function appendEntry(entry) {
|
|
|
186
179
|
...entry
|
|
187
180
|
};
|
|
188
181
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Atomic append using flag 'a'
|
|
192
|
-
fs.appendFileSync(getRequestsPath(), line, { flag: "a" });
|
|
182
|
+
appendPrivateJsonLine(getRequestsPath(), fullEntry, { root: getBasePath() });
|
|
193
183
|
|
|
194
184
|
return fullEntry;
|
|
195
185
|
} finally {
|
|
@@ -202,8 +192,8 @@ async function appendEntry(entry) {
|
|
|
202
192
|
* @param {Object} entry - Network entry to append
|
|
203
193
|
* @returns {Object} The entry with assigned ID
|
|
204
194
|
*/
|
|
205
|
-
function appendEntrySync(entry) {
|
|
206
|
-
ensureDirectories();
|
|
195
|
+
function appendEntrySync(entry, basePath) {
|
|
196
|
+
ensureDirectories(basePath);
|
|
207
197
|
|
|
208
198
|
const id = entry.id || generateId();
|
|
209
199
|
const timestamp = entry.timestamp || Date.now();
|
|
@@ -214,15 +204,14 @@ function appendEntrySync(entry) {
|
|
|
214
204
|
...entry
|
|
215
205
|
};
|
|
216
206
|
|
|
217
|
-
const line = JSON.stringify(fullEntry) + "\n";
|
|
218
|
-
|
|
219
207
|
// Use a simple lock file for synchronous operations
|
|
220
|
-
const lockPath = path.join(getBasePath(), ".lock");
|
|
208
|
+
const lockPath = path.join(getBasePath(basePath), ".lock");
|
|
221
209
|
let lockFd;
|
|
222
210
|
|
|
223
211
|
try {
|
|
224
212
|
// Try to acquire lock
|
|
225
|
-
|
|
213
|
+
assertNotSymlink(lockPath, true);
|
|
214
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
226
215
|
} catch (err) {
|
|
227
216
|
// Lock exists - check if stale and remove, otherwise proceed without lock
|
|
228
217
|
try {
|
|
@@ -230,7 +219,7 @@ function appendEntrySync(entry) {
|
|
|
230
219
|
if (Date.now() - stat.mtimeMs > 5000) {
|
|
231
220
|
fs.unlinkSync(lockPath);
|
|
232
221
|
try {
|
|
233
|
-
lockFd = fs.openSync(lockPath, "wx");
|
|
222
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
234
223
|
} catch (e) {
|
|
235
224
|
// Still can't get lock, proceed without it
|
|
236
225
|
}
|
|
@@ -241,13 +230,13 @@ function appendEntrySync(entry) {
|
|
|
241
230
|
|
|
242
231
|
if (lockFd === undefined) {
|
|
243
232
|
// Proceed without lock as fallback
|
|
244
|
-
|
|
233
|
+
appendPrivateJsonLine(getRequestsPath(basePath), fullEntry, { root: getBasePath(basePath) });
|
|
245
234
|
return fullEntry;
|
|
246
235
|
}
|
|
247
236
|
}
|
|
248
237
|
|
|
249
238
|
try {
|
|
250
|
-
|
|
239
|
+
appendPrivateJsonLine(getRequestsPath(basePath), fullEntry, { root: getBasePath(basePath) });
|
|
251
240
|
} finally {
|
|
252
241
|
if (lockFd !== undefined) {
|
|
253
242
|
fs.closeSync(lockFd);
|
|
@@ -385,6 +374,7 @@ async function readEntries(filters = {}) {
|
|
|
385
374
|
if (!fs.existsSync(requestsPath)) {
|
|
386
375
|
return [];
|
|
387
376
|
}
|
|
377
|
+
assertNotSymlink(requestsPath, false);
|
|
388
378
|
|
|
389
379
|
const { last } = filters;
|
|
390
380
|
const entries = [];
|
|
@@ -433,6 +423,7 @@ function readEntriesSync(filters = {}) {
|
|
|
433
423
|
if (!fs.existsSync(requestsPath)) {
|
|
434
424
|
return [];
|
|
435
425
|
}
|
|
426
|
+
assertNotSymlink(requestsPath, false);
|
|
436
427
|
|
|
437
428
|
const { last } = filters;
|
|
438
429
|
const entries = [];
|
|
@@ -682,10 +673,8 @@ async function cleanup(options = {}) {
|
|
|
682
673
|
|
|
683
674
|
if (deletedEntries > 0 || entries.length === 0) {
|
|
684
675
|
// Atomic write: write to temp then rename
|
|
685
|
-
const tempPath = requestsPath + ".tmp";
|
|
686
676
|
const content = entries.map(e => JSON.stringify(e)).join("\n") + (entries.length > 0 ? "\n" : "");
|
|
687
|
-
|
|
688
|
-
fs.renameSync(tempPath, requestsPath);
|
|
677
|
+
atomicWriteFile(requestsPath, content, { root: getBasePath(), encoding: "utf8" });
|
|
689
678
|
}
|
|
690
679
|
|
|
691
680
|
// 6. Update meta
|
|
@@ -777,10 +766,8 @@ async function clear(options = {}) {
|
|
|
777
766
|
|
|
778
767
|
// Rewrite entries file
|
|
779
768
|
if (deletedEntries > 0) {
|
|
780
|
-
const tempPath = requestsPath + ".tmp";
|
|
781
769
|
const content = remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length > 0 ? "\n" : "");
|
|
782
|
-
|
|
783
|
-
fs.renameSync(tempPath, requestsPath);
|
|
770
|
+
atomicWriteFile(requestsPath, content, { root: getBasePath(), encoding: "utf8" });
|
|
784
771
|
}
|
|
785
772
|
|
|
786
773
|
return { deletedEntries, deletedBodies };
|
|
@@ -807,9 +794,6 @@ function maybeAutoCleanup() {
|
|
|
807
794
|
}
|
|
808
795
|
}
|
|
809
796
|
|
|
810
|
-
// Run auto-cleanup check on module load
|
|
811
|
-
maybeAutoCleanup();
|
|
812
|
-
|
|
813
797
|
module.exports = {
|
|
814
798
|
// Configuration
|
|
815
799
|
getBasePath,
|
|
@@ -841,10 +825,6 @@ module.exports = {
|
|
|
841
825
|
clear,
|
|
842
826
|
maybeAutoCleanup,
|
|
843
827
|
|
|
844
|
-
// Configuration
|
|
845
|
-
setBasePath,
|
|
846
|
-
getBasePath,
|
|
847
|
-
|
|
848
828
|
// Constants
|
|
849
829
|
DEFAULT_BASE,
|
|
850
830
|
DEFAULT_TTL,
|