@oh-story/dsh 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/client.js +610 -214
- package/lib/client.js.map +3 -3
- package/lib/index.js +190 -172
- package/lib/index.js.map +4 -4
- package/lib/types/client/file-activity.d.ts +11 -6
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +9 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/native-hooks.d.ts +4 -1
- package/lib/types/native-hooks.d.ts.map +1 -1
- package/lib/types/workspace-route.d.ts +1 -2
- package/lib/types/workspace-route.d.ts.map +1 -1
- package/package.json +14 -3
- package/lib/types/workspace-security.d.ts +0 -9
- package/lib/types/workspace-security.d.ts.map +0 -1
package/lib/index.js
CHANGED
|
@@ -216,8 +216,6 @@ function createDramaSkillProvider(skillRoot = defaultDramaSkillRoot()) {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
// src/native-hooks.ts
|
|
219
|
-
import { access, readdir as readdir2 } from "node:fs/promises";
|
|
220
|
-
import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
221
219
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
222
220
|
var MUTATION_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
|
|
223
221
|
function mutationPath(name2, args) {
|
|
@@ -229,52 +227,84 @@ function mutationPath(name2, args) {
|
|
|
229
227
|
}
|
|
230
228
|
return typeof record.file_path === "string" && record.file_path.trim() !== "" ? record.file_path : void 0;
|
|
231
229
|
}
|
|
230
|
+
function normalizedRelativePath(path) {
|
|
231
|
+
const segments = [];
|
|
232
|
+
for (const part of path.split("/")) {
|
|
233
|
+
if (part === "" || part === ".") continue;
|
|
234
|
+
if (part === "..") {
|
|
235
|
+
if (segments.length === 0) return void 0;
|
|
236
|
+
segments.pop();
|
|
237
|
+
} else segments.push(part);
|
|
238
|
+
}
|
|
239
|
+
return segments.join("/");
|
|
240
|
+
}
|
|
232
241
|
function detectStoryMutation(name2, args, cwd) {
|
|
233
|
-
const
|
|
234
|
-
if (cwd === void 0 ||
|
|
235
|
-
const root =
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
const rawPath = mutationPath(name2, args);
|
|
243
|
+
if (cwd === void 0 || rawPath === void 0) return void 0;
|
|
244
|
+
const root = cwd.replaceAll("\\", "/").replace(/\/$/u, "");
|
|
245
|
+
const candidate = rawPath.replaceAll("\\", "/");
|
|
246
|
+
const absolute = candidate.startsWith("/") || /^[a-z]:\//iu.test(candidate) || /^[a-z][a-z\d+.-]*:\/\//iu.test(candidate);
|
|
247
|
+
const insideRoot = /^[a-z]:\//iu.test(root) ? candidate.toLowerCase().startsWith(`${root.toLowerCase()}/`) : candidate.startsWith(`${root}/`);
|
|
248
|
+
if (absolute && !insideRoot) return void 0;
|
|
249
|
+
const normalized = normalizedRelativePath(absolute ? candidate.slice(root.length + 1) : candidate);
|
|
250
|
+
if (normalized === void 0) return void 0;
|
|
240
251
|
if (!normalized.startsWith("\u6B63\u6587/")) return void 0;
|
|
241
252
|
const chapterText = /第0*(\d+)章/u.exec(normalized)?.[1];
|
|
242
253
|
return { root, path: normalized, ...chapterText === void 0 ? {} : { chapter: Number(chapterText) } };
|
|
243
254
|
}
|
|
244
|
-
function storyMutation(exec) {
|
|
245
|
-
|
|
255
|
+
async function storyMutation(exec, fs) {
|
|
256
|
+
const cwd = exec.agent?.session.header.cwd;
|
|
257
|
+
const path = mutationPath(exec.name, exec.arguments);
|
|
258
|
+
if (cwd === void 0 || path === void 0) return void 0;
|
|
259
|
+
try {
|
|
260
|
+
const [rootTarget, mutationTarget] = await Promise.all([
|
|
261
|
+
fs.resolve(cwd, { signal: exec.signal }),
|
|
262
|
+
fs.resolve(path, { cwd, signal: exec.signal })
|
|
263
|
+
]);
|
|
264
|
+
if (!fs.contains(rootTarget, mutationTarget)) return void 0;
|
|
265
|
+
} catch {
|
|
266
|
+
return void 0;
|
|
267
|
+
}
|
|
268
|
+
return detectStoryMutation(exec.name, exec.arguments, cwd);
|
|
269
|
+
}
|
|
270
|
+
async function target(fs, root, path, signal) {
|
|
271
|
+
return fs.resolve(path, { cwd: root, ...signal === void 0 ? {} : { signal } });
|
|
246
272
|
}
|
|
247
|
-
async function exists(path) {
|
|
248
|
-
return
|
|
273
|
+
async function exists(fs, root, path, signal) {
|
|
274
|
+
return target(fs, root, path, signal).then((value) => fs.stat(value, signal)).then((info) => info !== void 0, () => false);
|
|
249
275
|
}
|
|
250
|
-
async function hasChapterOutline(root, chapter) {
|
|
251
|
-
const entries = await
|
|
252
|
-
return entries.some((entry) => entry.
|
|
276
|
+
async function hasChapterOutline(fs, root, chapter, signal) {
|
|
277
|
+
const entries = await target(fs, root, "\u5927\u7EB2", signal).then((directory) => fs.listDir(directory, signal)).catch(() => []);
|
|
278
|
+
return entries.some((entry) => entry.type === "file" && Number(/^细纲_第0*(\d+)章.*\.md$/u.exec(entry.name)?.[1]) === chapter);
|
|
253
279
|
}
|
|
254
|
-
async function validateStoryMutation(mutation) {
|
|
255
|
-
const hasLongFormLayout = await exists(
|
|
280
|
+
async function validateStoryMutation(fs, mutation, signal) {
|
|
281
|
+
const hasLongFormLayout = await exists(fs, mutation.root, "\u5927\u7EB2", signal) || await exists(fs, mutation.root, "\u8FFD\u8E2A", signal);
|
|
256
282
|
if (!hasLongFormLayout) return void 0;
|
|
257
|
-
if (!await exists(
|
|
283
|
+
if (!await exists(fs, mutation.root, "\u8FFD\u8E2A/_tracking-state.json", signal)) {
|
|
258
284
|
return void 0;
|
|
259
285
|
}
|
|
260
|
-
if (mutation.chapter !== void 0 && !await hasChapterOutline(mutation.root, mutation.chapter)) {
|
|
286
|
+
if (mutation.chapter !== void 0 && !await hasChapterOutline(fs, mutation.root, mutation.chapter, signal)) {
|
|
261
287
|
return `Oh Story \u963B\u6B62\u5199\u5165\u7B2C ${String(mutation.chapter)} \u7AE0\uFF1A\u672A\u627E\u5230\u5BF9\u5E94\u7684 \u5927\u7EB2/\u7EC6\u7EB2_\u7B2CXXX\u7AE0*.md\u3002\u8BF7\u5148\u5B8C\u6210\u7EC6\u7EB2\u3002`;
|
|
262
288
|
}
|
|
263
289
|
return void 0;
|
|
264
290
|
}
|
|
265
291
|
async function decideStoryMutation(exec, next) {
|
|
266
|
-
const
|
|
292
|
+
const fs = exec.agent?.ctx.get("fs");
|
|
293
|
+
if (fs === void 0) return next();
|
|
294
|
+
const mutation = await storyMutation(exec, fs);
|
|
267
295
|
if (mutation === void 0) return next();
|
|
268
|
-
const reason = await validateStoryMutation(mutation);
|
|
296
|
+
const reason = await validateStoryMutation(fs, mutation, exec.signal);
|
|
269
297
|
if (reason !== void 0) return { kind: "deny", reason };
|
|
270
298
|
return next();
|
|
271
299
|
}
|
|
272
300
|
function registerOhStoryHooks(context) {
|
|
273
301
|
context.on("tools/pre-execute", decideStoryMutation);
|
|
274
302
|
context.on("tools/post-execute", async (exec, result, next) => {
|
|
275
|
-
const mutation = storyMutation(exec);
|
|
276
303
|
const downstream = await next();
|
|
277
|
-
if (
|
|
304
|
+
if (result.isError || downstream.kind !== "accept") return downstream;
|
|
305
|
+
const fs = exec.agent?.ctx.get("fs");
|
|
306
|
+
const mutation = fs === void 0 ? void 0 : await storyMutation(exec, fs);
|
|
307
|
+
if (mutation === void 0) return downstream;
|
|
278
308
|
const reminder = createUserMessage({
|
|
279
309
|
source: { kind: "plugin", plugin: "oh-story" },
|
|
280
310
|
content: [{
|
|
@@ -294,7 +324,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
|
294
324
|
|
|
295
325
|
// src/role-provider.ts
|
|
296
326
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
297
|
-
import { basename as basename2, dirname as dirname2, join as join2, resolve as
|
|
327
|
+
import { basename as basename2, dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
298
328
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
299
329
|
var OH_STORY_ROLE_NAMES = [
|
|
300
330
|
"chapter-extractor",
|
|
@@ -311,10 +341,10 @@ function roleBody(source) {
|
|
|
311
341
|
}
|
|
312
342
|
function defaultBundledRoleRoot() {
|
|
313
343
|
const current = dirname2(fileURLToPath2(import.meta.url));
|
|
314
|
-
return basename2(current) === "src" ?
|
|
344
|
+
return basename2(current) === "src" ? resolve2(current, "../../knowledge/oh-story/roles") : resolve2(current, "oh-story/roles");
|
|
315
345
|
}
|
|
316
346
|
async function loadBundledRole(name2, roleRoot = defaultBundledRoleRoot(), execution = "tool-free") {
|
|
317
|
-
const body = roleBody(await readFile2(join2(
|
|
347
|
+
const body = roleBody(await readFile2(join2(resolve2(roleRoot), `${name2}.md`), "utf8"));
|
|
318
348
|
if (body.length === 0) throw new Error(`Bundled role "${name2}" is empty.`);
|
|
319
349
|
const integration = execution === "tool-free" ? [
|
|
320
350
|
"You are running inside an Oh Story review-required DSH collaboration. The caller supplies every permitted input in the prompt.",
|
|
@@ -337,12 +367,12 @@ async function loadBundledRole(name2, roleRoot = defaultBundledRoleRoot(), execu
|
|
|
337
367
|
var OH_STORY_ROLE_TOOL_NAME = "oh_story_role";
|
|
338
368
|
var roleTools = {
|
|
339
369
|
"chapter-extractor": ["read", "glob", "grep"],
|
|
340
|
-
"character-designer": ["read", "glob", "grep"],
|
|
370
|
+
"character-designer": ["read", "glob", "grep", "write", "edit"],
|
|
341
371
|
"consistency-checker": ["read", "glob", "grep"],
|
|
342
|
-
"narrative-writer": ["read", "glob", "grep", "bash"],
|
|
343
|
-
"story-architect": ["read", "glob", "grep"],
|
|
372
|
+
"narrative-writer": ["read", "glob", "grep", "write", "edit", "bash"],
|
|
373
|
+
"story-architect": ["read", "glob", "grep", "write", "edit"],
|
|
344
374
|
"story-explorer": ["read", "glob", "grep"],
|
|
345
|
-
"story-researcher": ["read", "glob", "grep", "bash", "web_search", "web_fetch"]
|
|
375
|
+
"story-researcher": ["read", "glob", "grep", "bash", "write", "web_search", "web_fetch"]
|
|
346
376
|
};
|
|
347
377
|
function roleToolFilter(role) {
|
|
348
378
|
return { allow: roleTools[role] };
|
|
@@ -434,68 +464,10 @@ async function registerOhStoryRoleTool(context) {
|
|
|
434
464
|
}
|
|
435
465
|
|
|
436
466
|
// src/workspace-route.ts
|
|
437
|
-
import {
|
|
438
|
-
import {
|
|
439
|
-
import { dirname as dirname3, extname, join as join3, relative as relative4, resolve as resolve5 } from "node:path";
|
|
467
|
+
import { extname } from "node:path";
|
|
468
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
440
469
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
441
470
|
|
|
442
|
-
// src/workspace-security.ts
|
|
443
|
-
import { lstat, realpath } from "node:fs/promises";
|
|
444
|
-
import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve4, sep as sep2 } from "node:path";
|
|
445
|
-
var PathSecurityError = class extends Error {
|
|
446
|
-
constructor(message) {
|
|
447
|
-
super(message);
|
|
448
|
-
this.name = "PathSecurityError";
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
function isInside(root, candidate) {
|
|
452
|
-
const offset = relative3(root, candidate);
|
|
453
|
-
return offset === "" || !offset.startsWith(`..${sep2}`) && offset !== ".." && !isAbsolute3(offset);
|
|
454
|
-
}
|
|
455
|
-
async function canonicalWorkspaceRoot(root) {
|
|
456
|
-
const canonical = await realpath(root);
|
|
457
|
-
const info = await lstat(canonical);
|
|
458
|
-
if (!info.isDirectory()) throw new PathSecurityError("Workspace root must be a directory.");
|
|
459
|
-
return canonical;
|
|
460
|
-
}
|
|
461
|
-
async function resolveWorkspacePath(root, relativePath, options = {}) {
|
|
462
|
-
if (relativePath.includes("\0") || isAbsolute3(relativePath) || /^[A-Za-z]:[\\/]/u.test(relativePath)) {
|
|
463
|
-
throw new PathSecurityError("Only workspace-relative paths are allowed.");
|
|
464
|
-
}
|
|
465
|
-
const segments = relativePath.replaceAll("\\", "/").split("/");
|
|
466
|
-
if (segments.some((segment) => segment === ".." || segment === "")) {
|
|
467
|
-
throw new PathSecurityError("Path traversal and empty segments are not allowed.");
|
|
468
|
-
}
|
|
469
|
-
const canonicalRoot = await canonicalWorkspaceRoot(root);
|
|
470
|
-
const candidate = resolve4(canonicalRoot, ...segments);
|
|
471
|
-
if (!isInside(canonicalRoot, candidate)) throw new PathSecurityError("Path escapes the workspace.");
|
|
472
|
-
let cursor = canonicalRoot;
|
|
473
|
-
for (let index = 0; index < segments.length; index += 1) {
|
|
474
|
-
cursor = resolve4(cursor, segments[index] ?? "");
|
|
475
|
-
try {
|
|
476
|
-
const info = await lstat(cursor);
|
|
477
|
-
if (info.isSymbolicLink()) {
|
|
478
|
-
const target = await realpath(cursor);
|
|
479
|
-
if (!isInside(canonicalRoot, target)) throw new PathSecurityError("Symbolic link escapes the workspace.");
|
|
480
|
-
cursor = target;
|
|
481
|
-
}
|
|
482
|
-
if (index < segments.length - 1 && !info.isDirectory()) throw new PathSecurityError("A path parent is not a directory.");
|
|
483
|
-
if (index === segments.length - 1 && options.expect !== void 0) {
|
|
484
|
-
if (options.expect === "file" && !info.isFile()) throw new PathSecurityError("Expected a regular file.");
|
|
485
|
-
if (options.expect === "directory" && !info.isDirectory()) throw new PathSecurityError("Expected a directory.");
|
|
486
|
-
}
|
|
487
|
-
} catch (error) {
|
|
488
|
-
if (error instanceof PathSecurityError) throw error;
|
|
489
|
-
const code = error instanceof Error && "code" in error ? String(error.code) : "";
|
|
490
|
-
if (code === "ENOENT" && options.allowMissing === true && index === segments.length - 1) return cursor;
|
|
491
|
-
throw error;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
const finalPath = await realpath(cursor);
|
|
495
|
-
if (!isInside(canonicalRoot, finalPath)) throw new PathSecurityError("Resolved path escapes the workspace.");
|
|
496
|
-
return finalPath;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
471
|
// src/workspace-request-trust.ts
|
|
500
472
|
function header(headers, name2) {
|
|
501
473
|
if (headers instanceof Headers) return headers.get(name2) ?? void 0;
|
|
@@ -552,6 +524,7 @@ var DRAMA_DIRECTORIES = ["\u8F93\u5165", "\u9879\u76EE\u5F00\u53D1", "\u8BBE\u5B
|
|
|
552
524
|
var CREATIVE_DIRECTORIES = [...STORY_DIRECTORIES, ...DRAMA_DIRECTORIES];
|
|
553
525
|
var ROOT_FILES = /* @__PURE__ */ new Set(["short-drama.json"]);
|
|
554
526
|
var EDITABLE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl"]);
|
|
527
|
+
var FILE_LIMIT = 1e3;
|
|
555
528
|
var WorkspaceHttpError = class extends Error {
|
|
556
529
|
constructor(status, message) {
|
|
557
530
|
super(message);
|
|
@@ -570,9 +543,6 @@ function send(response, status, value) {
|
|
|
570
543
|
});
|
|
571
544
|
response.end(body);
|
|
572
545
|
}
|
|
573
|
-
function workspaceContentVersion(content) {
|
|
574
|
-
return createHash("sha256").update(content).digest("hex");
|
|
575
|
-
}
|
|
576
546
|
async function jsonBody(request, maxBytes) {
|
|
577
547
|
const chunks = [];
|
|
578
548
|
let size = 0;
|
|
@@ -594,7 +564,7 @@ function assertCreativePath(path) {
|
|
|
594
564
|
if (!EDITABLE_EXTENSIONS.has(extname(path).toLocaleLowerCase())) {
|
|
595
565
|
throw new WorkspaceHttpError(415, "\u5DE5\u4F5C\u53F0\u53EA\u7F16\u8F91 Markdown\u3001\u6587\u672C\u3001JSON \u548C JSONL \u6587\u4EF6\u3002");
|
|
596
566
|
}
|
|
597
|
-
if (path.split("/").some((segment) => segment === "" || segment === "..")) {
|
|
567
|
+
if (path.startsWith("/") || path.includes("\\") || path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
598
568
|
throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u4E0D\u5728\u521B\u4F5C\u5DE5\u4F5C\u53F0\u4E2D\u3002");
|
|
599
569
|
}
|
|
600
570
|
const root = path.split("/", 1)[0];
|
|
@@ -602,112 +572,160 @@ function assertCreativePath(path) {
|
|
|
602
572
|
throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u4E0D\u5728\u521B\u4F5C\u5DE5\u4F5C\u53F0\u4E2D\u3002");
|
|
603
573
|
}
|
|
604
574
|
}
|
|
605
|
-
async function
|
|
575
|
+
async function workspaceRealm(context, url) {
|
|
576
|
+
const rawId = url.searchParams.get("sessionId");
|
|
577
|
+
if (rawId === null || rawId === "") throw new WorkspaceHttpError(400, "\u7F3A\u5C11 DSH sessionId\u3002");
|
|
578
|
+
const lookup = context.typert.lookups.get("agent");
|
|
579
|
+
if (lookup === void 0) throw new WorkspaceHttpError(503, "DSH Agent lookup \u5F53\u524D\u4E0D\u53EF\u7528\u3002");
|
|
580
|
+
let agent;
|
|
581
|
+
try {
|
|
582
|
+
agent = await lookup.resolve(SessionId(rawId));
|
|
583
|
+
} catch {
|
|
584
|
+
throw new WorkspaceHttpError(404, "DSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\u3002");
|
|
585
|
+
}
|
|
586
|
+
if (agent === void 0) throw new WorkspaceHttpError(404, "DSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\u3002");
|
|
587
|
+
if (agent.session.header.parentSession !== void 0 || agent.session.header.origin === "subagent") {
|
|
588
|
+
throw new WorkspaceHttpError(403, "\u5B50 Agent \u4F1A\u8BDD\u4E0D\u5F00\u653E\u521B\u4F5C\u7F16\u8F91\u5668\u3002");
|
|
589
|
+
}
|
|
590
|
+
const cwd = agent.session.header.cwd;
|
|
591
|
+
if (cwd === void 0) throw new WorkspaceHttpError(409, "\u5F53\u524D DSH \u4F1A\u8BDD\u6CA1\u6709\u5DE5\u4F5C\u76EE\u5F55\u3002");
|
|
592
|
+
const fs = agent.ctx.get("fs");
|
|
593
|
+
const sandboxPolicy = agent.ctx.get("sandboxPolicy");
|
|
594
|
+
if (fs === void 0 || sandboxPolicy === void 0) throw new WorkspaceHttpError(503, "DSH \u6587\u4EF6\u7CFB\u7EDF\u5F53\u524D\u4E0D\u53EF\u7528\u3002");
|
|
595
|
+
return { agent, fs, sandboxPolicy, cwd, root: await fs.resolve(cwd) };
|
|
596
|
+
}
|
|
597
|
+
async function creativeTarget(realm, path) {
|
|
598
|
+
assertCreativePath(path);
|
|
599
|
+
const target2 = await realm.fs.resolve(path, { cwd: realm.cwd });
|
|
600
|
+
if (!realm.fs.contains(realm.root, target2)) throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u79BB\u5F00\u4E86 DSH \u5DE5\u4F5C\u76EE\u5F55\u3002");
|
|
601
|
+
return target2;
|
|
602
|
+
}
|
|
603
|
+
function requireRegularFile(info) {
|
|
604
|
+
if (info === void 0) throw new WorkspaceHttpError(404, "\u6587\u4EF6\u4E0D\u5B58\u5728\u3002");
|
|
605
|
+
if (info.type !== "file") throw new WorkspaceHttpError(415, "\u76EE\u6807\u4E0D\u662F\u53EF\u7F16\u8F91\u7684\u666E\u901A\u6587\u4EF6\u3002");
|
|
606
|
+
return info;
|
|
607
|
+
}
|
|
608
|
+
async function readVersionedFile(fs, target2, maxBytes) {
|
|
609
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
610
|
+
const before = requireRegularFile(await fs.stat(target2));
|
|
611
|
+
if (before.size !== void 0 && before.size > maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
|
|
612
|
+
const bytes = await fs.readBytes(target2, void 0, maxBytes);
|
|
613
|
+
const after = requireRegularFile(await fs.stat(target2));
|
|
614
|
+
if (before.version !== after.version) continue;
|
|
615
|
+
let content;
|
|
616
|
+
try {
|
|
617
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
618
|
+
} catch {
|
|
619
|
+
throw new WorkspaceHttpError(415, "\u6587\u4EF6\u4E0D\u662F\u6709\u6548\u7684 UTF-8 \u6587\u672C\u3002");
|
|
620
|
+
}
|
|
621
|
+
return { content, bytes: bytes.byteLength, version: after.version };
|
|
622
|
+
}
|
|
623
|
+
throw new WorkspaceHttpError(409, "\u6587\u4EF6\u6B63\u5728\u88AB\u4FEE\u6539\uFF0C\u8BF7\u91CD\u8BD5\u3002");
|
|
624
|
+
}
|
|
625
|
+
async function listFiles(realm) {
|
|
606
626
|
const files = [];
|
|
607
|
-
const walk = async (directory) => {
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (entry.
|
|
613
|
-
|
|
614
|
-
|
|
627
|
+
const walk = async (path, directory) => {
|
|
628
|
+
for (const entry of await realm.fs.listDir(directory)) {
|
|
629
|
+
if (entry.name.startsWith(".") || !realm.fs.contains(realm.root, entry.target)) continue;
|
|
630
|
+
const childPath = `${path}/${entry.name}`;
|
|
631
|
+
if (entry.type === "directory") await walk(childPath, entry.target);
|
|
632
|
+
else if (entry.type === "file" && EDITABLE_EXTENSIONS.has(extname(entry.name).toLocaleLowerCase())) {
|
|
633
|
+
const info = entry.version === void 0 || entry.size === void 0 ? await realm.fs.stat(entry.target) : void 0;
|
|
634
|
+
const version = entry.version ?? info?.version;
|
|
635
|
+
if (version !== void 0) files.push({ path: childPath, bytes: entry.size ?? info?.size ?? 0, version });
|
|
615
636
|
}
|
|
616
|
-
if (files.length >=
|
|
637
|
+
if (files.length >= FILE_LIMIT) return;
|
|
617
638
|
}
|
|
618
639
|
};
|
|
619
|
-
for (const directory of CREATIVE_DIRECTORIES)
|
|
640
|
+
for (const directory of CREATIVE_DIRECTORIES) {
|
|
641
|
+
const target2 = await realm.fs.resolve(directory, { cwd: realm.cwd });
|
|
642
|
+
if (!realm.fs.contains(realm.root, target2)) continue;
|
|
643
|
+
const info = await realm.fs.stat(target2);
|
|
644
|
+
if (info?.type === "directory") await walk(directory, target2);
|
|
645
|
+
if (files.length >= FILE_LIMIT) break;
|
|
646
|
+
}
|
|
620
647
|
for (const path of ROOT_FILES) {
|
|
621
|
-
await
|
|
622
|
-
|
|
623
|
-
|
|
648
|
+
const target2 = await creativeTarget(realm, path);
|
|
649
|
+
const info = await realm.fs.stat(target2);
|
|
650
|
+
if (info?.type === "file") files.push({ path, bytes: info.size ?? 0, version: info.version });
|
|
624
651
|
}
|
|
625
652
|
return files.sort((left, right) => left.path.localeCompare(right.path, "zh-Hans-CN"));
|
|
626
653
|
}
|
|
627
|
-
function
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
654
|
+
async function metadata(realm, files, path, maxBytes) {
|
|
655
|
+
if (!files.some((file) => file.path === path)) return { value: null };
|
|
656
|
+
try {
|
|
657
|
+
const target2 = await creativeTarget(realm, path);
|
|
658
|
+
return { value: JSON.parse((await readVersionedFile(realm.fs, target2, maxBytes)).content) };
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return { value: null, error: error instanceof SyntaxError ? `${path} \u4E0D\u662F\u6709\u6548\u7684 JSON\u3002` : `${path} \u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u3002` };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function mapFsError(error) {
|
|
664
|
+
if (!(error instanceof FsError)) return void 0;
|
|
665
|
+
switch (error.code) {
|
|
666
|
+
case "FS_NOT_FOUND":
|
|
667
|
+
return new WorkspaceHttpError(404, "\u6587\u4EF6\u4E0D\u5B58\u5728\u3002");
|
|
668
|
+
case "FS_TOO_LARGE":
|
|
669
|
+
return new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
|
|
670
|
+
case "FS_NOT_TEXT":
|
|
671
|
+
case "FS_NOT_REGULAR_FILE":
|
|
672
|
+
return new WorkspaceHttpError(415, "\u76EE\u6807\u4E0D\u662F\u53EF\u7F16\u8F91\u7684\u6587\u672C\u6587\u4EF6\u3002");
|
|
673
|
+
case "FS_PERMISSION_DENIED":
|
|
674
|
+
case "FS_SANDBOX_DENIED":
|
|
675
|
+
return new WorkspaceHttpError(403, "\u5F53\u524D DSH \u6743\u9650\u4E0D\u5141\u8BB8\u4FEE\u6539\u8BE5\u6587\u4EF6\u3002");
|
|
676
|
+
case "FS_STALE_VERSION":
|
|
677
|
+
case "FS_NOT_OBSERVED":
|
|
678
|
+
return new WorkspaceHttpError(412, "\u6587\u4EF6\u5DF2\u5728\u78C1\u76D8\u4E0A\u66F4\u65B0\u3002\u8BF7\u5904\u7406\u51B2\u7A81\u540E\u518D\u4FDD\u5B58\u3002");
|
|
679
|
+
case "FS_ABORTED":
|
|
680
|
+
return new WorkspaceHttpError(409, "\u6587\u4EF6\u64CD\u4F5C\u5DF2\u53D6\u6D88\u3002");
|
|
681
|
+
default:
|
|
682
|
+
return new WorkspaceHttpError(500, "DSH \u6587\u4EF6\u7CFB\u7EDF\u64CD\u4F5C\u5931\u8D25\u3002");
|
|
683
|
+
}
|
|
636
684
|
}
|
|
637
685
|
async function handle(context, request, response, options) {
|
|
638
686
|
try {
|
|
639
|
-
if (!isTrustedWorkspaceRequest(request, options.trustedHosts ?? []))
|
|
640
|
-
throw new WorkspaceHttpError(403, "\u8BF7\u6C42\u6765\u6E90\u4E0D\u53D7\u4FE1\u4EFB\u3002");
|
|
641
|
-
}
|
|
687
|
+
if (!isTrustedWorkspaceRequest(request, options.trustedHosts ?? [])) throw new WorkspaceHttpError(403, "\u8BF7\u6C42\u6765\u6E90\u4E0D\u53D7\u4FE1\u4EFB\u3002");
|
|
642
688
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
643
689
|
if (url.pathname === "/oh-story/workspace" && request.method === "GET") {
|
|
644
|
-
const
|
|
645
|
-
const files = await listFiles(
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
send(response, 200, { cwd: root, files, tracking, shortDrama, mode: "dsh-session" });
|
|
690
|
+
const realm = await workspaceRealm(context, url);
|
|
691
|
+
const files = await listFiles(realm);
|
|
692
|
+
const tracking = await metadata(realm, files, "\u8FFD\u8E2A/_tracking-state.json", options.maxBytes);
|
|
693
|
+
const shortDrama = await metadata(realm, files, "short-drama.json", options.maxBytes);
|
|
694
|
+
const metadataErrors = [tracking.error, shortDrama.error].filter((value) => value !== void 0);
|
|
695
|
+
send(response, 200, { cwd: realm.cwd, files, tracking: tracking.value, shortDrama: shortDrama.value, metadataErrors, mode: "dsh-session" });
|
|
651
696
|
return;
|
|
652
697
|
}
|
|
653
698
|
if (url.pathname === "/oh-story/file" && request.method === "GET") {
|
|
654
|
-
const
|
|
699
|
+
const realm = await workspaceRealm(context, url);
|
|
655
700
|
const path = url.searchParams.get("path");
|
|
656
701
|
if (path === null) throw new WorkspaceHttpError(400, "\u7F3A\u5C11\u6587\u4EF6\u8DEF\u5F84\u3002");
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const data = await readFile3(absolute);
|
|
660
|
-
if (data.byteLength > options.maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
|
|
661
|
-
send(response, 200, {
|
|
662
|
-
path,
|
|
663
|
-
content: data.toString("utf8"),
|
|
664
|
-
bytes: data.byteLength,
|
|
665
|
-
version: workspaceContentVersion(data)
|
|
666
|
-
});
|
|
702
|
+
const file = await readVersionedFile(realm.fs, await creativeTarget(realm, path), options.maxBytes);
|
|
703
|
+
send(response, 200, { path, ...file });
|
|
667
704
|
return;
|
|
668
705
|
}
|
|
669
706
|
if (url.pathname === "/oh-story/file" && request.method === "PUT") {
|
|
670
|
-
const
|
|
707
|
+
const realm = await workspaceRealm(context, url);
|
|
671
708
|
const path = url.searchParams.get("path");
|
|
672
709
|
if (path === null) throw new WorkspaceHttpError(400, "\u7F3A\u5C11\u6587\u4EF6\u8DEF\u5F84\u3002");
|
|
673
|
-
assertCreativePath(path);
|
|
674
710
|
const input = await jsonBody(request, options.maxBytes * 6 + 1024);
|
|
675
711
|
if (typeof input.content !== "string") throw new WorkspaceHttpError(400, "content \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u3002");
|
|
676
|
-
if (typeof input.baseVersion !== "string") throw new WorkspaceHttpError(400, "baseVersion \u5FC5\u987B\u662F\
|
|
712
|
+
if (typeof input.baseVersion !== "string" || input.baseVersion === "") throw new WorkspaceHttpError(400, "baseVersion \u5FC5\u987B\u662F\u6709\u6548\u7248\u672C\u3002");
|
|
677
713
|
if (Buffer.byteLength(input.content) > options.maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const parentReal = await canonicalWorkspaceRoot(parent);
|
|
687
|
-
const offset = relative4(rootReal, parentReal);
|
|
688
|
-
if (offset === ".." || offset.startsWith("../")) throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u79BB\u5F00\u4E86 DSH \u5DE5\u4F5C\u76EE\u5F55\u3002");
|
|
689
|
-
const temporary = resolve5(parent, `.${randomUUID()}.oh-story.tmp`);
|
|
690
|
-
try {
|
|
691
|
-
await writeFile(temporary, input.content, { encoding: "utf8", flag: "wx" });
|
|
692
|
-
if (workspaceContentVersion(await readFile3(absolute)) !== input.baseVersion) {
|
|
693
|
-
throw new WorkspaceHttpError(412, "\u6587\u4EF6\u5DF2\u5728\u78C1\u76D8\u4E0A\u66F4\u65B0\u3002\u8BF7\u5237\u65B0\u5E76\u5408\u5E76\u540E\u518D\u4FDD\u5B58\u3002");
|
|
694
|
-
}
|
|
695
|
-
await rename(temporary, absolute);
|
|
696
|
-
} finally {
|
|
697
|
-
await rm(temporary, { force: true });
|
|
698
|
-
}
|
|
699
|
-
send(response, 200, {
|
|
700
|
-
path,
|
|
701
|
-
content: input.content,
|
|
702
|
-
bytes: Buffer.byteLength(input.content),
|
|
703
|
-
version: workspaceContentVersion(input.content)
|
|
704
|
-
});
|
|
714
|
+
const outcome = await realm.fs.writeText(
|
|
715
|
+
await creativeTarget(realm, path),
|
|
716
|
+
input.content,
|
|
717
|
+
{ kind: "replaceIfVersion", version: input.baseVersion },
|
|
718
|
+
void 0,
|
|
719
|
+
realm.sandboxPolicy.resolve({ session: realm.agent.session })
|
|
720
|
+
);
|
|
721
|
+
send(response, 200, { path, content: outcome.after, bytes: Buffer.byteLength(outcome.after), version: outcome.version });
|
|
705
722
|
return;
|
|
706
723
|
}
|
|
707
724
|
send(response, 404, { error: "Oh Story route not found." });
|
|
708
725
|
} catch (error) {
|
|
709
|
-
|
|
710
|
-
|
|
726
|
+
const mapped = error instanceof WorkspaceHttpError ? error : mapFsError(error);
|
|
727
|
+
if (mapped === void 0) context.logger("oh-story").error("workspace route failed", error);
|
|
728
|
+
send(response, mapped?.status ?? 500, { error: mapped?.message ?? "Oh Story workspace operation failed." });
|
|
711
729
|
}
|
|
712
730
|
}
|
|
713
731
|
function registerWorkspaceRoute(context, options) {
|
|
@@ -720,7 +738,7 @@ function registerWorkspaceRoute(context, options) {
|
|
|
720
738
|
|
|
721
739
|
// src/index.ts
|
|
722
740
|
var name = "oh-story";
|
|
723
|
-
var inject = ["
|
|
741
|
+
var inject = ["skills", "subagents", "tools", "typert", "webServer"];
|
|
724
742
|
var Config = z.object({
|
|
725
743
|
editorMaxBytes: z.natural().min(65536).max(8388608).default(2097152),
|
|
726
744
|
trustedHosts: z.array(String).default([])
|