@fieldwangai/agentflow 0.1.66 → 0.1.68

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.
@@ -97,6 +97,7 @@ import {
97
97
  isAuthUserAllowed,
98
98
  loginOrCreateUser,
99
99
  logoutRequest,
100
+ readAuthUsers,
100
101
  readUserAllowlist,
101
102
  writeUserAllowlist,
102
103
  } from "./auth.mjs";
@@ -257,8 +258,38 @@ function createFeedbackItem(payload, user) {
257
258
  };
258
259
  }
259
260
 
260
- function skillCollectionsAbs(userCtx = {}) {
261
- return path.join(getAgentflowUserDataRoot(userCtx.userId), SKILL_COLLECTIONS_FILENAME);
261
+ function skillCollectionsAbs() {
262
+ return path.join(getAgentflowDataRoot(), "admin", SKILL_COLLECTIONS_FILENAME);
263
+ }
264
+
265
+ function legacyAdminSkillCollectionPaths() {
266
+ const users = readAuthUsers();
267
+ return Object.entries(users || {})
268
+ .filter(([, user]) => user?.isAdmin)
269
+ .map(([userId, user]) => path.join(getAgentflowUserDataRoot(user.userId || userId), SKILL_COLLECTIONS_FILENAME));
270
+ }
271
+
272
+ function readSkillCollectionFile(filePath) {
273
+ try {
274
+ if (!fs.existsSync(filePath)) return null;
275
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
276
+ return data && typeof data === "object" && !Array.isArray(data) ? data : null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function mergeSkillCollectionConfigs(configs = []) {
283
+ const merged = [];
284
+ const seen = new Set();
285
+ for (const config of configs) {
286
+ for (const collection of normalizeSkillCollectionConfig(config).collections) {
287
+ if (!collection.id || seen.has(collection.id)) continue;
288
+ seen.add(collection.id);
289
+ merged.push(collection);
290
+ }
291
+ }
292
+ return { version: 1, collections: merged };
262
293
  }
263
294
 
264
295
  function slugifySkillCollectionId(name, fallback = "collection") {
@@ -382,8 +413,12 @@ function withBuiltinSkillCollections(config, availableSkills = []) {
382
413
  function readSkillCollectionConfig(userCtx = {}, availableSkills = []) {
383
414
  const p = skillCollectionsAbs(userCtx);
384
415
  try {
385
- if (!fs.existsSync(p)) return withBuiltinSkillCollections({}, availableSkills);
386
- return withBuiltinSkillCollections(JSON.parse(fs.readFileSync(p, "utf-8")), availableSkills);
416
+ const globalConfig = readSkillCollectionFile(p);
417
+ if (globalConfig) return withBuiltinSkillCollections(globalConfig, availableSkills);
418
+ const legacyConfigs = legacyAdminSkillCollectionPaths()
419
+ .map((legacyPath) => readSkillCollectionFile(legacyPath))
420
+ .filter(Boolean);
421
+ return withBuiltinSkillCollections(mergeSkillCollectionConfigs(legacyConfigs), availableSkills);
387
422
  } catch {
388
423
  return withBuiltinSkillCollections({}, availableSkills);
389
424
  }
@@ -1408,6 +1443,7 @@ function readWorkspaceGraph(workspaceRoot) {
1408
1443
  }
1409
1444
 
1410
1445
  const DISPLAY_SHARE_FILENAME = "display-shares.json";
1446
+ const DISPLAY_SHARE_TTL_MS = 24 * 60 * 60 * 1000;
1411
1447
 
1412
1448
  function displaySharesPath() {
1413
1449
  return path.join(getAgentflowDataRoot(), DISPLAY_SHARE_FILENAME);
@@ -1432,6 +1468,26 @@ function createDisplayShareId() {
1432
1468
  return crypto.randomBytes(12).toString("base64url");
1433
1469
  }
1434
1470
 
1471
+ function displayShareExpiresAt(now = new Date()) {
1472
+ const time = now instanceof Date ? now.getTime() : Date.now();
1473
+ return new Date(time + DISPLAY_SHARE_TTL_MS).toISOString();
1474
+ }
1475
+
1476
+ function isDisplayShareExpired(share) {
1477
+ const expiresAt = Date.parse(String(share?.expiresAt || ""));
1478
+ return Number.isFinite(expiresAt) && expiresAt <= Date.now();
1479
+ }
1480
+
1481
+ function getDisplayShareOrExpired(id) {
1482
+ const shares = readDisplayShares();
1483
+ const share = shares[id];
1484
+ if (!share) return { shares, share: null, expired: false };
1485
+ if (!isDisplayShareExpired(share)) return { shares, share, expired: false };
1486
+ delete shares[id];
1487
+ writeDisplayShares(shares);
1488
+ return { shares, share: null, expired: true };
1489
+ }
1490
+
1435
1491
  function normalizeDisplayShareNodeIds(ids, graph) {
1436
1492
  const out = [];
1437
1493
  const seen = new Set();
@@ -1519,6 +1575,7 @@ function publicDisplayPayloadFromShare(root, share) {
1519
1575
  : null,
1520
1576
  createdAt: share.createdAt || "",
1521
1577
  updatedAt: share.updatedAt || "",
1578
+ expiresAt: share.expiresAt || "",
1522
1579
  },
1523
1580
  nodes,
1524
1581
  };
@@ -2416,7 +2473,7 @@ function workspaceOutputProtocolRequirements(graph, nodeId) {
2416
2473
  ].join("\n");
2417
2474
  }
2418
2475
 
2419
- function workspaceRunPlan(graph, runNodeId) {
2476
+ function workspaceRunPlan(graph, runNodeId, scopedRoot = "") {
2420
2477
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
2421
2478
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
2422
2479
  const target = String(runNodeId || "").trim();
@@ -2464,6 +2521,7 @@ function workspaceRunPlan(graph, runNodeId) {
2464
2521
  for (const edge of incoming.get(id) || []) {
2465
2522
  const source = String(edge?.source || "");
2466
2523
  if (!source || source === target || needed.has(source)) continue;
2524
+ if (!workspaceNeedsUpstreamExecutionForEdge(graph, edge, scopedRoot)) continue;
2467
2525
  addNeeded(source);
2468
2526
  if (needed.has(source)) dependencyQueue.push(source);
2469
2527
  }
@@ -2511,6 +2569,42 @@ function workspaceIsControlEdge(graph, edge) {
2511
2569
  workspaceIsControlInputSlot(workspaceTargetSlotForEdge(graph, edge));
2512
2570
  }
2513
2571
 
2572
+ function workspaceNeedsUpstreamExecutionForEdge(graph, edge, scopedRoot = "") {
2573
+ if (workspaceIsControlEdge(graph, edge)) return true;
2574
+ return !workspaceEdgeHasCachedOutput(graph, edge, scopedRoot);
2575
+ }
2576
+
2577
+ function workspaceEdgeHasCachedOutput(graph, edge, scopedRoot = "") {
2578
+ const sourceId = String(edge?.source || "");
2579
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
2580
+ const source = instances[sourceId];
2581
+ if (!source) return false;
2582
+ const slot = workspaceSourceSlotForEdge(graph, edge);
2583
+ if (!isWorkspaceSemanticOutputSlot(slot) && slot && String(slot?.type || "") !== "node") {
2584
+ const value = workspaceSlotValue(slot);
2585
+ if (workspaceCachedOutputValueExists(value, scopedRoot)) return true;
2586
+ }
2587
+ const defId = String(source.definitionId || "");
2588
+ if (workspaceDisplayKind(defId) && String(source.body || "").trim()) return true;
2589
+ if (defId === "provide_str" || defId === "provide_bool" || defId === "provide_file") {
2590
+ return Boolean(String(workspaceInstanceText(source) || "").trim());
2591
+ }
2592
+ return false;
2593
+ }
2594
+
2595
+ function workspaceCachedOutputValueExists(value, scopedRoot = "") {
2596
+ const text = String(value || "").trim();
2597
+ if (!text) return false;
2598
+ const outputRel = workspaceSafeNodeOutputRelPath(text);
2599
+ if (!outputRel) return true;
2600
+ const root = path.resolve(scopedRoot || "");
2601
+ if (!root) return false;
2602
+ const abs = path.resolve(root, outputRel);
2603
+ const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
2604
+ if (abs !== root && !abs.startsWith(rootWithSep)) return false;
2605
+ return fs.existsSync(abs) && fs.statSync(abs).isFile();
2606
+ }
2607
+
2514
2608
  function workspaceUpstreamText(graph, nodeId, outputs) {
2515
2609
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
2516
2610
  const incoming = edges
@@ -2539,13 +2633,17 @@ function isWorkspaceSemanticInputSlot(slot) {
2539
2633
  return type === "node" || name === "prev" || name === "next" || name === "skillsContext" || name === "mcpContext" || name === "workspaceContext" || name === "gitContext";
2540
2634
  }
2541
2635
 
2542
- function workspaceAgentInputBlock(inputValues = {}) {
2636
+ function workspaceAgentInputBlock(inputValues = {}, inputMounts = {}) {
2543
2637
  const entries = Object.entries(inputValues || {}).filter(([name, value]) => String(name || "").trim() && String(value || "").trim());
2544
2638
  if (!entries.length) return "## 输入\n\n无。";
2545
2639
  const lines = entries.map(([name, value]) => {
2546
2640
  const text = String(value || "");
2547
2641
  const clipped = text.length > 6000 ? `${text.slice(0, 6000)}\n...[已截断 ${text.length - 6000} 字]` : text;
2548
- return `### ${name}\n\n${clipped}`;
2642
+ const mount = inputMounts?.[name];
2643
+ const mountNote = mount?.mounted
2644
+ ? `\n\n> 源文件:\`${mount.source}\`\n> 已挂载为当前任务可读文件:\`${mount.mounted}\`。请读取挂载路径,不要修改源文件。`
2645
+ : "";
2646
+ return `### ${name}\n\n${clipped}${mountNote}`;
2549
2647
  });
2550
2648
  return ["## 输入", "", ...lines].join("\n");
2551
2649
  }
@@ -2560,6 +2658,7 @@ function workspaceNodeFileBoundaryBlock(runPackage = {}) {
2560
2658
  "",
2561
2659
  nodeRunDir ? `- 当前执行目录:\`${nodeRunDir}\`。` : "",
2562
2660
  nodeTmpDir ? `- 临时文件只能写入:\`${nodeTmpDir}\`,也可通过环境变量 \`AGENTFLOW_NODE_TMP_DIR\` 获取。` : "",
2661
+ Object.keys(runPackage?.inputMounts || {}).length ? "- 已挂载的输入文件位于本任务 `inputs/`;`inputs/` 只用于读取,正式产物仍写入 `outputs/`。" : "",
2563
2662
  `- 正式产物写入本任务 \`${outputsRel}/\`,例如 \`${outputsRel}/result.html\`、\`${outputsRel}/result.md\`;返回时仍使用 \`${outputsRel}/...\` 相对路径。`,
2564
2663
  "- 不要在执行目录根部创建 `temp_*`、`_out.json`、`tmp.html` 等临时产物。",
2565
2664
  "- 不要自行删除 run package;AgentFlow 会在运行结束后统一清理。",
@@ -2615,6 +2714,18 @@ function workspaceResolveBodyPlaceholders(body, inputValues = {}) {
2615
2714
  });
2616
2715
  }
2617
2716
 
2717
+ function workspacePromptUpstreamText(upstreamText, runPackage = {}) {
2718
+ const raw = String(upstreamText || "").trim();
2719
+ if (!raw) return "";
2720
+ for (const [name, mount] of Object.entries(runPackage?.inputMounts || {})) {
2721
+ if (!mount?.mounted) continue;
2722
+ if (raw === String(mount.source || "").trim() || raw === String(mount.mounted || "").trim()) {
2723
+ return `输入 \`${name}\` 已挂载为 \`${mount.mounted}\`。源文件:\`${mount.source}\`。`;
2724
+ }
2725
+ }
2726
+ return upstreamText;
2727
+ }
2728
+
2618
2729
  function parseWorkspaceSkillKeys(raw) {
2619
2730
  const text = String(raw || "").trim();
2620
2731
  if (!text) return [];
@@ -2792,7 +2903,7 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2792
2903
  const body = workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim();
2793
2904
  const { values: relevantInputValues, placeholders } = workspaceRelevantInputValues(instance.body || "", inputValues);
2794
2905
  const runPackage = typeof nodeTmpDir === "object" && nodeTmpDir ? nodeTmpDir : { nodeTmpDir: String(nodeTmpDir || "") };
2795
- const inputBlock = workspaceAgentInputBlock(relevantInputValues);
2906
+ const inputBlock = workspaceAgentInputBlock(relevantInputValues, runPackage.inputMounts || {});
2796
2907
  const fileBoundary = workspaceNodeFileBoundaryBlock(runPackage);
2797
2908
  const outputProtocolRequirements = workspaceOutputProtocolRequirements(graph, nodeId);
2798
2909
  return [
@@ -2900,7 +3011,8 @@ function workspaceCreateNodeRunPackage(runTmpRoot, nodeId, { scopedRoot, task =
2900
3011
  const nodeRunDir = workspaceCreateNodeTmpDir(runTmpRoot, nodeId);
2901
3012
  const nodeTmpDir = path.join(nodeRunDir, "tmp");
2902
3013
  const outputsDir = path.join(nodeRunDir, "outputs");
2903
- const workspaceOutputsDir = path.join(path.resolve(scopedRoot), "outputs");
3014
+ const workspaceRoot = path.resolve(scopedRoot);
3015
+ const workspaceOutputsDir = path.join(workspaceRoot, "outputs");
2904
3016
  fs.mkdirSync(nodeTmpDir, { recursive: true });
2905
3017
  fs.mkdirSync(outputsDir, { recursive: true });
2906
3018
  fs.mkdirSync(workspaceOutputsDir, { recursive: true });
@@ -2910,13 +3022,19 @@ function workspaceCreateNodeRunPackage(runTmpRoot, nodeId, { scopedRoot, task =
2910
3022
  nodeRunDir,
2911
3023
  nodeTmpDir,
2912
3024
  outputsDir,
3025
+ workspaceRoot,
2913
3026
  createdAt: new Date().toISOString(),
2914
3027
  };
3028
+ const materializedInputs = workspaceMaterializeNodeInputFiles(nodeRunDir, workspaceRoot, inputValues);
3029
+ const runtimeInputValues = { ...(inputValues || {}), ...(materializedInputs.values || {}) };
2915
3030
  try {
2916
3031
  fs.writeFileSync(path.join(nodeRunDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
2917
3032
  fs.writeFileSync(path.join(nodeRunDir, "task.md"), String(task || "").trimEnd() + "\n", "utf-8");
2918
- if (Object.keys(inputValues || {}).length) {
2919
- fs.writeFileSync(path.join(nodeRunDir, "inputs.json"), JSON.stringify(inputValues, null, 2) + "\n", "utf-8");
3033
+ if (Object.keys(runtimeInputValues || {}).length) {
3034
+ fs.writeFileSync(path.join(nodeRunDir, "inputs.json"), JSON.stringify(runtimeInputValues, null, 2) + "\n", "utf-8");
3035
+ }
3036
+ if (Object.keys(materializedInputs.mounts || {}).length) {
3037
+ fs.writeFileSync(path.join(nodeRunDir, "inputs.manifest.json"), JSON.stringify(materializedInputs.mounts, null, 2) + "\n", "utf-8");
2920
3038
  }
2921
3039
  if (skillsBlock) fs.writeFileSync(path.join(nodeRunDir, "skills.md"), String(skillsBlock).trimEnd() + "\n", "utf-8");
2922
3040
  if (mcpBlock) fs.writeFileSync(path.join(nodeRunDir, "mcp.md"), String(mcpBlock).trimEnd() + "\n", "utf-8");
@@ -2927,9 +3045,59 @@ function workspaceCreateNodeRunPackage(runTmpRoot, nodeId, { scopedRoot, task =
2927
3045
  ...manifest,
2928
3046
  workspaceOutputsDir,
2929
3047
  outputsRel: "outputs",
3048
+ inputValues: runtimeInputValues,
3049
+ inputMounts: materializedInputs.mounts,
2930
3050
  };
2931
3051
  }
2932
3052
 
3053
+ function workspaceMaterializeNodeInputFiles(nodeRunDir, workspaceRoot, inputValues = {}) {
3054
+ const values = {};
3055
+ const mounts = {};
3056
+ for (const [name, value] of Object.entries(inputValues || {})) {
3057
+ const slotName = String(name || "").trim();
3058
+ if (!slotName) continue;
3059
+ const rel = workspaceInputFileRelPath(value);
3060
+ if (!rel) continue;
3061
+ const src = path.resolve(workspaceRoot, rel);
3062
+ const rootWithSep = workspaceRoot.endsWith(path.sep) ? workspaceRoot : `${workspaceRoot}${path.sep}`;
3063
+ if (src !== workspaceRoot && !src.startsWith(rootWithSep)) continue;
3064
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
3065
+ const stat = fs.statSync(src);
3066
+ const mountedRel = path.join("inputs", workspaceSanitizeTmpSegment(slotName, "input"), path.basename(rel));
3067
+ const dest = path.resolve(nodeRunDir, mountedRel);
3068
+ const nodeRunWithSep = nodeRunDir.endsWith(path.sep) ? nodeRunDir : `${nodeRunDir}${path.sep}`;
3069
+ if (dest !== nodeRunDir && !dest.startsWith(nodeRunWithSep)) continue;
3070
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
3071
+ workspaceCopyInputFile(src, dest);
3072
+ const mounted = mountedRel.split(path.sep).join(path.posix.sep);
3073
+ values[slotName] = mounted;
3074
+ mounts[slotName] = {
3075
+ source: rel,
3076
+ mounted,
3077
+ bytes: stat.size,
3078
+ };
3079
+ }
3080
+ return { values, mounts };
3081
+ }
3082
+
3083
+ function workspaceCopyInputFile(src, dest) {
3084
+ try {
3085
+ fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE);
3086
+ } catch {
3087
+ fs.copyFileSync(src, dest);
3088
+ }
3089
+ }
3090
+
3091
+ function workspaceInputFileRelPath(value) {
3092
+ const text = String(value || "").trim().replace(/^["']|["']$/g, "");
3093
+ if (!text || text.length > 260) return "";
3094
+ if (/[\r\n<>]/.test(text)) return "";
3095
+ if (/^(?:https?:|data:|blob:|file:|javascript:|mailto:|tel:)/i.test(text)) return "";
3096
+ const clean = text.replace(/^\/+/, "");
3097
+ if (clean.includes("..") || clean.startsWith(".") || path.isAbsolute(clean)) return "";
3098
+ return clean;
3099
+ }
3100
+
2933
3101
  function workspaceShouldKeepTmp(userCtx = {}) {
2934
3102
  const env = { ...process.env, ...readMergedEnvObject(userCtx.userId) };
2935
3103
  const value = String(env.AGENTFLOW_KEEP_TMP || env.AGENTFLOW_KEEP_WORKSPACE_TMP || "").trim().toLowerCase();
@@ -2953,7 +3121,7 @@ function workspaceCleanupTmpRoot(runTmpRoot, userCtx = {}, emit = () => {}) {
2953
3121
  async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts = {}) {
2954
3122
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
2955
3123
  const runNodeId = String(payload?.runNodeId || "").trim();
2956
- const { order, pauseNodeIds } = workspaceRunPlan(graph, runNodeId);
3124
+ const { order, pauseNodeIds } = workspaceRunPlan(graph, runNodeId, scopedRoot);
2957
3125
  const signal = opts.signal || null;
2958
3126
  const throwIfAborted = () => {
2959
3127
  if (signal?.aborted) {
@@ -3316,22 +3484,29 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3316
3484
  const inputValues = workspaceInputValues(graph, nodeId, outputs);
3317
3485
  const relevantInputs = workspaceRelevantInputValues(instance.body || "", inputValues);
3318
3486
  const upstreamText = workspaceTaskUpstreamText(graph, nodeId, outputs, relevantInputs.placeholders);
3319
- const body = workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim();
3320
- if (defId === "agent_subAgent" && !body && !String(upstreamText || "").trim()) {
3321
- throw new Error(`Workspace node ${nodeId} has no task. Fill the node body or connect upstream text.`);
3322
- }
3323
3487
  const upstreamSkillBlocks = workspaceUpstreamSkillBlocks(graph, nodeId, outputs);
3324
3488
  const promptSkillsBlock = mergeWorkspaceSkillBlocks(upstreamSkillBlocks);
3325
3489
  const promptMcpBlock = workspaceUpstreamMcpBlocks(graph, nodeId, outputs);
3326
3490
  const runPackage = workspaceCreateNodeRunPackage(runTmpRoot, nodeId, {
3327
3491
  scopedRoot,
3328
3492
  cwd,
3329
- task: body || upstreamText,
3493
+ task: workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim() || upstreamText,
3330
3494
  inputValues: relevantInputs.values,
3331
3495
  skillsBlock: promptSkillsBlock,
3332
3496
  mcpBlock: promptMcpBlock,
3333
3497
  });
3334
- const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, promptSkillsBlock, promptMcpBlock, inputValues, runPackage);
3498
+ const runtimeInputValues = { ...inputValues, ...(runPackage.inputValues || {}) };
3499
+ const body = workspaceResolveBodyPlaceholders(instance.body || "", runtimeInputValues).trim();
3500
+ const promptUpstreamText = workspacePromptUpstreamText(upstreamText, runPackage);
3501
+ if (defId === "agent_subAgent" && !body && !String(promptUpstreamText || "").trim()) {
3502
+ throw new Error(`Workspace node ${nodeId} has no task. Fill the node body or connect upstream text.`);
3503
+ }
3504
+ try {
3505
+ fs.writeFileSync(path.join(runPackage.nodeRunDir, "task.md"), String(body || promptUpstreamText || "").trimEnd() + "\n", "utf-8");
3506
+ } catch {
3507
+ // Best-effort debug artifact only.
3508
+ }
3509
+ const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage);
3335
3510
  try {
3336
3511
  fs.writeFileSync(path.join(runPackage.nodeRunDir, "prompt.md"), prompt.trimEnd() + "\n", "utf-8");
3337
3512
  } catch {
@@ -3809,9 +3984,9 @@ export function startUiServer({
3809
3984
  json(res, 400, { error: "Missing display share id" });
3810
3985
  return;
3811
3986
  }
3812
- const share = readDisplayShares()[id];
3987
+ const { share, expired } = getDisplayShareOrExpired(id);
3813
3988
  if (!share) {
3814
- json(res, 404, { error: "Display share not found" });
3989
+ json(res, expired ? 410 : 404, { error: expired ? "Display share has expired" : "Display share not found" });
3815
3990
  return;
3816
3991
  }
3817
3992
  const payload = publicDisplayPayloadFromShare(root, share);
@@ -3833,9 +4008,9 @@ export function startUiServer({
3833
4008
  json(res, 400, { error: "Missing display share id" });
3834
4009
  return;
3835
4010
  }
3836
- const share = readDisplayShares()[id];
4011
+ const { share, expired } = getDisplayShareOrExpired(id);
3837
4012
  if (!share) {
3838
- json(res, 404, { error: "Display share not found" });
4013
+ json(res, expired ? 410 : 404, { error: expired ? "Display share has expired" : "Display share not found" });
3839
4014
  return;
3840
4015
  }
3841
4016
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -4276,7 +4451,8 @@ export function startUiServer({
4276
4451
  const shares = readDisplayShares();
4277
4452
  let id = createDisplayShareId();
4278
4453
  while (shares[id]) id = createDisplayShareId();
4279
- const now = new Date().toISOString();
4454
+ const nowDate = new Date();
4455
+ const now = nowDate.toISOString();
4280
4456
  const share = {
4281
4457
  id,
4282
4458
  userId: authUser.userId,
@@ -4284,10 +4460,11 @@ export function startUiServer({
4284
4460
  flowSource: scoped.flowSource || "user",
4285
4461
  archived: scoped.archived === true,
4286
4462
  title: String(payload.title || "").trim() || "AgentFlow Display",
4287
- layout: ["canvas", "gallery", "slides", "document"].includes(String(payload.layout || "")) ? String(payload.layout) : "canvas",
4463
+ layout: ["canvas", "gallery", "slides", "document", "single"].includes(String(payload.layout || "")) ? String(payload.layout) : "canvas",
4288
4464
  nodeIds,
4289
4465
  createdAt: now,
4290
4466
  updatedAt: now,
4467
+ expiresAt: displayShareExpiresAt(nowDate),
4291
4468
  };
4292
4469
  shares[id] = share;
4293
4470
  writeDisplayShares(shares);
@@ -5363,6 +5540,10 @@ export function startUiServer({
5363
5540
  json(res, 400, { error: "Missing skill slug or collection" });
5364
5541
  return;
5365
5542
  }
5543
+ if (payload?.collection && !authUser?.isAdmin) {
5544
+ json(res, 403, { error: "Admin required" });
5545
+ return;
5546
+ }
5366
5547
  const beforeSkills = payload?.collection ? listComposerSkills(PACKAGE_ROOT, root) : [];
5367
5548
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
5368
5549
  if (!result.ok) {
@@ -5392,6 +5573,10 @@ export function startUiServer({
5392
5573
  json(res, 400, { error: "Missing skill slug or collection" });
5393
5574
  return;
5394
5575
  }
5576
+ if (payload?.collection && !authUser?.isAdmin) {
5577
+ json(res, 403, { error: "Admin required" });
5578
+ return;
5579
+ }
5395
5580
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 120_000, maxBuffer: 4 * 1024 * 1024 });
5396
5581
  if (!result.ok) {
5397
5582
  json(res, 500, { error: result.error, stdout: result.stdout });
@@ -6381,6 +6566,10 @@ finishedAt: "${new Date().toISOString()}"
6381
6566
  }
6382
6567
 
6383
6568
  if (req.method === "POST" && url.pathname === "/api/skill-collections") {
6569
+ if (!authUser?.isAdmin) {
6570
+ json(res, 403, { error: "Admin required" });
6571
+ return;
6572
+ }
6384
6573
  let payload;
6385
6574
  try {
6386
6575
  payload = JSON.parse(await readBody(req));