@papercraneai/sandbox-agent 0.1.13 → 0.1.14
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/dist/dashboard-fs/edit.d.ts +24 -0
- package/dist/dashboard-fs/edit.js +218 -0
- package/dist/dashboard-fs/file-index.d.ts +33 -0
- package/dist/dashboard-fs/file-index.js +102 -0
- package/dist/dashboard-fs/grep.d.ts +25 -0
- package/dist/dashboard-fs/grep.js +106 -0
- package/dist/dashboard-fs/index.d.ts +23 -0
- package/dist/dashboard-fs/index.js +57 -0
- package/dist/dashboard-fs/read.d.ts +28 -0
- package/dist/dashboard-fs/read.js +190 -0
- package/dist/dashboard-fs/write.d.ts +36 -0
- package/dist/dashboard-fs/write.js +179 -0
- package/dist/index.js +125 -7
- package/dist/papercrane-data/index.d.ts +23 -0
- package/dist/papercrane-data/index.js +98 -0
- package/package.json +4 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { open } from "fs/promises";
|
|
2
|
+
import { extname } from "path";
|
|
3
|
+
import { lookup } from "./file-index.js";
|
|
4
|
+
const DEFAULT_LIMIT = 2000;
|
|
5
|
+
const MAX_LINE_LENGTH = 2000;
|
|
6
|
+
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
|
|
7
|
+
const MAX_BYTES = 50 * 1024;
|
|
8
|
+
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
|
|
9
|
+
const SAMPLE_BYTES = 4096;
|
|
10
|
+
// Binary-file detection ported from opencode read.ts:104-149 (MIT).
|
|
11
|
+
// Pure function: extension list + null-byte / non-printable density check.
|
|
12
|
+
const BINARY_EXTENSIONS = new Set([
|
|
13
|
+
".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class", ".jar", ".war",
|
|
14
|
+
".7z", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods",
|
|
15
|
+
".odp", ".bin", ".dat", ".obj", ".o", ".a", ".lib", ".wasm", ".pyc", ".pyo",
|
|
16
|
+
// Images and PDFs are also binary; we report them as binary for v1 since the
|
|
17
|
+
// shared-view chat surface doesn't have an image-attachment path. We can add
|
|
18
|
+
// base64 attachment support later if useful.
|
|
19
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ico", ".svg"
|
|
20
|
+
]);
|
|
21
|
+
function isBinaryFile(filepath, sample) {
|
|
22
|
+
if (BINARY_EXTENSIONS.has(extname(filepath).toLowerCase()))
|
|
23
|
+
return true;
|
|
24
|
+
if (sample.length === 0)
|
|
25
|
+
return false;
|
|
26
|
+
let nonPrintableCount = 0;
|
|
27
|
+
for (let i = 0; i < sample.length; i++) {
|
|
28
|
+
if (sample[i] === 0)
|
|
29
|
+
return true;
|
|
30
|
+
if (sample[i] < 9 || (sample[i] > 13 && sample[i] < 32)) {
|
|
31
|
+
nonPrintableCount++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return nonPrintableCount / sample.length > 0.3;
|
|
35
|
+
}
|
|
36
|
+
async function readSample(filePath, fileSize) {
|
|
37
|
+
const handle = await open(filePath, "r");
|
|
38
|
+
try {
|
|
39
|
+
const sampleSize = Math.min(fileSize, SAMPLE_BYTES);
|
|
40
|
+
const buf = Buffer.alloc(sampleSize);
|
|
41
|
+
await handle.read(buf, 0, sampleSize, 0);
|
|
42
|
+
return buf;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function readLines(filePath, offset, limit) {
|
|
49
|
+
const { createReadStream } = await import("fs");
|
|
50
|
+
const { createInterface } = await import("readline");
|
|
51
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
52
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
53
|
+
const lines = [];
|
|
54
|
+
let totalLines = 0;
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
let truncatedByBytes = false;
|
|
57
|
+
let hasMore = false;
|
|
58
|
+
try {
|
|
59
|
+
for await (const rawLine of rl) {
|
|
60
|
+
totalLines++;
|
|
61
|
+
if (totalLines < offset)
|
|
62
|
+
continue;
|
|
63
|
+
if (lines.length >= limit) {
|
|
64
|
+
hasMore = true;
|
|
65
|
+
// Continue counting totalLines so the caller can report the full count.
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
let line = rawLine;
|
|
69
|
+
if (line.length > MAX_LINE_LENGTH) {
|
|
70
|
+
line = line.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX;
|
|
71
|
+
}
|
|
72
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
73
|
+
if (bytes + lineBytes > MAX_BYTES) {
|
|
74
|
+
truncatedByBytes = true;
|
|
75
|
+
hasMore = true;
|
|
76
|
+
// Continue counting totalLines so the caller can report the full count.
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
bytes += lineBytes;
|
|
80
|
+
lines.push(line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rl.close();
|
|
85
|
+
stream.destroy();
|
|
86
|
+
}
|
|
87
|
+
const startLine = offset;
|
|
88
|
+
const endLine = startLine + lines.length - 1;
|
|
89
|
+
return {
|
|
90
|
+
content: lines.map((l, i) => `${i + startLine}: ${l}`).join("\n"),
|
|
91
|
+
totalLines,
|
|
92
|
+
startLine,
|
|
93
|
+
endLine,
|
|
94
|
+
truncatedByBytes,
|
|
95
|
+
hasMore
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reads a file by handle. The agent never sees or constructs a path; the
|
|
100
|
+
* `fileId` is opaque and resolves to an absolute path only via the
|
|
101
|
+
* server-built index. Output format mirrors what models trained on the
|
|
102
|
+
* SDK Read tool already expect: `<line>: <content>` with truncation
|
|
103
|
+
* markers and a footer explaining offset/total.
|
|
104
|
+
*/
|
|
105
|
+
export async function executeReadFile(index, args) {
|
|
106
|
+
const entry = lookup(index, args.fileId);
|
|
107
|
+
if (!entry) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
|
|
110
|
+
isError: true
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const offsetRaw = args.offset;
|
|
114
|
+
const limitRaw = args.limit;
|
|
115
|
+
const offset = typeof offsetRaw === "number" && offsetRaw >= 1 ? Math.floor(offsetRaw) : 1;
|
|
116
|
+
const limit = typeof limitRaw === "number" && limitRaw >= 1 ? Math.floor(limitRaw) : DEFAULT_LIMIT;
|
|
117
|
+
if (offset < 1) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Error: offset must be >= 1` }],
|
|
120
|
+
isError: true
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let sample;
|
|
124
|
+
try {
|
|
125
|
+
sample = await readSample(entry.absolutePath, entry.size);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: `Error reading file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
130
|
+
isError: true
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (isBinaryFile(entry.absolutePath, sample)) {
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: "text", text: `Error: cannot read binary file ${entry.relativePath}` }],
|
|
136
|
+
isError: true
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const result = await readLines(entry.absolutePath, offset, limit);
|
|
140
|
+
if (result.totalLines === 0 && offset === 1) {
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: `<path>${entry.relativePath}</path>\n<content>\n(empty file)\n</content>` }]
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (result.totalLines < offset) {
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: `Error: offset ${offset} is out of range for this file (${result.totalLines} lines)` }],
|
|
148
|
+
isError: true
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
let footer;
|
|
152
|
+
if (result.truncatedByBytes) {
|
|
153
|
+
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${result.startLine}-${result.endLine}. Use offset=${result.endLine + 1} to continue.)`;
|
|
154
|
+
}
|
|
155
|
+
else if (result.hasMore) {
|
|
156
|
+
footer = `(Showing lines ${result.startLine}-${result.endLine} of ${result.totalLines}. Use offset=${result.endLine + 1} to continue.)`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
footer = `(End of file - total ${result.totalLines} lines)`;
|
|
160
|
+
}
|
|
161
|
+
const text = [
|
|
162
|
+
`<path>${entry.relativePath}</path>`,
|
|
163
|
+
`<content>`,
|
|
164
|
+
result.content,
|
|
165
|
+
``,
|
|
166
|
+
footer,
|
|
167
|
+
`</content>`
|
|
168
|
+
].join("\n");
|
|
169
|
+
return { content: [{ type: "text", text }] };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Returns the index as a list of `{ fileId, relativePath, size, writable }`.
|
|
173
|
+
* The agent uses this to discover what's readable before issuing ReadFile.
|
|
174
|
+
*/
|
|
175
|
+
export function executeListFiles(index) {
|
|
176
|
+
const items = [...index.entries()].map(([fileId, entry]) => ({
|
|
177
|
+
fileId,
|
|
178
|
+
relativePath: entry.relativePath,
|
|
179
|
+
size: entry.size,
|
|
180
|
+
writable: entry.writable
|
|
181
|
+
}));
|
|
182
|
+
// Sort by relativePath for stable, predictable output.
|
|
183
|
+
items.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
184
|
+
return {
|
|
185
|
+
content: [{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: JSON.stringify({ files: items, total: items.length }, null, 2)
|
|
188
|
+
}]
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FileIndex } from "./file-index.js";
|
|
2
|
+
interface WriteFileArgs {
|
|
3
|
+
fileId: unknown;
|
|
4
|
+
content: unknown;
|
|
5
|
+
}
|
|
6
|
+
interface ToolResult {
|
|
7
|
+
content: {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
}[];
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Writes a file by handle. The fileId must reference a writable entry in the index.
|
|
16
|
+
* Preserves the original file's line endings so diffs stay clean. Returns a unified
|
|
17
|
+
* diff of what changed.
|
|
18
|
+
*
|
|
19
|
+
* Not in shared-view's `allowedTools` for v1 — registered for use by future authoring
|
|
20
|
+
* surfaces with reduced privilege.
|
|
21
|
+
*/
|
|
22
|
+
export declare function executeWriteFile(index: FileIndex, args: WriteFileArgs): Promise<ToolResult>;
|
|
23
|
+
interface CreateFileArgs {
|
|
24
|
+
relativePath: unknown;
|
|
25
|
+
content: unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new file under the dashboard root. Validates the relativePath has
|
|
29
|
+
* no `..` segments and isn't absolute; the absolute path is resolved against the
|
|
30
|
+
* dashboard root (passed in via `dashboardRoot`). The new file is added to the
|
|
31
|
+
* index and a fresh fileId is returned for subsequent reads/writes.
|
|
32
|
+
*
|
|
33
|
+
* Not in shared-view's `allowedTools` for v1.
|
|
34
|
+
*/
|
|
35
|
+
export declare function executeCreateFile(index: FileIndex, dashboardRoot: string, args: CreateFileArgs): Promise<ToolResult>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from "fs/promises";
|
|
2
|
+
import { createTwoFilesPatch } from "diff";
|
|
3
|
+
import { lookup } from "./file-index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Detects whether the given text uses CRLF line endings. Ported from gemini-cli's
|
|
6
|
+
* write-file.ts approach (Apache 2.0). When the existing file uses CRLF, new content
|
|
7
|
+
* is normalized to CRLF too — avoids polluting diffs with line-ending churn.
|
|
8
|
+
*/
|
|
9
|
+
function detectCrlf(text) {
|
|
10
|
+
return text.includes("\r\n");
|
|
11
|
+
}
|
|
12
|
+
function applyLineEndings(text, useCrlf) {
|
|
13
|
+
// Normalize the input first to LF, then upgrade if needed.
|
|
14
|
+
const lf = text.replaceAll("\r\n", "\n");
|
|
15
|
+
return useCrlf ? lf.replaceAll("\n", "\r\n") : lf;
|
|
16
|
+
}
|
|
17
|
+
async function readExisting(entry) {
|
|
18
|
+
try {
|
|
19
|
+
const buf = await readFile(entry.absolutePath, "utf-8");
|
|
20
|
+
return { existed: true, content: buf };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err.code === "ENOENT") {
|
|
24
|
+
return { existed: false, content: "" };
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Writes a file by handle. The fileId must reference a writable entry in the index.
|
|
31
|
+
* Preserves the original file's line endings so diffs stay clean. Returns a unified
|
|
32
|
+
* diff of what changed.
|
|
33
|
+
*
|
|
34
|
+
* Not in shared-view's `allowedTools` for v1 — registered for use by future authoring
|
|
35
|
+
* surfaces with reduced privilege.
|
|
36
|
+
*/
|
|
37
|
+
export async function executeWriteFile(index, args) {
|
|
38
|
+
const entry = lookup(index, args.fileId);
|
|
39
|
+
if (!entry) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
|
|
42
|
+
isError: true
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!entry.writable) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: `Error: file ${entry.relativePath} is not writable in this session.` }],
|
|
48
|
+
isError: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (typeof args.content !== "string") {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: `Error: content must be a string` }],
|
|
54
|
+
isError: true
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
let existing;
|
|
58
|
+
try {
|
|
59
|
+
existing = await readExisting(entry);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: `Error reading existing file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
64
|
+
isError: true
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const useCrlf = existing.existed && detectCrlf(existing.content);
|
|
68
|
+
const newContent = applyLineEndings(args.content, useCrlf);
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(entry.absolutePath, newContent, "utf-8");
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: `Error writing file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
75
|
+
isError: true
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Refresh the index entry's size so subsequent ReadFile reflects the new state.
|
|
79
|
+
try {
|
|
80
|
+
const s = await stat(entry.absolutePath);
|
|
81
|
+
entry.size = s.size;
|
|
82
|
+
}
|
|
83
|
+
catch { /* non-fatal */ }
|
|
84
|
+
const diff = createTwoFilesPatch(entry.relativePath, entry.relativePath, existing.content, newContent, existing.existed ? "before" : "(new file)", "after");
|
|
85
|
+
// Strip the standard `Index:` header that diff emits — it's noise for the model.
|
|
86
|
+
const trimmedDiff = diff.replace(/^Index: .*\n=+\n/m, "");
|
|
87
|
+
const summary = existing.existed
|
|
88
|
+
? `Wrote ${entry.relativePath} (${newContent.length} bytes)`
|
|
89
|
+
: `Created ${entry.relativePath} (${newContent.length} bytes)`;
|
|
90
|
+
return {
|
|
91
|
+
content: [{
|
|
92
|
+
type: "text",
|
|
93
|
+
text: `${summary}\n\n${trimmedDiff}`
|
|
94
|
+
}]
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new file under the dashboard root. Validates the relativePath has
|
|
99
|
+
* no `..` segments and isn't absolute; the absolute path is resolved against the
|
|
100
|
+
* dashboard root (passed in via `dashboardRoot`). The new file is added to the
|
|
101
|
+
* index and a fresh fileId is returned for subsequent reads/writes.
|
|
102
|
+
*
|
|
103
|
+
* Not in shared-view's `allowedTools` for v1.
|
|
104
|
+
*/
|
|
105
|
+
export async function executeCreateFile(index, dashboardRoot, args) {
|
|
106
|
+
if (typeof args.relativePath !== "string" || args.relativePath.length === 0) {
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text", text: `Error: relativePath must be a non-empty string` }],
|
|
109
|
+
isError: true
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (typeof args.content !== "string") {
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: `Error: content must be a string` }],
|
|
115
|
+
isError: true
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const rel = args.relativePath;
|
|
119
|
+
if (rel.startsWith("/") || rel.includes("\0")) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: `Error: relativePath must be relative and not contain null bytes` }],
|
|
122
|
+
isError: true
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Reject any path segment equal to ".." — can't escape the root.
|
|
126
|
+
const segments = rel.split(/[\\/]/);
|
|
127
|
+
if (segments.some((s) => s === ".." || s === ".")) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: `Error: relativePath must not contain "." or ".." segments` }],
|
|
130
|
+
isError: true
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const { join, isAbsolute } = await import("path");
|
|
134
|
+
const { randomBytes } = await import("crypto");
|
|
135
|
+
const absolutePath = join(dashboardRoot, rel);
|
|
136
|
+
// Belt and suspenders — the resolved absolute path must be under dashboardRoot.
|
|
137
|
+
if (!isAbsolute(absolutePath) || !absolutePath.startsWith(dashboardRoot)) {
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: "text", text: `Error: resolved path is outside dashboard root` }],
|
|
140
|
+
isError: true
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Don't overwrite an existing file silently — Write does that, Create doesn't.
|
|
144
|
+
try {
|
|
145
|
+
await stat(absolutePath);
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: `Error: file already exists at ${rel}. Use WriteFile to overwrite.` }],
|
|
148
|
+
isError: true
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch { /* expected — file shouldn't exist */ }
|
|
152
|
+
// Write the file, then add to index.
|
|
153
|
+
const { mkdir } = await import("fs/promises");
|
|
154
|
+
const { dirname } = await import("path");
|
|
155
|
+
try {
|
|
156
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
157
|
+
await writeFile(absolutePath, args.content, "utf-8");
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
content: [{ type: "text", text: `Error creating file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
162
|
+
isError: true
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const fileId = `f_${randomBytes(8).toString("hex")}`;
|
|
166
|
+
const s = await stat(absolutePath);
|
|
167
|
+
index.set(fileId, {
|
|
168
|
+
absolutePath,
|
|
169
|
+
relativePath: rel,
|
|
170
|
+
writable: true,
|
|
171
|
+
size: s.size
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
content: [{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: `Created ${rel} (${s.size} bytes). fileId: ${fileId}`
|
|
177
|
+
}]
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { createServer } from "http";
|
|
|
4
4
|
import { WebSocketServer, WebSocket } from "ws";
|
|
5
5
|
import { Tail } from "tail";
|
|
6
6
|
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
7
|
+
import { createDashboardFsServer } from "./dashboard-fs/index.js";
|
|
8
|
+
import { createPapercraneDataServer } from "./papercrane-data/index.js";
|
|
7
9
|
import { realpathSync } from "fs";
|
|
8
10
|
import { readdir, stat, mkdir, readFile, writeFile, access, unlink, rm } from "fs/promises";
|
|
9
11
|
import { join, dirname, resolve } from "path";
|
|
@@ -1136,7 +1138,21 @@ app.post("/chat", async (req, res) => {
|
|
|
1136
1138
|
const requestId = Math.random().toString(36).substring(7);
|
|
1137
1139
|
const { message, sessionId, systemPrompt, verbose = false, subdir, selectedDashboard,
|
|
1138
1140
|
// Configurable agent options
|
|
1139
|
-
maxTurns = 40, allowedTools, disallowedTools, model, maxBudgetUsd
|
|
1141
|
+
maxTurns = 40, allowedTools, disallowedTools, model, maxBudgetUsd,
|
|
1142
|
+
// Reasoning effort (low | medium | high | xhigh | max | <number>). The
|
|
1143
|
+
// SDK defaults to "high" on Opus 4.6+; "medium" is Anthropic's recommended
|
|
1144
|
+
// balance of speed/cost/performance for most agentic workflows. Callers can
|
|
1145
|
+
// override per request when a heavier or lighter pass is warranted.
|
|
1146
|
+
effort = "medium",
|
|
1147
|
+
// Shared-view chat fields (Phase 2a/2b). When mode === "shared-view" the agent
|
|
1148
|
+
// gets a locked-down tool set (handle-based file tools + scoped integration
|
|
1149
|
+
// calls only; no Bash/Web/SDK file tools) regardless of the caller's
|
|
1150
|
+
// `allowedTools` value. The dashboard folder is identified via the existing
|
|
1151
|
+
// `subdir` field (relative to PROJECT_DIR); Papercrane doesn't need to know
|
|
1152
|
+
// the sandbox's absolute filesystem layout. `queryFiles` is an optional list
|
|
1153
|
+
// of additional relative paths to include in the file index. The remaining
|
|
1154
|
+
// fields configure the papercrane-data MCP server (Phase 2b).
|
|
1155
|
+
mode, queryFiles, shareId, shareCode, visitorSessionId } = req.body;
|
|
1140
1156
|
const ctx = { requestId, sessionId };
|
|
1141
1157
|
log.info(ctx, "Agent received chat request");
|
|
1142
1158
|
if (!message) {
|
|
@@ -1185,7 +1201,7 @@ app.post("/chat", async (req, res) => {
|
|
|
1185
1201
|
parent_tool_use_id: null
|
|
1186
1202
|
};
|
|
1187
1203
|
}
|
|
1188
|
-
// Default tools if not specified
|
|
1204
|
+
// Default tools if not specified (authoring mode)
|
|
1189
1205
|
const defaultTools = [
|
|
1190
1206
|
"Read",
|
|
1191
1207
|
"Write",
|
|
@@ -1199,18 +1215,111 @@ app.post("/chat", async (req, res) => {
|
|
|
1199
1215
|
"mcp__client-tools__ShowPreview",
|
|
1200
1216
|
"mcp__client-tools__GetContext"
|
|
1201
1217
|
];
|
|
1218
|
+
// Locked-down tool set for shared-view chat. Server-enforced regardless of
|
|
1219
|
+
// any `allowedTools` passed in the request body — defense-in-depth so a
|
|
1220
|
+
// miswiring on the Papercrane side can't widen the agent's surface.
|
|
1221
|
+
const sharedViewAllowedTools = [
|
|
1222
|
+
"mcp__dashboard-fs__ListFiles",
|
|
1223
|
+
"mcp__dashboard-fs__ReadFile",
|
|
1224
|
+
"mcp__dashboard-fs__GrepFiles",
|
|
1225
|
+
"mcp__papercrane-data__List",
|
|
1226
|
+
"mcp__papercrane-data__Describe",
|
|
1227
|
+
"mcp__papercrane-data__Call",
|
|
1228
|
+
"mcp__client-tools__GetContext"
|
|
1229
|
+
];
|
|
1230
|
+
const isSharedView = mode === "shared-view";
|
|
1202
1231
|
// Store UI context for GetContext tool
|
|
1203
1232
|
sessionContext[requestId] = { selectedDashboard: selectedDashboard || null };
|
|
1204
1233
|
const clientTools = createClientToolsServer(requestId);
|
|
1234
|
+
// Build the dashboard-fs MCP server for shared-view chat. The file index is
|
|
1235
|
+
// walked once at session start from `cwd` (PROJECT_DIR + subdir); the agent
|
|
1236
|
+
// addresses files only by handle.
|
|
1237
|
+
let dashboardFs = null;
|
|
1238
|
+
let papercraneData = null;
|
|
1239
|
+
if (isSharedView) {
|
|
1240
|
+
if (!subdir || typeof subdir !== "string") {
|
|
1241
|
+
res.status(400).json({ error: "subdir (the dashboard folder name) is required when mode is 'shared-view'" });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (typeof shareId !== "number") {
|
|
1245
|
+
res.status(400).json({ error: "shareId is required when mode is 'shared-view'" });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (!shareCode || typeof shareCode !== "string") {
|
|
1249
|
+
res.status(400).json({ error: "shareCode is required when mode is 'shared-view'" });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (!visitorSessionId || typeof visitorSessionId !== "string") {
|
|
1253
|
+
res.status(400).json({ error: "visitorSessionId is required when mode is 'shared-view'" });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
// Read Papercrane credentials from the same config file the CLI uses
|
|
1257
|
+
// (~/.papercrane/config.json). Single source of truth.
|
|
1258
|
+
let papercraneApiUrl;
|
|
1259
|
+
let papercraneApiKey;
|
|
1260
|
+
try {
|
|
1261
|
+
const configPath = join(homedir(), ".papercrane", "config.json");
|
|
1262
|
+
const raw = await readFile(configPath, "utf-8");
|
|
1263
|
+
const cfg = JSON.parse(raw);
|
|
1264
|
+
console.log(`[shared-view] full config:`, cfg);
|
|
1265
|
+
if (!cfg.apiKey)
|
|
1266
|
+
throw new Error("apiKey missing from ~/.papercrane/config.json");
|
|
1267
|
+
if (!cfg.apiBaseUrl)
|
|
1268
|
+
throw new Error("apiBaseUrl missing from ~/.papercrane/config.json");
|
|
1269
|
+
papercraneApiKey = cfg.apiKey;
|
|
1270
|
+
papercraneApiUrl = cfg.apiBaseUrl;
|
|
1271
|
+
}
|
|
1272
|
+
catch (err) {
|
|
1273
|
+
log.error({ ...ctx, err: err instanceof Error ? err.message : String(err) }, "Failed to load Papercrane credentials");
|
|
1274
|
+
res.status(500).json({ error: "Sandbox is missing Papercrane credentials. Run `papercrane login` to set up the API key." });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
try {
|
|
1278
|
+
// Resolve queryFiles to absolute paths if relative (callers pass relative paths)
|
|
1279
|
+
const resolvedQueryFiles = Array.isArray(queryFiles)
|
|
1280
|
+
? queryFiles.map((qf) => qf.startsWith("/") ? qf : join(PROJECT_DIR, qf))
|
|
1281
|
+
: undefined;
|
|
1282
|
+
dashboardFs = await createDashboardFsServer({
|
|
1283
|
+
dashboardRoot: cwd,
|
|
1284
|
+
queryFiles: resolvedQueryFiles
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
log.error({ ...ctx, err: err instanceof Error ? err.message : String(err) }, "Failed to build dashboard-fs index");
|
|
1289
|
+
res.status(400).json({ error: `Failed to build dashboard-fs index: ${err instanceof Error ? err.message : String(err)}` });
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
papercraneData = createPapercraneDataServer({
|
|
1293
|
+
apiBaseUrl: papercraneApiUrl,
|
|
1294
|
+
apiKey: papercraneApiKey,
|
|
1295
|
+
shareCode,
|
|
1296
|
+
visitorSessionId
|
|
1297
|
+
});
|
|
1298
|
+
log.info({
|
|
1299
|
+
...ctx,
|
|
1300
|
+
shareId,
|
|
1301
|
+
shareCode,
|
|
1302
|
+
visitorSessionId,
|
|
1303
|
+
cwd,
|
|
1304
|
+
papercraneApiUrl
|
|
1305
|
+
}, "Shared-view chat session initialized");
|
|
1306
|
+
}
|
|
1307
|
+
const mcpServers = {
|
|
1308
|
+
"client-tools": clientTools
|
|
1309
|
+
};
|
|
1310
|
+
if (dashboardFs) {
|
|
1311
|
+
mcpServers["dashboard-fs"] = dashboardFs.server;
|
|
1312
|
+
}
|
|
1313
|
+
if (papercraneData) {
|
|
1314
|
+
mcpServers["papercrane-data"] = papercraneData.server;
|
|
1315
|
+
}
|
|
1205
1316
|
const options = {
|
|
1206
1317
|
maxTurns,
|
|
1207
1318
|
cwd,
|
|
1208
1319
|
permissionMode: "bypassPermissions",
|
|
1209
1320
|
allowDangerouslySkipPermissions: true,
|
|
1210
|
-
mcpServers
|
|
1211
|
-
|
|
1212
|
-
},
|
|
1213
|
-
allowedTools: allowedTools || defaultTools,
|
|
1321
|
+
mcpServers,
|
|
1322
|
+
allowedTools: isSharedView ? sharedViewAllowedTools : (allowedTools || defaultTools),
|
|
1214
1323
|
settingSources: ["project"],
|
|
1215
1324
|
hooks,
|
|
1216
1325
|
abortController,
|
|
@@ -1234,6 +1343,9 @@ app.post("/chat", async (req, res) => {
|
|
|
1234
1343
|
if (maxBudgetUsd) {
|
|
1235
1344
|
options.maxBudgetUsd = maxBudgetUsd;
|
|
1236
1345
|
}
|
|
1346
|
+
if (effort !== undefined) {
|
|
1347
|
+
options.effort = effort;
|
|
1348
|
+
}
|
|
1237
1349
|
try {
|
|
1238
1350
|
let gotResult = false;
|
|
1239
1351
|
log.debug({ ...ctx, elapsed: Date.now() - requestStartTime }, "Starting Claude SDK query");
|
|
@@ -1310,9 +1422,15 @@ app.post("/chat", async (req, res) => {
|
|
|
1310
1422
|
}
|
|
1311
1423
|
}
|
|
1312
1424
|
finally {
|
|
1313
|
-
// Clean up per-request context and close MCP
|
|
1425
|
+
// Clean up per-request context and close MCP servers
|
|
1314
1426
|
delete sessionContext[requestId];
|
|
1315
1427
|
await clientTools.instance?.close().catch(() => { });
|
|
1428
|
+
if (dashboardFs) {
|
|
1429
|
+
await dashboardFs.server.instance?.close().catch(() => { });
|
|
1430
|
+
}
|
|
1431
|
+
if (papercraneData) {
|
|
1432
|
+
await papercraneData.server.instance?.close().catch(() => { });
|
|
1433
|
+
}
|
|
1316
1434
|
}
|
|
1317
1435
|
});
|
|
1318
1436
|
// =============================================================================
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface CreateOptions {
|
|
2
|
+
/** Base URL of the Papercrane API, e.g. "https://app.papercrane.ai". No trailing slash. */
|
|
3
|
+
apiBaseUrl: string;
|
|
4
|
+
/** Environment API key (Bearer token). Same key the rest of the agent uses. */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Share code from the chat session. Papercrane uses it to scope every call. */
|
|
7
|
+
shareCode: string;
|
|
8
|
+
/** Visitor session identifier (cookie-derived). Stamped on outbound calls for usage attribution. */
|
|
9
|
+
visitorSessionId: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds the papercrane-data MCP server for a shared-view chat session.
|
|
13
|
+
*
|
|
14
|
+
* Each tool is a thin HTTP wrapper around the share-scoped integrations
|
|
15
|
+
* endpoint at `/api/share/:shareCode/integrations/*`. Papercrane resolves the
|
|
16
|
+
* share's allowlist on every call (single source of truth, no staleness) and
|
|
17
|
+
* filters/gates the response server-side. The MCP server does no filtering of
|
|
18
|
+
* its own.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createPapercraneDataServer(options: CreateOptions): {
|
|
21
|
+
server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
22
|
+
};
|
|
23
|
+
export {};
|