pmx-canvas 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/extension.mjs +67 -3
- package/CHANGELOG.md +73 -0
- package/Readme.md +35 -5
- package/dist/canvas/global.css +498 -28
- package/dist/canvas/index.js +110 -95
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +4 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +3 -3
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +8 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +65 -9
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +146 -3
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +107 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +4 -2
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +157 -0
- package/docs/http-api.md +277 -6
- package/docs/mcp.md +96 -7
- package/docs/node-types.md +106 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +62 -5
- package/skills/pmx-canvas/references/full-reference.md +44 -8
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +9 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +14 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +21 -9
- package/src/client/App.tsx +28 -4
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +49 -7
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/icons.tsx +2 -0
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +8 -0
- package/src/client/state/sse-bridge.ts +52 -5
- package/src/client/theme/global.css +498 -28
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +45 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/agent-presence.ts +15 -9
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +612 -105
- package/src/server/canvas-operations.ts +183 -31
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +22 -0
- package/src/server/canvas-state.ts +812 -48
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +670 -9
- package/src/server/document-import.ts +223 -0
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +243 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +29 -5
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +15 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +20 -4
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +99 -21
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +129 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +155 -8
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +11 -3
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/server/workboard.ts +3 -3
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
- package/src/shared/tour.ts +87 -0
package/src/server/server.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - POST /api/canvas/graph -> create a native graph node
|
|
24
24
|
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
25
|
* - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
|
|
26
|
+
* - GET /api/canvas/exports/<file> -> a written static board export (sandboxed)
|
|
26
27
|
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
27
28
|
* - GET /api/workbench/events -> SSE event stream
|
|
28
29
|
* - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
|
|
@@ -38,9 +39,11 @@
|
|
|
38
39
|
|
|
39
40
|
import { spawnSync } from 'node:child_process';
|
|
40
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
|
|
43
|
+
import { withCurrentActor } from './attribution.js';
|
|
41
44
|
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
42
45
|
import { readFile } from 'node:fs/promises';
|
|
43
|
-
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
46
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
44
47
|
import * as marked from 'marked';
|
|
45
48
|
import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
46
49
|
import {
|
|
@@ -67,11 +70,13 @@ import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
|
|
|
67
70
|
import { canOpenNodeAsSurface } from '../shared/surface.js';
|
|
68
71
|
import { validateLocalImageFile } from './image-source.js';
|
|
69
72
|
import {
|
|
73
|
+
addCanvasNode,
|
|
70
74
|
cancelCodeGraphRecompute,
|
|
71
75
|
primeCanvasRuntimeBackends,
|
|
72
76
|
setCanvasLayoutUpdateEmitter,
|
|
73
77
|
syncCanvasRuntimeBackends,
|
|
74
78
|
} from './canvas-operations.js';
|
|
79
|
+
import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-import.js';
|
|
75
80
|
import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
|
|
76
81
|
import { intentRegistry } from './intent-registry.js';
|
|
77
82
|
import { agentPresence } from './agent-presence.js';
|
|
@@ -79,6 +84,8 @@ import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
|
|
|
79
84
|
import { diffLayouts } from './mutation-history.js';
|
|
80
85
|
import { humanPresence } from './human-presence.js';
|
|
81
86
|
import { startGateTtlSweeper, stopGateTtlSweeper } from './ax-gate-ttl.js';
|
|
87
|
+
import { startBackupScheduler, stopBackupScheduler } from './backup.js';
|
|
88
|
+
import { exportsFolder } from './board-export.js';
|
|
82
89
|
import { setWebviewRunner } from './operations/webview-runner.js';
|
|
83
90
|
import { closeNodeAppSession, nodeAppSessionId } from './operations/ops/nodes.js';
|
|
84
91
|
import { traceManager } from './trace-manager.js';
|
|
@@ -143,7 +150,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
|
|
|
143
150
|
let snapshot = startSnapshotId
|
|
144
151
|
? (canvasState.listSnapshots({ all: true }).find((entry) => entry.id === startSnapshotId) ?? null)
|
|
145
152
|
: null;
|
|
146
|
-
let unchanged =
|
|
153
|
+
let unchanged = !startSnapshotId && canvasState.getLayout().nodes.length === 0;
|
|
147
154
|
// A session that changed NOTHING on the board leaves no snapshot behind —
|
|
148
155
|
// session churn (idle timeouts, receipt tests, attach/detach cycles) was
|
|
149
156
|
// flooding the History drawer with identical boards.
|
|
@@ -184,6 +191,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
|
|
|
184
191
|
// workbench session and would overwrite it. The label is what the receipt shows.
|
|
185
192
|
emitPrimaryWorkbenchEvent('agent-session-ended', {
|
|
186
193
|
label: presence.label,
|
|
194
|
+
parentAgentId: presence.parentAgentId ?? null,
|
|
187
195
|
endedAt,
|
|
188
196
|
endedBy,
|
|
189
197
|
counts,
|
|
@@ -970,6 +978,39 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
|
|
|
970
978
|
const CANVAS_ASSET_VERSION = Date.now().toString(36);
|
|
971
979
|
const MAX_FRAME_DOCUMENTS = 128;
|
|
972
980
|
const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
|
|
981
|
+
|
|
982
|
+
async function readBoundedBody(req: Request, limit: number): Promise<Uint8Array | null> {
|
|
983
|
+
if (!req.body) return new Uint8Array();
|
|
984
|
+
const reader = req.body.getReader();
|
|
985
|
+
const chunks: Uint8Array[] = [];
|
|
986
|
+
let size = 0;
|
|
987
|
+
try {
|
|
988
|
+
while (true) {
|
|
989
|
+
const { done, value } = await reader.read();
|
|
990
|
+
if (done) break;
|
|
991
|
+
size += value.byteLength;
|
|
992
|
+
if (size > limit) {
|
|
993
|
+
await reader.cancel();
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
chunks.push(value);
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
reader.releaseLock();
|
|
1000
|
+
}
|
|
1001
|
+
const bytes = new Uint8Array(size);
|
|
1002
|
+
let offset = 0;
|
|
1003
|
+
for (const chunk of chunks) {
|
|
1004
|
+
bytes.set(chunk, offset);
|
|
1005
|
+
offset += chunk.byteLength;
|
|
1006
|
+
}
|
|
1007
|
+
return bytes;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function attachmentDisposition(name: string): string {
|
|
1011
|
+
const fallback = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
|
|
1012
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
1013
|
+
}
|
|
973
1014
|
const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
|
|
974
1015
|
const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
|
|
975
1016
|
'allow-downloads',
|
|
@@ -1111,6 +1152,7 @@ function canvasSpaHtml(): string {
|
|
|
1111
1152
|
}
|
|
1112
1153
|
<script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
|
|
1113
1154
|
<script>window.__PMX_BOOT_BUNDLE_STAMP = ${JSON.stringify(canvasBundleStamp())};</script>
|
|
1155
|
+
<script>window.__PMX_WORKBENCH_TOKEN = ${JSON.stringify(workbenchToken)};</script>
|
|
1114
1156
|
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1115
1157
|
</body>
|
|
1116
1158
|
</html>`;
|
|
@@ -1431,15 +1473,17 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1431
1473
|
// escaped diagram source, rendered by /canvas/mermaid-entry.js.
|
|
1432
1474
|
const inlineAssets = url.searchParams.get('inline-assets') === '1';
|
|
1433
1475
|
const doc = buildHtmlSurfaceDocument(
|
|
1434
|
-
buildMermaidSurfaceHtml(
|
|
1476
|
+
buildMermaidSurfaceHtml(
|
|
1477
|
+
source,
|
|
1478
|
+
inlineAssets ? mermaidEntryJsInline() : null,
|
|
1479
|
+
node.data.fit === 'none' ? 'none' : 'contain',
|
|
1480
|
+
url.searchParams.get('frameToken') ?? '',
|
|
1481
|
+
),
|
|
1435
1482
|
{
|
|
1436
1483
|
theme,
|
|
1437
1484
|
title: surfaceTitle,
|
|
1438
1485
|
themeToken: url.searchParams.get('themeToken') ?? undefined,
|
|
1439
|
-
//
|
|
1440
|
-
...(url.searchParams.get('frameToken')
|
|
1441
|
-
? { contentHeightToken: url.searchParams.get('frameToken') as string }
|
|
1442
|
-
: {}),
|
|
1486
|
+
// Mermaid reports its natural height itself, before contain scaling.
|
|
1443
1487
|
// Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
|
|
1444
1488
|
// are styled without depending on the <link> subresource.
|
|
1445
1489
|
...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
|
|
@@ -1555,6 +1599,39 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1555
1599
|
});
|
|
1556
1600
|
}
|
|
1557
1601
|
|
|
1602
|
+
// ── Serve a written board export (plan 013) ─────────────────
|
|
1603
|
+
// Only names the exporter writes, only from the exports folder. The page runs
|
|
1604
|
+
// its own viewer script, so it is served with an opaque-origin sandbox: it can
|
|
1605
|
+
// never reach this server's API as the workbench.
|
|
1606
|
+
const EXPORT_FILE_NAME = /^[a-z0-9-]+-\d{8}T\d{9}Z(?:-\d+)?\.html$/;
|
|
1607
|
+
|
|
1608
|
+
function handleBoardExportFile(url: URL): Response {
|
|
1609
|
+
const name = decodeURIComponent(url.pathname.slice('/api/canvas/exports/'.length));
|
|
1610
|
+
const folder = exportsFolder();
|
|
1611
|
+
if (!folder || !EXPORT_FILE_NAME.test(name)) return responseText('Export not found', 404);
|
|
1612
|
+
let path: string;
|
|
1613
|
+
try {
|
|
1614
|
+
const realFolder = realpathSync(folder);
|
|
1615
|
+
path = realpathSync(join(folder, name));
|
|
1616
|
+
const rel = relative(realFolder, path);
|
|
1617
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || !statSync(path).isFile()) {
|
|
1618
|
+
return responseText('Export not found', 404);
|
|
1619
|
+
}
|
|
1620
|
+
} catch {
|
|
1621
|
+
return responseText('Export not found', 404);
|
|
1622
|
+
}
|
|
1623
|
+
const download = url.searchParams.get('download') === '1';
|
|
1624
|
+
return new Response(Bun.file(path), {
|
|
1625
|
+
headers: {
|
|
1626
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1627
|
+
'Content-Security-Policy': 'sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox',
|
|
1628
|
+
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${name}"`,
|
|
1629
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1630
|
+
'Cache-Control': 'no-store',
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1558
1635
|
// ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
|
|
1559
1636
|
/**
|
|
1560
1637
|
* Ceiling for the raw byte route. The route streams via Bun.file, so this is a
|
|
@@ -3217,6 +3294,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3217
3294
|
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
3218
3295
|
// Unattended-approval policy: hold pending gates whose TTL elapsed.
|
|
3219
3296
|
startGateTtlSweeper((event, payload) => emitPrimaryWorkbenchEvent(event, payload));
|
|
3297
|
+
startBackupScheduler();
|
|
3220
3298
|
setCanvasLayoutUpdateEmitter(() => {
|
|
3221
3299
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3222
3300
|
});
|
|
@@ -3370,6 +3448,69 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3370
3448
|
// theme/update/viewport/annotation/refresh (plan-009 C1 slices 1-3). A
|
|
3371
3449
|
// null return falls through to the remaining hand-written routes
|
|
3372
3450
|
// (SSE, binary, HTML/static serving, and the server-coupled handlers).
|
|
3451
|
+
if (url.pathname === '/api/canvas/attachments' && req.method === 'POST') {
|
|
3452
|
+
const boardId = url.searchParams.get('boardId')?.trim() ?? '';
|
|
3453
|
+
const name = url.searchParams.get('name')?.trim() ?? '';
|
|
3454
|
+
const mime = url.searchParams.get('mime')?.trim() || 'application/octet-stream';
|
|
3455
|
+
const x = Number(url.searchParams.get('x') ?? 40);
|
|
3456
|
+
const y = Number(url.searchParams.get('y') ?? 40);
|
|
3457
|
+
if (!boardId || !name || !Number.isFinite(x) || !Number.isFinite(y))
|
|
3458
|
+
return responseJson({ ok: false, error: 'boardId, name, and finite x/y are required.' }, 400);
|
|
3459
|
+
if (name.length > MAX_ATTACHMENT_NAME_LENGTH)
|
|
3460
|
+
return responseJson(
|
|
3461
|
+
{ ok: false, error: `Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters.` },
|
|
3462
|
+
400,
|
|
3463
|
+
);
|
|
3464
|
+
if (Number(req.headers.get('content-length') ?? 0) > MAX_ATTACHMENT_BYTES)
|
|
3465
|
+
return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
|
|
3466
|
+
const bytes = await readBoundedBody(req, MAX_ATTACHMENT_BYTES);
|
|
3467
|
+
if (!bytes) return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
|
|
3468
|
+
if (bytes.byteLength === 0) return responseJson({ ok: false, error: 'Attachment is empty.' }, 400);
|
|
3469
|
+
if (canvasState.activeBoardId !== boardId)
|
|
3470
|
+
return responseJson({ ok: false, error: 'The captured target board is no longer active.' }, 409);
|
|
3471
|
+
if (!isWorkbenchToken(req.headers.get('x-pmx-workbench-token')))
|
|
3472
|
+
return responseJson({ ok: false, error: 'A trusted human upload is required.' }, 403);
|
|
3473
|
+
const attachment = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
|
|
3474
|
+
canvasState.storeAttachment({ boardId, name, mime, bytes }),
|
|
3475
|
+
);
|
|
3476
|
+
if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
|
|
3477
|
+
const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
|
|
3478
|
+
addCanvasNode({
|
|
3479
|
+
type: 'file',
|
|
3480
|
+
title: name,
|
|
3481
|
+
data: { attachmentId: attachment.id, mime, size: attachment.size },
|
|
3482
|
+
x,
|
|
3483
|
+
y,
|
|
3484
|
+
defaultWidth: 440,
|
|
3485
|
+
defaultHeight: 360,
|
|
3486
|
+
fileMode: 'auto',
|
|
3487
|
+
}),
|
|
3488
|
+
);
|
|
3489
|
+
const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
|
|
3490
|
+
const documentImport = created;
|
|
3491
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3492
|
+
return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
const attachmentBytesMatch = url.pathname.match(/^\/api\/canvas\/attachments\/([^/]+)\/bytes$/);
|
|
3496
|
+
if (attachmentBytesMatch && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3497
|
+
const id = decodeURIComponent(attachmentBytesMatch[1]!);
|
|
3498
|
+
const attachment = canvasState.getAttachment(id);
|
|
3499
|
+
if (!attachment || !canvasState.listBoards().some((board) => board.id === attachment.boardId))
|
|
3500
|
+
return responseJson({ ok: false, error: 'Attachment not found.' }, 404);
|
|
3501
|
+
const bytes = canvasState.readAttachmentBytes(id);
|
|
3502
|
+
if (!bytes) return responseJson({ ok: false, error: 'Attachment bytes are missing.' }, 404);
|
|
3503
|
+
return new Response(req.method === 'HEAD' ? null : new Blob([Uint8Array.from(bytes)]), {
|
|
3504
|
+
headers: {
|
|
3505
|
+
'Content-Type': 'application/octet-stream',
|
|
3506
|
+
'Content-Length': String(bytes.byteLength),
|
|
3507
|
+
'Content-Disposition': attachmentDisposition(attachment.name),
|
|
3508
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3509
|
+
'Cache-Control': 'no-store',
|
|
3510
|
+
},
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3373
3514
|
if (url.pathname.startsWith('/api/')) {
|
|
3374
3515
|
const operationResponse = await dispatchOperationRoute(req, url);
|
|
3375
3516
|
if (operationResponse) return operationResponse;
|
|
@@ -3393,6 +3534,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3393
3534
|
|
|
3394
3535
|
// HEAD answers like GET (see the standalone-surface note above): a PDF
|
|
3395
3536
|
// viewer probes this URL for type/length before fetching the bytes.
|
|
3537
|
+
if (url.pathname.startsWith('/api/canvas/exports/') && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3538
|
+
return handleBoardExportFile(url);
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3396
3541
|
if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3397
3542
|
return await handleCanvasFileBytes(url);
|
|
3398
3543
|
}
|
|
@@ -3428,11 +3573,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3428
3573
|
}
|
|
3429
3574
|
|
|
3430
3575
|
export function stopCanvasServer(): void {
|
|
3576
|
+
// A failed flush must leave the live server and its runtime state available for recovery.
|
|
3577
|
+
canvasState.close();
|
|
3431
3578
|
stopGateTtlSweeper();
|
|
3579
|
+
stopBackupScheduler();
|
|
3432
3580
|
agentPresence.reset();
|
|
3433
3581
|
humanPresence.reset();
|
|
3434
3582
|
intentRegistry.reset();
|
|
3435
|
-
canvasState.close();
|
|
3436
3583
|
closeAllMcpAppSessions();
|
|
3437
3584
|
cancelCodeGraphRecompute();
|
|
3438
3585
|
setCanvasLayoutUpdateEmitter(null);
|
|
@@ -306,14 +306,12 @@ export function searchNodes(
|
|
|
306
306
|
|
|
307
307
|
for (const node of nodes) {
|
|
308
308
|
const title = ((node.data.title as string) ?? '').toLowerCase();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
''
|
|
316
|
-
).toLowerCase();
|
|
309
|
+
// Search the same meaning delivered to agents, without a brief's truncation.
|
|
310
|
+
const fullContent = summarizeNodeForAgentContext(node, {
|
|
311
|
+
defaultTextLength: Number.POSITIVE_INFINITY,
|
|
312
|
+
webpageTextLength: Number.POSITIVE_INFINITY,
|
|
313
|
+
});
|
|
314
|
+
const content = fullContent.toLowerCase();
|
|
317
315
|
const path = ((node.data.path as string) ?? '').toLowerCase();
|
|
318
316
|
const description = ((node.data.description as string) ?? '').toLowerCase();
|
|
319
317
|
const summary = (
|
|
@@ -337,16 +335,10 @@ export function searchNodes(
|
|
|
337
335
|
|
|
338
336
|
if (score === 0) continue;
|
|
339
337
|
|
|
340
|
-
//
|
|
338
|
+
// Any query term may have matched, not necessarily the first one.
|
|
341
339
|
let snippet = '';
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
(node.data.agentSummary as string) ??
|
|
345
|
-
(node.data.contentSummary as string) ??
|
|
346
|
-
(node.data.description as string) ??
|
|
347
|
-
(node.data.fileContent as string) ??
|
|
348
|
-
'';
|
|
349
|
-
const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
|
|
340
|
+
const matches = terms.map((term) => content.indexOf(term)).filter((index) => index >= 0);
|
|
341
|
+
const matchIdx = matches.length ? Math.min(...matches) : -1;
|
|
350
342
|
if (matchIdx >= 0) {
|
|
351
343
|
const start = Math.max(0, matchIdx - 40);
|
|
352
344
|
const end = Math.min(fullContent.length, matchIdx + 80);
|
|
@@ -88,6 +88,7 @@ class TraceManager {
|
|
|
88
88
|
mcpToolName?: string | null;
|
|
89
89
|
}): void {
|
|
90
90
|
if (!this._enabled) return;
|
|
91
|
+
this.dropStaleChain();
|
|
91
92
|
|
|
92
93
|
const id = nextTraceNodeId();
|
|
93
94
|
const category = categorize(payload.name, payload.mcpServerName);
|
|
@@ -178,6 +179,7 @@ class TraceManager {
|
|
|
178
179
|
|
|
179
180
|
onSubagentStarted(payload: { agentName: string; agentDisplayName?: string }): void {
|
|
180
181
|
if (!this._enabled) return;
|
|
182
|
+
this.dropStaleChain();
|
|
181
183
|
|
|
182
184
|
const id = nextTraceNodeId();
|
|
183
185
|
const origin = this.getOrigin();
|
|
@@ -264,6 +266,23 @@ class TraceManager {
|
|
|
264
266
|
this.broadcastUpdate();
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The chain's last node is gone (another board was opened, or it was
|
|
271
|
+
* deleted): start a new chain instead of wiring edges to a missing node.
|
|
272
|
+
*/
|
|
273
|
+
private dropStaleChain(): void {
|
|
274
|
+
if (this.lastTraceNodeId && !canvasState.getNode(this.lastTraceNodeId)) this.resetChain();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Forget the trace chain without touching nodes. */
|
|
278
|
+
resetChain(): void {
|
|
279
|
+
this.traceNodeIds = [];
|
|
280
|
+
this.lastTraceNodeId = null;
|
|
281
|
+
this.toolCallToNodeId.clear();
|
|
282
|
+
this.traceOrigin = null;
|
|
283
|
+
this.chainIndex = 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
clearTrace(): void {
|
|
268
287
|
const traceNodeIds = new Set(this.traceNodeIds);
|
|
269
288
|
for (const node of canvasState.getLayout().nodes) {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
14
|
import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
|
|
15
15
|
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
16
|
-
import { emitCanvasLayoutUpdate } from './canvas-operations.js';
|
|
16
|
+
import { emitCanvasLayoutUpdate, type CanvasSizeAdjustment } from './canvas-operations.js';
|
|
17
17
|
import { findOpenCanvasPosition } from './placement.js';
|
|
18
18
|
|
|
19
19
|
const BUNDLED_WEB_ARTIFACT_SCRIPTS_DIR = join(import.meta.dir, 'web-artifacts', 'scripts');
|
|
@@ -95,12 +95,14 @@ export interface WebArtifactSourceContext {
|
|
|
95
95
|
export interface WebArtifactCanvasOpenResult {
|
|
96
96
|
nodeId: string;
|
|
97
97
|
url: string;
|
|
98
|
+
sizeAdjustment: CanvasSizeAdjustment;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
|
|
101
102
|
openedInCanvas: boolean;
|
|
102
103
|
nodeId?: string;
|
|
103
104
|
url?: string;
|
|
105
|
+
sizeAdjustment?: CanvasSizeAdjustment;
|
|
104
106
|
startedAt: string;
|
|
105
107
|
completedAt: string;
|
|
106
108
|
durationMs: number;
|
|
@@ -740,7 +742,11 @@ export function openWebArtifactInCanvas(input: {
|
|
|
740
742
|
// This keeps web-artifacts.ts server-independent so the registry op
|
|
741
743
|
// (operations/ops/app.ts) can import it without pulling in server.ts.
|
|
742
744
|
emitCanvasLayoutUpdate();
|
|
743
|
-
return {
|
|
745
|
+
return {
|
|
746
|
+
nodeId: id,
|
|
747
|
+
url,
|
|
748
|
+
sizeAdjustment: { requested: { width: null, height: null }, applied: node.size, reason: 'defaulted' },
|
|
749
|
+
};
|
|
744
750
|
}
|
|
745
751
|
|
|
746
752
|
export async function buildWebArtifactOnCanvas(
|
|
@@ -748,6 +754,7 @@ export async function buildWebArtifactOnCanvas(
|
|
|
748
754
|
openInCanvas?: boolean;
|
|
749
755
|
},
|
|
750
756
|
): Promise<WebArtifactCanvasBuildResult> {
|
|
757
|
+
const boardGeneration = canvasState.boardGeneration;
|
|
751
758
|
const startedMs = Date.now();
|
|
752
759
|
const startedAt = new Date(startedMs).toISOString();
|
|
753
760
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -759,7 +766,7 @@ export async function buildWebArtifactOnCanvas(
|
|
|
759
766
|
durationMs: completedMs - startedMs,
|
|
760
767
|
timeoutMs,
|
|
761
768
|
};
|
|
762
|
-
if (input.openInCanvas === false) {
|
|
769
|
+
if (input.openInCanvas === false || canvasState.boardGeneration !== boardGeneration) {
|
|
763
770
|
return { ...build, openedInCanvas: false, ...timing };
|
|
764
771
|
}
|
|
765
772
|
const opened = openWebArtifactInCanvas({
|
|
@@ -778,6 +785,7 @@ export async function buildWebArtifactOnCanvas(
|
|
|
778
785
|
openedInCanvas: true,
|
|
779
786
|
nodeId: opened.nodeId,
|
|
780
787
|
url: opened.url,
|
|
788
|
+
sizeAdjustment: opened.sizeAdjustment,
|
|
781
789
|
...timing,
|
|
782
790
|
};
|
|
783
791
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { request as httpsRequest } from 'node:https';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
|
|
1
7
|
const FETCH_TIMEOUT_MS = 10000;
|
|
2
8
|
const MAX_HTML_LENGTH = 1_000_000;
|
|
3
9
|
const MAX_TEXT_LENGTH = 50_000;
|
|
4
10
|
const EXCERPT_LENGTH = 420;
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
5
12
|
|
|
6
13
|
export const WEBPAGE_NODE_DEFAULT_SIZE = {
|
|
7
14
|
width: 520,
|
|
@@ -236,24 +243,177 @@ export function summarizeWebpageContent(data: Record<string, unknown>, maxLength
|
|
|
236
243
|
return parts.join('\n').trim();
|
|
237
244
|
}
|
|
238
245
|
|
|
239
|
-
export
|
|
246
|
+
export type AddressScope = 'public' | 'private' | 'link-local';
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Where an IP literal points. `private` covers loopback, RFC 1918, CGNAT and
|
|
250
|
+
* IPv6 ULA; `link-local` is kept apart because it holds cloud metadata
|
|
251
|
+
* (169.254.169.254) and is refused on every hop.
|
|
252
|
+
*/
|
|
253
|
+
export function classifyAddress(address: string): AddressScope {
|
|
254
|
+
let ip = address.toLowerCase().replace(/^\[|\]$/g, '');
|
|
255
|
+
// URL canonicalization also covers expanded IPv6 and dotted mapped IPv4.
|
|
256
|
+
// Checking only ::ffff:a.b.c.d misses the hex form used by URL.hostname.
|
|
257
|
+
if (isIP(ip) === 6) ip = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
|
|
258
|
+
const mapped = ip.match(/^::ffff:([\da-f]+):([\da-f]+)$/);
|
|
259
|
+
if (mapped) {
|
|
260
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
261
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
262
|
+
return classifyAddress(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
|
263
|
+
}
|
|
264
|
+
if (isIP(ip) === 4) {
|
|
265
|
+
const [a, b] = ip.split('.').map(Number);
|
|
266
|
+
if (a === 169 && b === 254) return 'link-local';
|
|
267
|
+
if (a === 127 || a === 10 || a === 0) return 'private';
|
|
268
|
+
if (a === 172 && b >= 16 && b <= 31) return 'private';
|
|
269
|
+
if (a === 192 && b === 168) return 'private';
|
|
270
|
+
if (a === 100 && b >= 64 && b <= 127) return 'private';
|
|
271
|
+
return 'public';
|
|
272
|
+
}
|
|
273
|
+
if (/^fe[89ab]/.test(ip)) return 'link-local';
|
|
274
|
+
if (ip === '::1' || ip === '::' || /^f[cd]/.test(ip)) return 'private';
|
|
275
|
+
return 'public';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface ResolvedHop {
|
|
279
|
+
addresses: string[];
|
|
280
|
+
scope: AddressScope;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ResolveHost = (hostname: string) => Promise<Array<{ address: string; family: number }>>;
|
|
284
|
+
|
|
285
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
288
|
+
if (signal.aborted) return abort();
|
|
289
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
290
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Resolves a hop once, retaining the narrowest scope any returned address reaches. */
|
|
295
|
+
async function resolveHop(url: URL, resolveHost: ResolveHost): Promise<ResolvedHop> {
|
|
296
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
297
|
+
const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await resolveHost(host);
|
|
298
|
+
const selected = addresses[0];
|
|
299
|
+
if (!selected || (selected.family !== 4 && selected.family !== 6)) {
|
|
300
|
+
throw new WebpageFetchError(`Could not resolve ${host}.`);
|
|
301
|
+
}
|
|
302
|
+
const scopes = addresses.map((entry) => classifyAddress(entry.address));
|
|
303
|
+
const scope = scopes.includes('link-local') ? 'link-local' : scopes.includes('private') ? 'private' : 'public';
|
|
304
|
+
return { addresses: addresses.map((entry) => entry.address), scope };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function requestHop(url: URL, resolved: ResolvedHop, signal: AbortSignal): Promise<Response> {
|
|
308
|
+
let failure: unknown;
|
|
309
|
+
for (const address of resolved.addresses) {
|
|
310
|
+
try {
|
|
311
|
+
return await requestAddress(url, address, signal);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (signal.aborted) throw error;
|
|
314
|
+
failure = error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw failure;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function requestAddress(url: URL, address: string, signal: AbortSignal): Promise<Response> {
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
|
|
323
|
+
url,
|
|
324
|
+
{
|
|
325
|
+
method: 'GET',
|
|
326
|
+
hostname: address,
|
|
327
|
+
servername: isIP(url.hostname.replace(/^\[|\]$/g, '')) ? undefined : url.hostname,
|
|
328
|
+
signal,
|
|
329
|
+
headers: {
|
|
330
|
+
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
|
|
331
|
+
Host: url.host,
|
|
332
|
+
'User-Agent': 'pmx-canvas webpage node',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
(incoming) => {
|
|
336
|
+
const headers = new Headers();
|
|
337
|
+
for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
|
|
338
|
+
headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
|
|
339
|
+
}
|
|
340
|
+
const status = incoming.statusCode ?? 500;
|
|
341
|
+
const empty = status === 204 || status === 205 || status === 304;
|
|
342
|
+
// Node and DOM declare separate types for the same web-stream API.
|
|
343
|
+
const body = empty ? null : (Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>);
|
|
344
|
+
if (empty) incoming.resume();
|
|
345
|
+
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers }));
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
request.on('error', reject);
|
|
349
|
+
request.end();
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readBoundedBody(response: Response): Promise<string> {
|
|
354
|
+
const reader = response.body?.getReader();
|
|
355
|
+
if (!reader) return '';
|
|
356
|
+
const decoder = new TextDecoder();
|
|
357
|
+
let body = '';
|
|
358
|
+
let bytes = 0;
|
|
359
|
+
while (bytes < MAX_HTML_LENGTH) {
|
|
360
|
+
const { done, value } = await reader.read();
|
|
361
|
+
if (done) break;
|
|
362
|
+
const remaining = MAX_HTML_LENGTH - bytes;
|
|
363
|
+
body += decoder.decode(value.subarray(0, remaining), { stream: value.byteLength <= remaining });
|
|
364
|
+
bytes += Math.min(value.byteLength, remaining);
|
|
365
|
+
if (value.byteLength > remaining || bytes === MAX_HTML_LENGTH) {
|
|
366
|
+
await reader.cancel();
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return body + decoder.decode();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Refuses link-local on every hop, and a redirect that moves a public fetch
|
|
375
|
+
* onto a loopback or private address. A URL the caller points at a local dev
|
|
376
|
+
* server on purpose still loads, and may redirect within the local network.
|
|
377
|
+
*/
|
|
378
|
+
export function checkWebpageHop(originScope: AddressScope, hopScope: AddressScope, hopUrl: string): void {
|
|
379
|
+
if (hopScope === 'link-local') {
|
|
380
|
+
throw new WebpageFetchError(`Refused to fetch ${hopUrl}: link-local addresses are not allowed.`);
|
|
381
|
+
}
|
|
382
|
+
if (originScope === 'public' && hopScope === 'private') {
|
|
383
|
+
throw new WebpageFetchError(`Refused redirect to ${hopUrl}: a public page may not redirect to a private address.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function fetchWebpageSnapshot(
|
|
388
|
+
inputUrl: string,
|
|
389
|
+
options: { resolveHost?: ResolveHost; timeoutMs?: number } = {},
|
|
390
|
+
): Promise<WebpageSnapshot> {
|
|
240
391
|
const url = normalizeWebpageUrl(inputUrl);
|
|
241
392
|
const controller = new AbortController();
|
|
242
|
-
const
|
|
393
|
+
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
394
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
395
|
+
const resolveHost = options.resolveHost ?? ((host) => lookup(host, { all: true }));
|
|
243
396
|
|
|
244
397
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
398
|
+
// Redirects are followed by hand so every hop's host is checked before it is fetched.
|
|
399
|
+
let resolved = await abortable(resolveHop(new URL(url), resolveHost), controller.signal);
|
|
400
|
+
const originScope = resolved.scope;
|
|
401
|
+
let hopUrl = url;
|
|
402
|
+
let response: Response;
|
|
403
|
+
for (let hop = 0; ; hop++) {
|
|
404
|
+
checkWebpageHop(originScope, resolved.scope, hopUrl);
|
|
405
|
+
response = await requestHop(new URL(hopUrl), resolved, controller.signal);
|
|
406
|
+
const location = response.headers.get('location');
|
|
407
|
+
if (response.status < 300 || response.status >= 400 || !location) break;
|
|
408
|
+
await response.body?.cancel();
|
|
409
|
+
if (hop === MAX_REDIRECTS) throw new WebpageFetchError(`Too many redirects while fetching ${url}.`);
|
|
410
|
+
hopUrl = normalizeWebpageUrl(new URL(location, hopUrl).toString());
|
|
411
|
+
resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost), controller.signal);
|
|
412
|
+
}
|
|
253
413
|
|
|
254
414
|
const contentType = response.headers.get('content-type');
|
|
255
|
-
const responseUrl =
|
|
256
|
-
const body =
|
|
415
|
+
const responseUrl = hopUrl;
|
|
416
|
+
const body = await readBoundedBody(response);
|
|
257
417
|
|
|
258
418
|
if (!response.ok) {
|
|
259
419
|
throw new WebpageFetchError(`Request failed with ${response.status} ${response.statusText}`.trim(), {
|
|
@@ -292,7 +452,7 @@ export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSna
|
|
|
292
452
|
} catch (error) {
|
|
293
453
|
if (error instanceof WebpageFetchError) throw error;
|
|
294
454
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
295
|
-
throw new WebpageFetchError(`Timed out after ${
|
|
455
|
+
throw new WebpageFetchError(`Timed out after ${timeoutMs}ms while fetching ${url}.`);
|
|
296
456
|
}
|
|
297
457
|
throw new WebpageFetchError(error instanceof Error ? error.message : `Failed to fetch ${url}.`);
|
|
298
458
|
} finally {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Per-process capability used only to distinguish workbench clicks from HTTP agents. */
|
|
4
|
+
export const workbenchToken = randomBytes(32).toString('base64url');
|
|
5
|
+
|
|
6
|
+
export function isWorkbenchToken(value: string | null): boolean {
|
|
7
|
+
if (!value) return false;
|
|
8
|
+
const actual = Buffer.from(value);
|
|
9
|
+
const expected = Buffer.from(workbenchToken);
|
|
10
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
11
|
+
}
|
package/src/server/workboard.ts
CHANGED
|
@@ -82,14 +82,14 @@ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
|
|
|
82
82
|
}
|
|
83
83
|
elements[columnId] = {
|
|
84
84
|
type: 'Stack',
|
|
85
|
-
props: { direction: 'vertical', gap: 'sm' },
|
|
85
|
+
props: { direction: 'vertical', gap: 'sm', align: 'stretch' },
|
|
86
86
|
children: columnChildren,
|
|
87
87
|
};
|
|
88
88
|
columnIds.push(columnId);
|
|
89
89
|
}
|
|
90
90
|
elements.board = {
|
|
91
|
-
type: '
|
|
92
|
-
props: {
|
|
91
|
+
type: 'Grid',
|
|
92
|
+
props: { columns: columnIds.length, gap: 'md' },
|
|
93
93
|
children: columnIds,
|
|
94
94
|
};
|
|
95
95
|
return { root: 'board', elements };
|