pi-studio 0.9.52 → 0.9.53
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/CHANGELOG.md +18 -0
- package/README.md +9 -7
- package/ROADMAP.md +14 -1
- package/client/studio-client.js +598 -106
- package/client/studio-preview-resource-helpers.js +61 -12
- package/client/studio.css +130 -3
- package/index.ts +547 -70
- package/package.json +1 -1
- package/shared/studio-resource-grants.js +157 -0
- package/shared/studio-side-question-context.js +17 -4
package/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
|
|
|
32
32
|
import { parseStudioLocalPreviewPage, parseStudioPdfLaunchTarget } from "./shared/studio-local-preview-path.js";
|
|
33
33
|
import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
34
34
|
import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
|
|
35
|
+
import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
|
|
35
36
|
import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
|
|
36
37
|
import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
37
38
|
import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
} from "./shared/studio-side-question.js";
|
|
76
77
|
import {
|
|
77
78
|
STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS,
|
|
79
|
+
assertStudioSideQuestionRootStable,
|
|
78
80
|
formatStudioSideQuestionContextMap,
|
|
79
81
|
listStudioSideQuestionContext,
|
|
80
82
|
readStudioSideQuestionContextText,
|
|
@@ -451,6 +453,8 @@ interface StudioSideQuestionToolDescriptor {
|
|
|
451
453
|
gateway: boolean;
|
|
452
454
|
}
|
|
453
455
|
|
|
456
|
+
type StudioSideQuestionContextRootInput = Pick<StudioSideQuestionContextInput, "sourcePath" | "resourceDir" | "gatherScope" | "contextPath">;
|
|
457
|
+
|
|
454
458
|
interface StudioSideQuestionContextInput {
|
|
455
459
|
focusKind: StudioSideQuestionFocusKind;
|
|
456
460
|
focusLabel: string;
|
|
@@ -3103,12 +3107,13 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
|
|
|
3103
3107
|
}
|
|
3104
3108
|
|
|
3105
3109
|
const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
|
|
3106
|
-
const
|
|
3110
|
+
const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
|
|
3107
3111
|
[".png", "image/png"],
|
|
3108
3112
|
[".jpg", "image/jpeg"],
|
|
3109
3113
|
[".jpeg", "image/jpeg"],
|
|
3110
3114
|
[".gif", "image/gif"],
|
|
3111
3115
|
[".webp", "image/webp"],
|
|
3116
|
+
[".pdf", "application/pdf"],
|
|
3112
3117
|
]);
|
|
3113
3118
|
const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
|
|
3114
3119
|
".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
|
|
@@ -3133,9 +3138,37 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
|
|
|
3133
3138
|
|
|
3134
3139
|
type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
|
|
3135
3140
|
type StudioFileBrowserSortMode = "name" | "mtime-desc" | "mtime-asc" | "size-desc" | "size-asc";
|
|
3141
|
+
type StudioResourceGrantRegistry = ReturnType<typeof createStudioResourceGrantRegistry>;
|
|
3142
|
+
|
|
3143
|
+
class StudioResourceGrantRequiredError extends Error {
|
|
3144
|
+
readonly code = "studio-resource-grant-required";
|
|
3145
|
+
readonly filePath: string;
|
|
3146
|
+
readonly directoryPath: string;
|
|
3147
|
+
readonly resourceKind: StudioLocalPreviewResourceKind;
|
|
3148
|
+
|
|
3149
|
+
constructor(filePath: string, resourceKind: StudioLocalPreviewResourceKind) {
|
|
3150
|
+
super("This local resource is outside the locations currently available to Studio.");
|
|
3151
|
+
this.name = "StudioResourceGrantRequiredError";
|
|
3152
|
+
this.filePath = filePath;
|
|
3153
|
+
this.directoryPath = dirname(filePath);
|
|
3154
|
+
this.resourceKind = resourceKind;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
class StudioDirectoryGrantRequiredError extends Error {
|
|
3159
|
+
readonly code = "studio-resource-directory-grant-required";
|
|
3160
|
+
readonly directoryPath: string;
|
|
3161
|
+
|
|
3162
|
+
constructor(directoryPath: string) {
|
|
3163
|
+
super("This folder is not available to the current Studio session.");
|
|
3164
|
+
this.name = "StudioDirectoryGrantRequiredError";
|
|
3165
|
+
this.directoryPath = directoryPath;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3136
3168
|
|
|
3137
3169
|
interface StudioLocalPreviewResource {
|
|
3138
3170
|
filePath: string;
|
|
3171
|
+
referencePath: string;
|
|
3139
3172
|
label: string;
|
|
3140
3173
|
extension: string;
|
|
3141
3174
|
kind: StudioLocalPreviewResourceKind;
|
|
@@ -3154,6 +3187,11 @@ interface StudioFileBrowserEntry {
|
|
|
3154
3187
|
hidden: boolean;
|
|
3155
3188
|
}
|
|
3156
3189
|
|
|
3190
|
+
interface StudioFileBrowserLocation {
|
|
3191
|
+
path: string;
|
|
3192
|
+
label: string;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3157
3195
|
function normalizeStudioFileBrowserSortMode(sort: string | null | undefined): StudioFileBrowserSortMode {
|
|
3158
3196
|
const value = typeof sort === "string" ? sort.trim().toLowerCase() : "";
|
|
3159
3197
|
if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
|
|
@@ -3177,25 +3215,34 @@ function compareStudioFileBrowserEntries(a: StudioFileBrowserEntry, b: StudioFil
|
|
|
3177
3215
|
return compareStudioFileBrowserEntryNames(a, b);
|
|
3178
3216
|
}
|
|
3179
3217
|
|
|
3180
|
-
function resolveStudioPdfResourcePath(
|
|
3218
|
+
function resolveStudioPdfResourcePath(
|
|
3219
|
+
pdfPath: string | undefined,
|
|
3220
|
+
sourcePath: string | undefined,
|
|
3221
|
+
resourceDir: string | undefined,
|
|
3222
|
+
fallbackCwd: string,
|
|
3223
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3224
|
+
): string {
|
|
3181
3225
|
const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
|
|
3182
3226
|
if (!rawPath) throw new Error("Missing PDF path.");
|
|
3183
3227
|
if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
|
|
3184
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3228
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3185
3229
|
throw new Error("Only local PDF paths are supported.");
|
|
3186
3230
|
}
|
|
3187
3231
|
|
|
3188
3232
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3189
|
-
const cleanedPath =
|
|
3233
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3190
3234
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3191
3235
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3192
3236
|
if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
|
|
3193
3237
|
|
|
3194
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3195
3238
|
const candidateReal = realpathSync(candidate);
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3239
|
+
if (resourceGrants) {
|
|
3240
|
+
if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, "pdf");
|
|
3241
|
+
} else {
|
|
3242
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3243
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3244
|
+
throw new Error("PDF path must stay within the current Studio resource directory.");
|
|
3245
|
+
}
|
|
3199
3246
|
}
|
|
3200
3247
|
|
|
3201
3248
|
const stat = statSync(candidateReal);
|
|
@@ -3216,6 +3263,22 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
|
|
|
3216
3263
|
}
|
|
3217
3264
|
}
|
|
3218
3265
|
|
|
3266
|
+
function decodeStudioLocalPreviewResourceReference(resourcePath: string): string {
|
|
3267
|
+
const raw = String(resourcePath ?? "").trim();
|
|
3268
|
+
if (!/^file:/i.test(raw)) {
|
|
3269
|
+
return decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(raw));
|
|
3270
|
+
}
|
|
3271
|
+
try {
|
|
3272
|
+
const url = new URL(raw);
|
|
3273
|
+
if (url.protocol !== "file:") throw new Error("Not a file URL.");
|
|
3274
|
+
url.hash = "";
|
|
3275
|
+
url.search = "";
|
|
3276
|
+
return fileURLToPath(url);
|
|
3277
|
+
} catch {
|
|
3278
|
+
throw new Error("Invalid local file URL.");
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3219
3282
|
function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
|
|
3220
3283
|
const ext = extension.toLowerCase();
|
|
3221
3284
|
const name = basename(String(filePathOrName || "")).toLowerCase();
|
|
@@ -3231,60 +3294,128 @@ function resolveStudioLocalPreviewResourcePath(
|
|
|
3231
3294
|
sourcePath: string | undefined,
|
|
3232
3295
|
resourceDir: string | undefined,
|
|
3233
3296
|
fallbackCwd: string,
|
|
3297
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3234
3298
|
): StudioLocalPreviewResource {
|
|
3235
3299
|
const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
|
|
3236
3300
|
if (!rawPath) throw new Error("Missing local resource path.");
|
|
3237
3301
|
if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
|
|
3238
3302
|
if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
|
|
3239
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3240
|
-
throw new Error("Only local
|
|
3303
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3304
|
+
throw new Error("Only local file paths and file URLs are supported.");
|
|
3241
3305
|
}
|
|
3242
3306
|
|
|
3243
3307
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3244
|
-
const cleanedPath =
|
|
3308
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3245
3309
|
if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
|
|
3246
3310
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3247
3311
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3248
3312
|
const extension = extname(candidate).toLowerCase();
|
|
3249
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3250
3313
|
const candidateReal = realpathSync(candidate);
|
|
3251
|
-
const rel = relative(boundaryReal, candidateReal);
|
|
3252
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
3253
|
-
throw new Error("Local resource path must stay within the current Studio resource directory.");
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
3314
|
const stat = statSync(candidateReal);
|
|
3257
3315
|
if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
|
|
3316
|
+
|
|
3317
|
+
const kind = getStudioLocalPreviewResourceKind(extension, candidateReal);
|
|
3318
|
+
const matchingGrant = resourceGrants?.findGrant(candidateReal);
|
|
3319
|
+
let effectiveDirectory: string;
|
|
3320
|
+
if (resourceGrants) {
|
|
3321
|
+
if (!matchingGrant) throw new StudioResourceGrantRequiredError(candidateReal, kind);
|
|
3322
|
+
effectiveDirectory = matchingGrant.kind === "directory" ? matchingGrant.path : dirname(candidateReal);
|
|
3323
|
+
} else {
|
|
3324
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3325
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3326
|
+
throw new Error("Local resource path must stay within the current Studio resource directory.");
|
|
3327
|
+
}
|
|
3328
|
+
effectiveDirectory = boundaryReal;
|
|
3329
|
+
}
|
|
3330
|
+
const relativePath = relative(effectiveDirectory, candidateReal);
|
|
3331
|
+
const useRelativeReference = isPathInsideOrEqualDirectory(candidateReal, effectiveDirectory);
|
|
3258
3332
|
return {
|
|
3259
3333
|
filePath: candidateReal,
|
|
3260
|
-
|
|
3334
|
+
referencePath: useRelativeReference ? (relativePath || basename(candidateReal)) : candidateReal,
|
|
3335
|
+
label: useRelativeReference ? (relativePath || basename(candidateReal)) : basename(candidateReal),
|
|
3261
3336
|
extension,
|
|
3262
|
-
kind
|
|
3337
|
+
kind,
|
|
3263
3338
|
page: parseStudioLocalPreviewPage(rawPath),
|
|
3264
|
-
resourceDir:
|
|
3339
|
+
resourceDir: effectiveDirectory,
|
|
3265
3340
|
};
|
|
3266
3341
|
}
|
|
3267
3342
|
|
|
3343
|
+
function getStudioFileBrowserGrantLocations(resourceGrants: StudioResourceGrantRegistry): {
|
|
3344
|
+
locations: StudioFileBrowserLocation[];
|
|
3345
|
+
exactFiles: StudioFileBrowserEntry[];
|
|
3346
|
+
} {
|
|
3347
|
+
const grants = resourceGrants.snapshot();
|
|
3348
|
+
const directoryPaths: string[] = [];
|
|
3349
|
+
for (const grant of grants) {
|
|
3350
|
+
if (grant.kind !== "directory") continue;
|
|
3351
|
+
try {
|
|
3352
|
+
const currentReal = realpathSync(grant.path);
|
|
3353
|
+
if (currentReal === grant.path && statSync(currentReal).isDirectory()) directoryPaths.push(currentReal);
|
|
3354
|
+
} catch {
|
|
3355
|
+
// Missing or replaced directory grants are omitted rather than followed.
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
const locations = directoryPaths
|
|
3359
|
+
.map((path) => ({ path, label: basename(path) || path }))
|
|
3360
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
3361
|
+
const exactFiles: StudioFileBrowserEntry[] = [];
|
|
3362
|
+
for (const grant of grants) {
|
|
3363
|
+
if (grant.kind !== "file") continue;
|
|
3364
|
+
if (directoryPaths.some((directoryPath) => isPathInsideOrEqualDirectory(grant.path, directoryPath))) continue;
|
|
3365
|
+
try {
|
|
3366
|
+
const currentReal = realpathSync(grant.path);
|
|
3367
|
+
if (currentReal !== grant.path) continue;
|
|
3368
|
+
const stat = statSync(currentReal);
|
|
3369
|
+
if (!stat.isFile()) continue;
|
|
3370
|
+
const name = basename(currentReal);
|
|
3371
|
+
const extension = extname(currentReal).toLowerCase();
|
|
3372
|
+
exactFiles.push({
|
|
3373
|
+
name,
|
|
3374
|
+
path: currentReal,
|
|
3375
|
+
type: "file",
|
|
3376
|
+
extension,
|
|
3377
|
+
kind: getStudioLocalPreviewResourceKind(extension, currentReal),
|
|
3378
|
+
size: stat.size,
|
|
3379
|
+
mtimeMs: stat.mtimeMs,
|
|
3380
|
+
hidden: name.startsWith("."),
|
|
3381
|
+
});
|
|
3382
|
+
} catch {
|
|
3383
|
+
// Missing exact-file grants remain bounded but are omitted from the Files view.
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
exactFiles.sort((a, b) => compareStudioFileBrowserEntries(a, b, "name"));
|
|
3387
|
+
return { locations, exactFiles };
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3268
3390
|
function resolveStudioFileBrowserDirectory(
|
|
3269
3391
|
dirPath: string | undefined,
|
|
3392
|
+
rootPath: string | undefined,
|
|
3270
3393
|
sourcePath: string | undefined,
|
|
3271
3394
|
resourceDir: string | undefined,
|
|
3272
3395
|
fallbackCwd: string,
|
|
3396
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3273
3397
|
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
|
|
3274
3398
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3275
|
-
const
|
|
3399
|
+
const rawRoot = typeof rootPath === "string" ? rootPath.trim() : "";
|
|
3400
|
+
const expandedRoot = recoverLikelyDroppedLeadingSlashPath(expandHome(rawRoot || context.boundaryDir));
|
|
3401
|
+
const requestedRoot = isAbsolute(expandedRoot) ? expandedRoot : resolve(fallbackCwd, expandedRoot);
|
|
3402
|
+
const requestedRootReal = realpathSync(requestedRoot);
|
|
3403
|
+
if (!statSync(requestedRootReal).isDirectory()) throw new Error("File browser root does not refer to a directory.");
|
|
3404
|
+
const matchingRootGrant = resourceGrants.findGrant(requestedRootReal, { cwd: fallbackCwd });
|
|
3405
|
+
if (!matchingRootGrant || matchingRootGrant.kind !== "directory") {
|
|
3406
|
+
throw new StudioDirectoryGrantRequiredError(requestedRootReal);
|
|
3407
|
+
}
|
|
3408
|
+
const rootReal = matchingRootGrant.path;
|
|
3276
3409
|
const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
|
|
3277
|
-
const
|
|
3410
|
+
const expandedDir = recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir));
|
|
3278
3411
|
const requested = rawDir
|
|
3279
|
-
? (isAbsolute(
|
|
3280
|
-
|
|
3281
|
-
: resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
|
|
3282
|
-
: baseDir;
|
|
3412
|
+
? (isAbsolute(expandedDir) ? expandedDir : resolve(rootReal, expandedDir))
|
|
3413
|
+
: rootReal;
|
|
3283
3414
|
const currentReal = realpathSync(requested);
|
|
3284
3415
|
const currentStat = statSync(currentReal);
|
|
3285
3416
|
if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
|
|
3286
3417
|
if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
|
|
3287
|
-
throw new Error("File browser path must stay within
|
|
3418
|
+
throw new Error("File browser path must stay within an allowed Studio folder.");
|
|
3288
3419
|
}
|
|
3289
3420
|
const parent = dirname(currentReal);
|
|
3290
3421
|
const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
|
|
@@ -3294,12 +3425,14 @@ function resolveStudioFileBrowserDirectory(
|
|
|
3294
3425
|
|
|
3295
3426
|
function listStudioFileBrowserDirectory(
|
|
3296
3427
|
dirPath: string | undefined,
|
|
3428
|
+
rootPath: string | undefined,
|
|
3297
3429
|
sourcePath: string | undefined,
|
|
3298
3430
|
resourceDir: string | undefined,
|
|
3299
3431
|
fallbackCwd: string,
|
|
3432
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3300
3433
|
sortMode?: string | null,
|
|
3301
|
-
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
|
|
3302
|
-
const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
|
|
3434
|
+
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; exactFiles: StudioFileBrowserEntry[]; locations: StudioFileBrowserLocation[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
|
|
3435
|
+
const context = resolveStudioFileBrowserDirectory(dirPath, rootPath, sourcePath, resourceDir, fallbackCwd, resourceGrants);
|
|
3303
3436
|
const sort = normalizeStudioFileBrowserSortMode(sortMode);
|
|
3304
3437
|
const entries: StudioFileBrowserEntry[] = [];
|
|
3305
3438
|
let omitted = 0;
|
|
@@ -3342,7 +3475,7 @@ function listStudioFileBrowserDirectory(
|
|
|
3342
3475
|
entries.sort((a, b) => compareStudioFileBrowserEntries(a, b, sort));
|
|
3343
3476
|
const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
|
|
3344
3477
|
omitted += Math.max(0, entries.length - limitedEntries.length);
|
|
3345
|
-
return { ...context, entries: limitedEntries, omitted, omittedIgnored, sort };
|
|
3478
|
+
return { ...context, ...getStudioFileBrowserGrantLocations(resourceGrants), entries: limitedEntries, omitted, omittedIgnored, sort };
|
|
3346
3479
|
}
|
|
3347
3480
|
|
|
3348
3481
|
function resolveStudioHtmlPreviewResourcePath(
|
|
@@ -3350,27 +3483,32 @@ function resolveStudioHtmlPreviewResourcePath(
|
|
|
3350
3483
|
sourcePath: string | undefined,
|
|
3351
3484
|
resourceDir: string | undefined,
|
|
3352
3485
|
fallbackCwd: string,
|
|
3486
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3353
3487
|
): { filePath: string; mimeType: string } {
|
|
3354
3488
|
const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
|
|
3355
3489
|
if (!rawPath) throw new Error("Missing HTML preview resource path.");
|
|
3356
3490
|
if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
|
|
3357
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3491
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3358
3492
|
throw new Error("Only local HTML preview resources are supported.");
|
|
3359
3493
|
}
|
|
3360
3494
|
|
|
3361
3495
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3362
|
-
const cleanedPath =
|
|
3496
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3363
3497
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3364
3498
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3365
3499
|
const ext = extname(candidate).toLowerCase();
|
|
3366
|
-
const mimeType =
|
|
3367
|
-
if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and
|
|
3500
|
+
const mimeType = STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT.get(ext);
|
|
3501
|
+
if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, WebP, and PDF media can be embedded in Studio previews.");
|
|
3368
3502
|
|
|
3369
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3370
3503
|
const candidateReal = realpathSync(candidate);
|
|
3371
|
-
const
|
|
3372
|
-
if (
|
|
3373
|
-
throw new
|
|
3504
|
+
const kind: StudioLocalPreviewResourceKind = ext === ".pdf" ? "pdf" : "image";
|
|
3505
|
+
if (resourceGrants) {
|
|
3506
|
+
if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, kind);
|
|
3507
|
+
} else {
|
|
3508
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3509
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3510
|
+
throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
|
|
3511
|
+
}
|
|
3374
3512
|
}
|
|
3375
3513
|
|
|
3376
3514
|
const stat = statSync(candidateReal);
|
|
@@ -3381,6 +3519,39 @@ function resolveStudioHtmlPreviewResourcePath(
|
|
|
3381
3519
|
return { filePath: candidateReal, mimeType };
|
|
3382
3520
|
}
|
|
3383
3521
|
|
|
3522
|
+
function resolveStudioAuthorizedPreviewRenderContext(
|
|
3523
|
+
sourcePath: string | undefined,
|
|
3524
|
+
resourceDir: string | undefined,
|
|
3525
|
+
fallbackCwd: string,
|
|
3526
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3527
|
+
): { resourcePath?: string; sourcePath?: string } {
|
|
3528
|
+
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3529
|
+
let resourcePath: string;
|
|
3530
|
+
try {
|
|
3531
|
+
resourcePath = realpathSync(context.baseDir);
|
|
3532
|
+
if (!statSync(resourcePath).isDirectory()) return {};
|
|
3533
|
+
} catch {
|
|
3534
|
+
return {};
|
|
3535
|
+
}
|
|
3536
|
+
const directoryGrant = resourceGrants.findGrant(resourcePath, { cwd: fallbackCwd });
|
|
3537
|
+
if (!directoryGrant || directoryGrant.kind !== "directory") return {};
|
|
3538
|
+
|
|
3539
|
+
const rawSource = typeof sourcePath === "string" ? sourcePath.trim() : "";
|
|
3540
|
+
if (!rawSource) return { resourcePath };
|
|
3541
|
+
try {
|
|
3542
|
+
const expandedSource = recoverLikelyDroppedLeadingSlashPath(expandHome(rawSource));
|
|
3543
|
+
const requestedSource = isAbsolute(expandedSource) ? expandedSource : resolve(fallbackCwd, expandedSource);
|
|
3544
|
+
const canonicalSource = realpathSync(requestedSource);
|
|
3545
|
+
const sourceGrant = resourceGrants.findGrant(dirname(canonicalSource), { cwd: fallbackCwd });
|
|
3546
|
+
if (statSync(canonicalSource).isFile() && sourceGrant?.kind === "directory") {
|
|
3547
|
+
return { resourcePath, sourcePath: canonicalSource };
|
|
3548
|
+
}
|
|
3549
|
+
} catch {
|
|
3550
|
+
// The submitted Markdown can still render without file-adjacent metadata.
|
|
3551
|
+
}
|
|
3552
|
+
return { resourcePath };
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3384
3555
|
function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
|
|
3385
3556
|
const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
3386
3557
|
if (!normalized) return undefined;
|
|
@@ -5191,11 +5362,34 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
|
|
|
5191
5362
|
return /^\s*header-includes\s*:/im.test(split.frontMatter);
|
|
5192
5363
|
}
|
|
5193
5364
|
|
|
5365
|
+
function encodeStudioLocalFileUrlForPandoc(resourceUrl: string): string {
|
|
5366
|
+
try {
|
|
5367
|
+
const url = new URL(resourceUrl);
|
|
5368
|
+
if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) return resourceUrl;
|
|
5369
|
+
const suffix = `${url.search}${url.hash}`;
|
|
5370
|
+
url.search = "";
|
|
5371
|
+
url.hash = "";
|
|
5372
|
+
const localPath = fileURLToPath(url);
|
|
5373
|
+
return encodeURI(localPath).replace(/\?/g, "%3F").replace(/#/g, "%23") + suffix;
|
|
5374
|
+
} catch {
|
|
5375
|
+
return resourceUrl;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
|
|
5379
|
+
function normalizeStudioMarkdownFileUrlDestinationsForPandoc(markdown: string): string {
|
|
5380
|
+
return transformStudioMarkdownOutsideFences(markdown, (segment: string) => (
|
|
5381
|
+
segment.replace(/(\]\(\s*<?)(file:\/\/(?:localhost)?\/[^<>\s)]+)/gi, (_match, prefix: string, resourceUrl: string) => (
|
|
5382
|
+
`${prefix}${encodeStudioLocalFileUrlForPandoc(resourceUrl)}`
|
|
5383
|
+
))
|
|
5384
|
+
));
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5194
5387
|
function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
|
|
5195
5388
|
const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
|
|
5196
5389
|
return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
|
|
5197
5390
|
const normalizedFences = normalizeStudioMarkdownSmartFences(body);
|
|
5198
|
-
const
|
|
5391
|
+
const normalizedFileUrls = normalizeStudioMarkdownFileUrlDestinationsForPandoc(normalizedFences);
|
|
5392
|
+
const normalizedMath = normalizeMathDelimiters(normalizedFileUrls);
|
|
5199
5393
|
const latexReady = shouldPreserveLiteralLatexCommands
|
|
5200
5394
|
? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
|
|
5201
5395
|
: normalizedMath;
|
|
@@ -6413,7 +6607,13 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
|
|
|
6413
6607
|
});
|
|
6414
6608
|
}
|
|
6415
6609
|
|
|
6416
|
-
async function renderStudioMarkdownWithPandoc(
|
|
6610
|
+
async function renderStudioMarkdownWithPandoc(
|
|
6611
|
+
markdown: string,
|
|
6612
|
+
isLatex?: boolean,
|
|
6613
|
+
resourcePath?: string,
|
|
6614
|
+
sourcePath?: string,
|
|
6615
|
+
options?: { embedResources?: boolean },
|
|
6616
|
+
): Promise<string> {
|
|
6417
6617
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
6418
6618
|
const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
|
|
6419
6619
|
?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
|
|
@@ -6451,7 +6651,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
6451
6651
|
await mkdir(htmlTemplateDir, { recursive: true });
|
|
6452
6652
|
const htmlTemplatePath = join(htmlTemplateDir, "template.html");
|
|
6453
6653
|
await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
|
|
6454
|
-
if (resourcePath
|
|
6654
|
+
if (resourcePath && options?.embedResources !== false) {
|
|
6655
|
+
args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
|
|
6656
|
+
}
|
|
6455
6657
|
args.push("--standalone", `--template=${htmlTemplatePath}`);
|
|
6456
6658
|
}
|
|
6457
6659
|
const normalizedMarkdown = isLatex
|
|
@@ -7599,6 +7801,29 @@ function respondStudioPendingError(res: ServerResponse, status: number, text: st
|
|
|
7599
7801
|
res.end(text);
|
|
7600
7802
|
}
|
|
7601
7803
|
|
|
7804
|
+
function respondStudioResourceGrantRequiredJson(res: ServerResponse, error: StudioResourceGrantRequiredError): void {
|
|
7805
|
+
respondJson(res, 403, {
|
|
7806
|
+
ok: false,
|
|
7807
|
+
code: error.code,
|
|
7808
|
+
error: error.message,
|
|
7809
|
+
path: error.filePath,
|
|
7810
|
+
directoryPath: error.directoryPath,
|
|
7811
|
+
label: basename(error.filePath),
|
|
7812
|
+
resourceKind: error.resourceKind,
|
|
7813
|
+
});
|
|
7814
|
+
}
|
|
7815
|
+
|
|
7816
|
+
function respondStudioDirectoryGrantRequiredJson(res: ServerResponse, error: StudioDirectoryGrantRequiredError): void {
|
|
7817
|
+
respondJson(res, 403, {
|
|
7818
|
+
ok: false,
|
|
7819
|
+
code: error.code,
|
|
7820
|
+
error: error.message,
|
|
7821
|
+
path: error.directoryPath,
|
|
7822
|
+
directoryPath: error.directoryPath,
|
|
7823
|
+
label: basename(error.directoryPath) || error.directoryPath,
|
|
7824
|
+
});
|
|
7825
|
+
}
|
|
7826
|
+
|
|
7602
7827
|
function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
|
|
7603
7828
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7604
7829
|
if (method !== "GET" && method !== "HEAD") {
|
|
@@ -7661,7 +7886,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
|
|
|
7661
7886
|
|
|
7662
7887
|
function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
|
|
7663
7888
|
const label = basename(resource.filePath) || resource.label || "local preview";
|
|
7664
|
-
const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
|
|
7889
|
+
const resourcePath = resource.referencePath || resource.label || basename(resource.filePath) || resource.filePath;
|
|
7665
7890
|
const title = sanitizeStudioPreviewBlockLine(label);
|
|
7666
7891
|
let text = "";
|
|
7667
7892
|
if (resource.kind === "pdf") {
|
|
@@ -7720,7 +7945,14 @@ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPrevie
|
|
|
7720
7945
|
return { text: `${note}\n\n${body}\n`, label };
|
|
7721
7946
|
}
|
|
7722
7947
|
|
|
7723
|
-
async function respondLocalPreviewLinkJson(
|
|
7948
|
+
async function respondLocalPreviewLinkJson(
|
|
7949
|
+
req: IncomingMessage,
|
|
7950
|
+
res: ServerResponse,
|
|
7951
|
+
requestUrl: URL,
|
|
7952
|
+
resource: StudioLocalPreviewResource,
|
|
7953
|
+
serverState: StudioServerState,
|
|
7954
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
7955
|
+
): Promise<void> {
|
|
7724
7956
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7725
7957
|
if (method !== "GET" && method !== "HEAD") {
|
|
7726
7958
|
res.setHeader("Allow", "GET, HEAD");
|
|
@@ -7770,6 +8002,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7770
8002
|
let document: InitialStudioDocument;
|
|
7771
8003
|
let responseText = "";
|
|
7772
8004
|
let converted = false;
|
|
8005
|
+
let documentResourceDir = resource.resourceDir;
|
|
7773
8006
|
if (resource.kind === "office") {
|
|
7774
8007
|
let conversion: { text: string; label: string };
|
|
7775
8008
|
try {
|
|
@@ -7793,12 +8026,18 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7793
8026
|
return;
|
|
7794
8027
|
}
|
|
7795
8028
|
responseText = file.text;
|
|
8029
|
+
try {
|
|
8030
|
+
const documentGrant = resourceGrants?.grantDocument(file.resolvedPath, { source: "document" });
|
|
8031
|
+
if (documentGrant?.path) documentResourceDir = documentGrant.path;
|
|
8032
|
+
} catch {
|
|
8033
|
+
// The exact-file grant remains usable if the bounded registry cannot add the document directory.
|
|
8034
|
+
}
|
|
7796
8035
|
document = {
|
|
7797
8036
|
text: file.text,
|
|
7798
8037
|
label: resource.label || file.label,
|
|
7799
8038
|
source: "file",
|
|
7800
8039
|
path: file.resolvedPath,
|
|
7801
|
-
resourceDir:
|
|
8040
|
+
resourceDir: documentResourceDir,
|
|
7802
8041
|
};
|
|
7803
8042
|
}
|
|
7804
8043
|
if (action === "document") {
|
|
@@ -7807,7 +8046,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7807
8046
|
text: responseText,
|
|
7808
8047
|
label: document.label,
|
|
7809
8048
|
converted,
|
|
7810
|
-
resourceDir:
|
|
8049
|
+
resourceDir: documentResourceDir,
|
|
7811
8050
|
});
|
|
7812
8051
|
return;
|
|
7813
8052
|
}
|
|
@@ -7816,6 +8055,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7816
8055
|
respondJson(res, 200, {
|
|
7817
8056
|
...basePayload,
|
|
7818
8057
|
converted,
|
|
8058
|
+
resourceDir: documentResourceDir,
|
|
7819
8059
|
relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
|
|
7820
8060
|
});
|
|
7821
8061
|
}
|
|
@@ -7906,7 +8146,61 @@ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: Serv
|
|
|
7906
8146
|
});
|
|
7907
8147
|
}
|
|
7908
8148
|
|
|
7909
|
-
async function
|
|
8149
|
+
async function handleStudioResourceGrantRequest(
|
|
8150
|
+
req: IncomingMessage,
|
|
8151
|
+
res: ServerResponse,
|
|
8152
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
8153
|
+
studioCwd: string,
|
|
8154
|
+
): Promise<void> {
|
|
8155
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
8156
|
+
if (method !== "POST") {
|
|
8157
|
+
res.setHeader("Allow", "POST");
|
|
8158
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
8159
|
+
return;
|
|
8160
|
+
}
|
|
8161
|
+
|
|
8162
|
+
const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
8163
|
+
let payload: Record<string, unknown> = {};
|
|
8164
|
+
try {
|
|
8165
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
8166
|
+
} catch {
|
|
8167
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
8168
|
+
return;
|
|
8169
|
+
}
|
|
8170
|
+
|
|
8171
|
+
const grantKind = payload.grantKind === "directory" ? "directory" : payload.grantKind === "file" ? "file" : "";
|
|
8172
|
+
const path = typeof payload.path === "string" ? payload.path.trim() : "";
|
|
8173
|
+
if (!grantKind || !path) {
|
|
8174
|
+
respondJson(res, 400, { ok: false, error: "Choose an exact file or folder to allow." });
|
|
8175
|
+
return;
|
|
8176
|
+
}
|
|
8177
|
+
|
|
8178
|
+
try {
|
|
8179
|
+
const grant = grantKind === "directory"
|
|
8180
|
+
? resourceGrants.grantDirectory(path, { cwd: studioCwd, source: "explicit-directory" })
|
|
8181
|
+
: resourceGrants.grantFile(path, { cwd: studioCwd, source: "explicit-file" });
|
|
8182
|
+
respondJson(res, 200, {
|
|
8183
|
+
ok: true,
|
|
8184
|
+
grant,
|
|
8185
|
+
resourceGrants: resourceGrants.snapshot(),
|
|
8186
|
+
message: grantKind === "directory"
|
|
8187
|
+
? `Allowed this folder for the current Studio session: ${grant.path}`
|
|
8188
|
+
: `Allowed this file for the current Studio session: ${grant.path}`,
|
|
8189
|
+
});
|
|
8190
|
+
} catch (error) {
|
|
8191
|
+
respondJson(res, 400, {
|
|
8192
|
+
ok: false,
|
|
8193
|
+
error: `Could not allow this ${grantKind}: ${error instanceof Error ? error.message : String(error)}`,
|
|
8194
|
+
});
|
|
8195
|
+
}
|
|
8196
|
+
}
|
|
8197
|
+
|
|
8198
|
+
async function handleRevealLocalPreviewResourceRequest(
|
|
8199
|
+
req: IncomingMessage,
|
|
8200
|
+
res: ServerResponse,
|
|
8201
|
+
studioCwd: string,
|
|
8202
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
8203
|
+
): Promise<void> {
|
|
7910
8204
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7911
8205
|
if (method !== "POST") {
|
|
7912
8206
|
res.setHeader("Allow", "POST");
|
|
@@ -7929,6 +8223,7 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
|
|
|
7929
8223
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
7930
8224
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
7931
8225
|
studioCwd,
|
|
8226
|
+
resourceGrants,
|
|
7932
8227
|
);
|
|
7933
8228
|
const result = revealStudioLocalFile(resource.filePath);
|
|
7934
8229
|
if (!result.ok) {
|
|
@@ -7937,11 +8232,20 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
|
|
|
7937
8232
|
}
|
|
7938
8233
|
respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
|
|
7939
8234
|
} catch (error) {
|
|
8235
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
8236
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
8237
|
+
return;
|
|
8238
|
+
}
|
|
7940
8239
|
respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
7941
8240
|
}
|
|
7942
8241
|
}
|
|
7943
8242
|
|
|
7944
|
-
async function handleOpenLocalPreviewResourceRequest(
|
|
8243
|
+
async function handleOpenLocalPreviewResourceRequest(
|
|
8244
|
+
req: IncomingMessage,
|
|
8245
|
+
res: ServerResponse,
|
|
8246
|
+
studioCwd: string,
|
|
8247
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
8248
|
+
): Promise<void> {
|
|
7945
8249
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7946
8250
|
if (method !== "POST") {
|
|
7947
8251
|
res.setHeader("Allow", "POST");
|
|
@@ -7968,6 +8272,7 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
|
|
|
7968
8272
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
7969
8273
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
7970
8274
|
studioCwd,
|
|
8275
|
+
resourceGrants,
|
|
7971
8276
|
);
|
|
7972
8277
|
if (resource.kind !== "pdf") {
|
|
7973
8278
|
respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
|
|
@@ -7981,6 +8286,10 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
|
|
|
7981
8286
|
label: resource.label,
|
|
7982
8287
|
});
|
|
7983
8288
|
} catch (error) {
|
|
8289
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
8290
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
8291
|
+
return;
|
|
8292
|
+
}
|
|
7984
8293
|
respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
7985
8294
|
}
|
|
7986
8295
|
}
|
|
@@ -8006,7 +8315,12 @@ function openPathInDefaultViewer(path: string): Promise<void> {
|
|
|
8006
8315
|
});
|
|
8007
8316
|
}
|
|
8008
8317
|
|
|
8009
|
-
async function handleOpenStudioFileBrowserDirectoryRequest(
|
|
8318
|
+
async function handleOpenStudioFileBrowserDirectoryRequest(
|
|
8319
|
+
req: IncomingMessage,
|
|
8320
|
+
res: ServerResponse,
|
|
8321
|
+
studioCwd: string,
|
|
8322
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
8323
|
+
): Promise<void> {
|
|
8010
8324
|
const method = (req.method ?? "GET").toUpperCase();
|
|
8011
8325
|
if (method !== "POST") {
|
|
8012
8326
|
res.setHeader("Allow", "POST");
|
|
@@ -8030,13 +8344,25 @@ async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage,
|
|
|
8030
8344
|
try {
|
|
8031
8345
|
const directory = resolveStudioFileBrowserDirectory(
|
|
8032
8346
|
typeof payload.dir === "string" ? payload.dir : undefined,
|
|
8347
|
+
typeof payload.root === "string" ? payload.root : undefined,
|
|
8033
8348
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
8034
8349
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
8035
8350
|
studioCwd,
|
|
8351
|
+
resourceGrants,
|
|
8036
8352
|
);
|
|
8037
8353
|
await openPathInDefaultViewer(directory.currentDir);
|
|
8038
8354
|
respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
|
|
8039
8355
|
} catch (error) {
|
|
8356
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
8357
|
+
respondJson(res, 403, {
|
|
8358
|
+
ok: false,
|
|
8359
|
+
code: error.code,
|
|
8360
|
+
error: error.message,
|
|
8361
|
+
path: error.directoryPath,
|
|
8362
|
+
directoryPath: error.directoryPath,
|
|
8363
|
+
});
|
|
8364
|
+
return;
|
|
8365
|
+
}
|
|
8040
8366
|
respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
|
|
8041
8367
|
}
|
|
8042
8368
|
}
|
|
@@ -8504,10 +8830,11 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8504
8830
|
const query = String(queryInput || "").trim();
|
|
8505
8831
|
if (!query) throw new Error("Local context search query is empty.");
|
|
8506
8832
|
if (query.length > 500) throw new Error("Local context search query is too long.");
|
|
8833
|
+
const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
|
|
8507
8834
|
const target = options.path
|
|
8508
|
-
? resolveStudioSideQuestionPath(
|
|
8509
|
-
:
|
|
8510
|
-
const targetRelative = relative(
|
|
8835
|
+
? resolveStudioSideQuestionPath(stableRoot, options.path, { directory: true }).path
|
|
8836
|
+
: stableRoot;
|
|
8837
|
+
const targetRelative = relative(stableRoot, target) || ".";
|
|
8511
8838
|
const maxResults = Math.max(1, Math.min(200, Math.floor(Number(options.maxResults) || 60)));
|
|
8512
8839
|
const args = [
|
|
8513
8840
|
"--fixed-strings",
|
|
@@ -8527,7 +8854,7 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8527
8854
|
args.push("--", query, targetRelative);
|
|
8528
8855
|
try {
|
|
8529
8856
|
const result = await runStudioSubprocess("rg", args, {
|
|
8530
|
-
cwd:
|
|
8857
|
+
cwd: stableRoot,
|
|
8531
8858
|
signal: options.signal,
|
|
8532
8859
|
timeoutMs: 20_000,
|
|
8533
8860
|
stdoutMaxBytes: 250_000,
|
|
@@ -8541,11 +8868,11 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8541
8868
|
if (!match) return [];
|
|
8542
8869
|
return [{ path: match[1].split("\\").join("/"), line: Number(match[2]), text: match[3].trim().slice(0, 1_000) }];
|
|
8543
8870
|
}).slice(0, maxResults);
|
|
8544
|
-
return { root:
|
|
8871
|
+
return { root: stableRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
|
|
8545
8872
|
} catch (error) {
|
|
8546
8873
|
if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
|
|
8547
8874
|
if (!(error instanceof Error) || !error.message.includes("__PI_STUDIO_RG_NOT_FOUND__")) throw error;
|
|
8548
|
-
return searchStudioSideQuestionContext(
|
|
8875
|
+
return searchStudioSideQuestionContext(stableRoot, query, {
|
|
8549
8876
|
path: targetRelative,
|
|
8550
8877
|
caseSensitive: options.caseSensitive,
|
|
8551
8878
|
maxResults,
|
|
@@ -8647,7 +8974,12 @@ function formatStudioSideQuestionGitSnapshotResult(
|
|
|
8647
8974
|
};
|
|
8648
8975
|
}
|
|
8649
8976
|
|
|
8650
|
-
function createStudioSideQuestionTools(
|
|
8977
|
+
function createStudioSideQuestionTools(
|
|
8978
|
+
contextRoot: string,
|
|
8979
|
+
webEnabled: boolean,
|
|
8980
|
+
gitSnapshot: StudioSideQuestionGitSnapshot | null = null,
|
|
8981
|
+
assertContextRoot?: () => string,
|
|
8982
|
+
) {
|
|
8651
8983
|
const mapTool = defineTool({
|
|
8652
8984
|
name: "studio_context_map",
|
|
8653
8985
|
label: "Map local context",
|
|
@@ -8658,6 +8990,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8658
8990
|
}),
|
|
8659
8991
|
async execute(_toolCallId, params, signal) {
|
|
8660
8992
|
if (signal?.aborted) throw new Error("Context map was cancelled.");
|
|
8993
|
+
if (assertContextRoot) assertContextRoot();
|
|
8661
8994
|
const target = params.path
|
|
8662
8995
|
? resolveStudioSideQuestionPath(contextRoot, params.path, { directory: true }).path
|
|
8663
8996
|
: contextRoot;
|
|
@@ -8680,6 +9013,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8680
9013
|
}),
|
|
8681
9014
|
async execute(_toolCallId, params, signal) {
|
|
8682
9015
|
if (signal?.aborted) throw new Error("Context read was cancelled.");
|
|
9016
|
+
if (assertContextRoot) assertContextRoot();
|
|
8683
9017
|
const result = readStudioSideQuestionContextText(contextRoot, params.path, { offset: params.offset, limit: params.limit, maxChars: STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS });
|
|
8684
9018
|
const extractableResult = result as { path: string; relativePath: string; extension?: string };
|
|
8685
9019
|
const content: { text: string; startLine: number; endLine: number; totalLines: number; truncated: boolean } = result.requiresExtraction
|
|
@@ -8703,6 +9037,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8703
9037
|
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
|
|
8704
9038
|
}),
|
|
8705
9039
|
async execute(_toolCallId, params, signal) {
|
|
9040
|
+
if (assertContextRoot) assertContextRoot();
|
|
8706
9041
|
const result = await searchStudioSideQuestionLocalContext(contextRoot, params.query, {
|
|
8707
9042
|
path: params.path,
|
|
8708
9043
|
caseSensitive: params.caseSensitive,
|
|
@@ -11671,7 +12006,7 @@ ${cssVarsBlock}
|
|
|
11671
12006
|
<section id="rightPane">
|
|
11672
12007
|
<div id="rightSectionHeader" class="section-header">
|
|
11673
12008
|
<div class="section-header-main">
|
|
11674
|
-
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/Q keep
|
|
12009
|
+
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
|
|
11675
12010
|
<option value="markdown">Response (Raw)</option>
|
|
11676
12011
|
<option value="preview" selected>Response (Preview)</option>
|
|
11677
12012
|
<option value="editor-preview">Editor (Preview)</option>
|
|
@@ -11780,6 +12115,7 @@ ${cssVarsBlock}
|
|
|
11780
12115
|
<div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
|
|
11781
12116
|
<div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
|
|
11782
12117
|
<div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
|
|
12118
|
+
<div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
|
|
11783
12119
|
<div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
|
|
11784
12120
|
<div><dt>F8</dt><dd>Focus editor text</dd></div>
|
|
11785
12121
|
<div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
|
|
@@ -11876,6 +12212,7 @@ ${cssVarsBlock}
|
|
|
11876
12212
|
export default function (pi: ExtensionAPI) {
|
|
11877
12213
|
let serverState: StudioServerState | null = null;
|
|
11878
12214
|
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
12215
|
+
const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
|
|
11879
12216
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
11880
12217
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
11881
12218
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
@@ -11936,6 +12273,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
11936
12273
|
actionRequestId: null,
|
|
11937
12274
|
};
|
|
11938
12275
|
|
|
12276
|
+
const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
|
|
12277
|
+
if (document.path) {
|
|
12278
|
+
try {
|
|
12279
|
+
studioResourceGrantRegistry.grantDocument(document.path, { cwd, source: "document" });
|
|
12280
|
+
} catch {
|
|
12281
|
+
// A failed grant must not broaden access or prevent an otherwise valid launch.
|
|
12282
|
+
}
|
|
12283
|
+
}
|
|
12284
|
+
if (document.resourceDir) {
|
|
12285
|
+
try {
|
|
12286
|
+
studioResourceGrantRegistry.grantDirectory(document.resourceDir, { cwd, source: "workspace" });
|
|
12287
|
+
} catch {
|
|
12288
|
+
// A failed workspace grant must not broaden access or prevent an otherwise valid launch.
|
|
12289
|
+
}
|
|
12290
|
+
}
|
|
12291
|
+
};
|
|
12292
|
+
|
|
11939
12293
|
const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
|
|
11940
12294
|
const state = listStudioReplSessions();
|
|
11941
12295
|
const sessions = state.sessions;
|
|
@@ -13604,7 +13958,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13604
13958
|
await closeStudioSideQuestionRuntime(runtime);
|
|
13605
13959
|
};
|
|
13606
13960
|
|
|
13607
|
-
const resolveStudioSideQuestionContextRoot = (context:
|
|
13961
|
+
const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => {
|
|
13608
13962
|
if (context.gatherScope === "none") return "";
|
|
13609
13963
|
const sourcePath = resolveStudioQuizContextPath(context.sourcePath, studioCwd);
|
|
13610
13964
|
const resourceDir = resolveStudioQuizContextPath(context.resourceDir, studioCwd);
|
|
@@ -13627,6 +13981,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
13627
13981
|
return resolveStudioSideQuestionRoot(root, studioCwd);
|
|
13628
13982
|
};
|
|
13629
13983
|
|
|
13984
|
+
const assertStudioSideQuestionContextRootAuthorized = (contextRoot: string): string => {
|
|
13985
|
+
if (!contextRoot) return "";
|
|
13986
|
+
const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
|
|
13987
|
+
const grant = studioResourceGrantRegistry.findGrant(stableRoot, { cwd: studioCwd });
|
|
13988
|
+
if (!grant || grant.kind !== "directory") throw new StudioDirectoryGrantRequiredError(stableRoot);
|
|
13989
|
+
return stableRoot;
|
|
13990
|
+
};
|
|
13991
|
+
|
|
13992
|
+
const resolveAuthorizedStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => (
|
|
13993
|
+
assertStudioSideQuestionContextRootAuthorized(resolveStudioSideQuestionContextRoot(context))
|
|
13994
|
+
);
|
|
13995
|
+
|
|
13996
|
+
const handleStudioSideQuestionContextRootRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
13997
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
13998
|
+
if (method !== "POST") {
|
|
13999
|
+
res.setHeader("Allow", "POST");
|
|
14000
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
14001
|
+
return;
|
|
14002
|
+
}
|
|
14003
|
+
const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
14004
|
+
let payload: Record<string, unknown> = {};
|
|
14005
|
+
try {
|
|
14006
|
+
payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
|
|
14007
|
+
} catch {
|
|
14008
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
14009
|
+
return;
|
|
14010
|
+
}
|
|
14011
|
+
const context: StudioSideQuestionContextRootInput = {
|
|
14012
|
+
gatherScope: normalizeStudioSideQuestionGatherScope(payload.gatherScope) as StudioSideQuestionGatherScope,
|
|
14013
|
+
sourcePath: typeof payload.sourcePath === "string" ? payload.sourcePath.slice(0, 16_384) : undefined,
|
|
14014
|
+
resourceDir: typeof payload.resourceDir === "string" ? payload.resourceDir.slice(0, 16_384) : undefined,
|
|
14015
|
+
contextPath: typeof payload.contextPath === "string" ? payload.contextPath.slice(0, 16_384) : undefined,
|
|
14016
|
+
};
|
|
14017
|
+
try {
|
|
14018
|
+
const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
|
|
14019
|
+
respondJson(res, 200, { ok: true, contextRoot, authorized: Boolean(contextRoot) });
|
|
14020
|
+
} catch (error) {
|
|
14021
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
14022
|
+
respondStudioDirectoryGrantRequiredJson(res, error);
|
|
14023
|
+
return;
|
|
14024
|
+
}
|
|
14025
|
+
respondJson(res, 400, { ok: false, error: `Side-question context unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
14026
|
+
}
|
|
14027
|
+
};
|
|
14028
|
+
|
|
13630
14029
|
const describeStudioSideQuestionActivity = (toolName: string, args: unknown): string => {
|
|
13631
14030
|
const value = args && typeof args === "object" ? args as Record<string, unknown> : {};
|
|
13632
14031
|
if (toolName === "studio_context_read") return `Reading ${String(value.path || "local context")}`;
|
|
@@ -13729,19 +14128,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13729
14128
|
const createStudioSideQuestionRuntime = async (context: StudioSideQuestionContextInput): Promise<StudioSideQuestionRuntime> => {
|
|
13730
14129
|
const ctx = lastCommandCtx;
|
|
13731
14130
|
if (!ctx) throw new Error("No active Studio command context is available. Re-open Studio and try again.");
|
|
13732
|
-
const
|
|
13733
|
-
if (!model) throw new Error("No active Pi model is available for side questions.");
|
|
13734
|
-
await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
|
|
13735
|
-
const contextRoot = resolveStudioSideQuestionContextRoot(context);
|
|
14131
|
+
const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
|
|
13736
14132
|
if (context.gitContext && context.gatherScope !== "repo") {
|
|
13737
14133
|
throw new Error("Git context requires Related files to be set to Repository.");
|
|
13738
14134
|
}
|
|
14135
|
+
const model = latestModelRequestCtx?.model ?? ctx.model;
|
|
14136
|
+
if (!model) throw new Error("No active Pi model is available for side questions.");
|
|
14137
|
+
await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
|
|
13739
14138
|
const gitSnapshot = context.gitContext
|
|
13740
14139
|
? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
|
|
13741
14140
|
: null;
|
|
13742
14141
|
const webAvailable = Boolean(String(process.env.BRAVE_API_KEY || "").trim());
|
|
13743
14142
|
const webEnabled = context.webSearch && webAvailable;
|
|
13744
|
-
const
|
|
14143
|
+
const assertContextRoot = contextRoot ? () => assertStudioSideQuestionContextRootAuthorized(contextRoot) : undefined;
|
|
14144
|
+
const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot, assertContextRoot);
|
|
13745
14145
|
const localActiveToolNames = context.gatherScope === "none" ? (webEnabled ? ["studio_web_search"] : []) : toolNames;
|
|
13746
14146
|
const toolSelection = selectStudioSideQuestionTools(getStudioSideQuestionToolCatalog(), context.toolIds);
|
|
13747
14147
|
if (toolSelection.missing.length > 0) {
|
|
@@ -13924,6 +14324,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13924
14324
|
sendStudioSideQuestionState();
|
|
13925
14325
|
try {
|
|
13926
14326
|
const context = msg.context;
|
|
14327
|
+
if (runtime.contextRoot) assertStudioSideQuestionContextRootAuthorized(runtime.contextRoot);
|
|
13927
14328
|
let prompt: string;
|
|
13928
14329
|
if (isFollowUp) {
|
|
13929
14330
|
prompt = buildStudioSideQuestionFollowUpPrompt(msg.question);
|
|
@@ -14040,6 +14441,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14040
14441
|
responseHistory: studioResponseHistory,
|
|
14041
14442
|
traceState: studioTraceState,
|
|
14042
14443
|
initialDocument: initialStudioDocument,
|
|
14444
|
+
resourceGrants: studioResourceGrantRegistry.snapshot(),
|
|
14043
14445
|
quartoPreview: getStudioQuartoPreviewSnapshot(),
|
|
14044
14446
|
sideQuestion: getStudioSideQuestionPublicState(),
|
|
14045
14447
|
webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
|
|
@@ -14996,6 +15398,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14996
15398
|
path: result.resolvedPath,
|
|
14997
15399
|
resourceDir: dirname(result.resolvedPath),
|
|
14998
15400
|
};
|
|
15401
|
+
recordStudioDocumentResourceGrants(initialStudioDocument);
|
|
14999
15402
|
|
|
15000
15403
|
sendToClient(client, {
|
|
15001
15404
|
type: "saved",
|
|
@@ -15088,6 +15491,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15088
15491
|
};
|
|
15089
15492
|
if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
|
|
15090
15493
|
initialStudioDocument = refreshedDocument;
|
|
15494
|
+
recordStudioDocumentResourceGrants(refreshedDocument);
|
|
15091
15495
|
}
|
|
15092
15496
|
|
|
15093
15497
|
sendToClient(client, {
|
|
@@ -15592,14 +15996,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
15592
15996
|
parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorLanguage?: unknown }).editorLanguage === "string"
|
|
15593
15997
|
? (parsedBody as { editorLanguage: string }).editorLanguage
|
|
15594
15998
|
: "";
|
|
15595
|
-
const
|
|
15999
|
+
const authorizedContext = resolveStudioAuthorizedPreviewRenderContext(
|
|
16000
|
+
sourcePath || undefined,
|
|
16001
|
+
userResourceDir || undefined,
|
|
16002
|
+
studioCwd,
|
|
16003
|
+
studioResourceGrantRegistry,
|
|
16004
|
+
);
|
|
15596
16005
|
const editorPreviewLanguage = normalizeStudioEditorLanguage(requestedEditorLanguage);
|
|
15597
16006
|
const isLatex = editorPreviewLanguage === "latex"
|
|
15598
16007
|
|| (
|
|
15599
16008
|
(editorPreviewLanguage === undefined || editorPreviewLanguage === "markdown")
|
|
15600
16009
|
&& isLikelyStandaloneLatexPreview(markdown)
|
|
15601
16010
|
);
|
|
15602
|
-
const html = await renderStudioMarkdownWithPandoc(
|
|
16011
|
+
const html = await renderStudioMarkdownWithPandoc(
|
|
16012
|
+
markdown,
|
|
16013
|
+
isLatex,
|
|
16014
|
+
authorizedContext.resourcePath,
|
|
16015
|
+
authorizedContext.sourcePath,
|
|
16016
|
+
{ embedResources: false },
|
|
16017
|
+
);
|
|
15603
16018
|
respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
|
|
15604
16019
|
} catch (error) {
|
|
15605
16020
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -16342,13 +16757,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
16342
16757
|
try {
|
|
16343
16758
|
const listing = listStudioFileBrowserDirectory(
|
|
16344
16759
|
requestUrl.searchParams.get("dir") ?? undefined,
|
|
16760
|
+
requestUrl.searchParams.get("root") ?? undefined,
|
|
16345
16761
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16346
16762
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16347
16763
|
studioCwd,
|
|
16764
|
+
studioResourceGrantRegistry,
|
|
16348
16765
|
requestUrl.searchParams.get("sort") ?? undefined,
|
|
16349
16766
|
);
|
|
16350
|
-
respondJson(res, 200, {
|
|
16767
|
+
respondJson(res, 200, {
|
|
16768
|
+
ok: true,
|
|
16769
|
+
...listing,
|
|
16770
|
+
entries: method === "HEAD" ? [] : listing.entries,
|
|
16771
|
+
exactFiles: method === "HEAD" ? [] : listing.exactFiles,
|
|
16772
|
+
});
|
|
16351
16773
|
} catch (error) {
|
|
16774
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
16775
|
+
respondJson(res, 403, {
|
|
16776
|
+
ok: false,
|
|
16777
|
+
code: error.code,
|
|
16778
|
+
error: error.message,
|
|
16779
|
+
path: error.directoryPath,
|
|
16780
|
+
directoryPath: error.directoryPath,
|
|
16781
|
+
...getStudioFileBrowserGrantLocations(studioResourceGrantRegistry),
|
|
16782
|
+
});
|
|
16783
|
+
return;
|
|
16784
|
+
}
|
|
16352
16785
|
respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16353
16786
|
}
|
|
16354
16787
|
return;
|
|
@@ -16361,7 +16794,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16361
16794
|
return;
|
|
16362
16795
|
}
|
|
16363
16796
|
|
|
16364
|
-
void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
|
|
16797
|
+
void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16365
16798
|
respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16366
16799
|
});
|
|
16367
16800
|
return;
|
|
@@ -16380,6 +16813,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
16380
16813
|
return;
|
|
16381
16814
|
}
|
|
16382
16815
|
|
|
16816
|
+
if (requestUrl.pathname === "/resource-grants") {
|
|
16817
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
16818
|
+
if (token !== serverState.token) {
|
|
16819
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
16820
|
+
return;
|
|
16821
|
+
}
|
|
16822
|
+
|
|
16823
|
+
void handleStudioResourceGrantRequest(req, res, studioResourceGrantRegistry, studioCwd).catch((error) => {
|
|
16824
|
+
respondJson(res, 500, { ok: false, error: `Resource grant failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16825
|
+
});
|
|
16826
|
+
return;
|
|
16827
|
+
}
|
|
16828
|
+
|
|
16829
|
+
if (requestUrl.pathname === "/side-question-context-root") {
|
|
16830
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
16831
|
+
if (token !== serverState.token) {
|
|
16832
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
16833
|
+
return;
|
|
16834
|
+
}
|
|
16835
|
+
|
|
16836
|
+
void handleStudioSideQuestionContextRootRequest(req, res).catch((error) => {
|
|
16837
|
+
respondJson(res, 500, { ok: false, error: `Side-question context check failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16838
|
+
});
|
|
16839
|
+
return;
|
|
16840
|
+
}
|
|
16841
|
+
|
|
16383
16842
|
if (requestUrl.pathname === "/local-preview-link") {
|
|
16384
16843
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
16385
16844
|
if (token !== serverState.token) {
|
|
@@ -16394,9 +16853,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
16394
16853
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16395
16854
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16396
16855
|
studioCwd,
|
|
16856
|
+
studioResourceGrantRegistry,
|
|
16397
16857
|
);
|
|
16398
|
-
await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
|
|
16858
|
+
await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState, studioResourceGrantRegistry);
|
|
16399
16859
|
} catch (error) {
|
|
16860
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
16861
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
16862
|
+
return;
|
|
16863
|
+
}
|
|
16400
16864
|
respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16401
16865
|
}
|
|
16402
16866
|
})();
|
|
@@ -16410,7 +16874,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16410
16874
|
return;
|
|
16411
16875
|
}
|
|
16412
16876
|
|
|
16413
|
-
void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
|
|
16877
|
+
void handleRevealLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16414
16878
|
respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16415
16879
|
});
|
|
16416
16880
|
return;
|
|
@@ -16423,7 +16887,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16423
16887
|
return;
|
|
16424
16888
|
}
|
|
16425
16889
|
|
|
16426
|
-
void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
|
|
16890
|
+
void handleOpenLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16427
16891
|
respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16428
16892
|
});
|
|
16429
16893
|
return;
|
|
@@ -16442,9 +16906,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
16442
16906
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16443
16907
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16444
16908
|
studioCwd,
|
|
16909
|
+
studioResourceGrantRegistry,
|
|
16445
16910
|
);
|
|
16446
16911
|
respondPdfFile(req, res, filePath);
|
|
16447
16912
|
} catch (error) {
|
|
16913
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
16914
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
16915
|
+
return;
|
|
16916
|
+
}
|
|
16448
16917
|
respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
16449
16918
|
}
|
|
16450
16919
|
return;
|
|
@@ -16463,10 +16932,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
16463
16932
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16464
16933
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16465
16934
|
studioCwd,
|
|
16935
|
+
studioResourceGrantRegistry,
|
|
16466
16936
|
);
|
|
16467
16937
|
respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
|
|
16468
16938
|
} catch (error) {
|
|
16469
|
-
|
|
16939
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
16940
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
16941
|
+
return;
|
|
16942
|
+
}
|
|
16943
|
+
respondJson(res, 404, { ok: false, error: `Studio preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16470
16944
|
}
|
|
16471
16945
|
return;
|
|
16472
16946
|
}
|
|
@@ -16675,6 +17149,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16675
17149
|
state.server.close(() => resolve());
|
|
16676
17150
|
});
|
|
16677
17151
|
studioWorkspaceStateStore.clear();
|
|
17152
|
+
studioResourceGrantRegistry.clear();
|
|
16678
17153
|
};
|
|
16679
17154
|
|
|
16680
17155
|
const hydrateLatestAssistant = (entries: SessionEntry[]) => {
|
|
@@ -17489,6 +17964,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17489
17964
|
ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
|
|
17490
17965
|
return;
|
|
17491
17966
|
}
|
|
17967
|
+
recordStudioDocumentResourceGrants(selected, ctx.cwd);
|
|
17492
17968
|
const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
|
|
17493
17969
|
const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
|
|
17494
17970
|
skipWorkspaceRestore: selection.skipWorkspaceRestore,
|
|
@@ -17983,6 +18459,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17983
18459
|
path: file.resolvedPath,
|
|
17984
18460
|
};
|
|
17985
18461
|
initialStudioDocument = nextDoc;
|
|
18462
|
+
recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);
|
|
17986
18463
|
|
|
17987
18464
|
broadcastState();
|
|
17988
18465
|
broadcastResponseHistory();
|