partforge 0.40.0 → 0.44.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/README.md +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +23 -42
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +19 -6
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +126 -16
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- package/src/testing/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
|
@@ -3,9 +3,9 @@ import { measure as defaultMeasure } from "./measure.js";
|
|
|
3
3
|
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
4
4
|
import { resolveProfile } from "./dfm-profiles.js";
|
|
5
5
|
import { expandCases } from "./cases.js";
|
|
6
|
-
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../
|
|
7
|
-
import { resolveParams } from "../
|
|
8
|
-
import { SUBPART_METRICS, VIEW_METRICS } from "../
|
|
6
|
+
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
|
|
7
|
+
import { resolveParams } from "../part-model.js";
|
|
8
|
+
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
9
9
|
|
|
10
10
|
// Re-exported for backwards compatibility: the registries moved to framework/ so
|
|
11
11
|
// the linter can read the metric vocabulary without importing a geometry kernel.
|
|
@@ -118,14 +118,23 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
|
118
118
|
if (actual === null || actual === undefined) {
|
|
119
119
|
if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
|
|
120
120
|
if (metric === "minWall") {
|
|
121
|
-
|
|
121
|
+
const out = { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
|
|
122
122
|
hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
|
|
123
|
+
// A missing reading still has a HOW: a sampled run whose rays all missed says
|
|
124
|
+
// so here, rather than reading like a mesh min-wall never looked at.
|
|
125
|
+
const note = reg.note?.(factsObj);
|
|
126
|
+
if (note) out.note = note;
|
|
127
|
+
return out;
|
|
123
128
|
}
|
|
124
129
|
return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
|
|
125
130
|
}
|
|
126
131
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
127
132
|
const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
|
|
128
133
|
const out = { ...base, actual, status, pass, message };
|
|
134
|
+
// A measurement caveat rides along whatever the verdict — a min-wall reading
|
|
135
|
+
// taken from a sample still passed, but the reader should know it was a sample.
|
|
136
|
+
const note = reg.note?.(factsObj);
|
|
137
|
+
if (note) out.note = note;
|
|
129
138
|
if (!pass) {
|
|
130
139
|
out.hint = partHint ?? reg.hint;
|
|
131
140
|
if (reg.pattern) out.pattern = reg.pattern;
|
|
@@ -158,7 +167,10 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
|
158
167
|
return checks;
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
|
|
170
|
+
// `seed` lets a caller that has ALREADY measured this part hand the result in so
|
|
171
|
+
// verify does not recompute it — see the seeding block below for the shape and
|
|
172
|
+
// the one correctness rule that governs it.
|
|
173
|
+
export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed } = {}) {
|
|
162
174
|
view = view ?? Object.keys(part.views)[0];
|
|
163
175
|
const profileSpec = process ?? part.verify?.process;
|
|
164
176
|
const profile = profileSpec ? resolveProfile(profileSpec) : null;
|
|
@@ -185,6 +197,50 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
185
197
|
: [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
|
|
186
198
|
|
|
187
199
|
const memo = new Map();
|
|
200
|
+
|
|
201
|
+
// SEEDING. expandCases always yields a "defaults" case, and the inspect job
|
|
202
|
+
// (framework/jobs.js) measures those exact params immediately before calling
|
|
203
|
+
// verify — so without this the oracle rebuilds the same geometry, casts the
|
|
204
|
+
// same min-wall rays and re-indexes the same meshes a second time. On a
|
|
205
|
+
// single-case part that is half the job.
|
|
206
|
+
// seed = { params, result } — the params the result was measured with, and
|
|
207
|
+
// the measure() output itself. Nothing else: every fact the rule below needs
|
|
208
|
+
// is read off the artifact, so a caller cannot assert it wrongly.
|
|
209
|
+
//
|
|
210
|
+
// THE MIN-WALL SUPERSET RULE, which is the trap here. measureCase asks for
|
|
211
|
+
// `{ minWall: needMinWall }`, and needMinWall is false whenever no profile and
|
|
212
|
+
// no expectation mentions min wall. A result measured WITH min wall is a strict
|
|
213
|
+
// superset of one measured without: the extra fields are only ever read by the
|
|
214
|
+
// minWall metric, which by definition this run never checks. Reuse in that
|
|
215
|
+
// direction is free. The reverse is NOT safe — a seed taken without min wall
|
|
216
|
+
// carries `minWall: null` on every sub-part, which the registry reports as
|
|
217
|
+
// "min wall unavailable", silently downgrading a real gate to a warning. So the
|
|
218
|
+
// seed is consulted only when `seed.result.measuredMinWall || !needMinWall` —
|
|
219
|
+
// and `measuredMinWall` is stamped by measure() itself, not claimed by whoever
|
|
220
|
+
// holds the result. Otherwise the seed is ignored and the case measured properly.
|
|
221
|
+
//
|
|
222
|
+
// ALIASING. A consulted seed is memoized BY REFERENCE, so the caller's result
|
|
223
|
+
// and every case that hits it are the same object — the inspect job's
|
|
224
|
+
// `report.measure` and `report.verify.cases[0]`'s facts included. Nothing here
|
|
225
|
+
// mutates facts (evaluateCase only reads), and that is what makes the sharing
|
|
226
|
+
// safe; a future check that wants to annotate a fact must copy first.
|
|
227
|
+
//
|
|
228
|
+
// Keyed through the SAME signature() the memo uses, never a JSON compare of the
|
|
229
|
+
// raw params — a separate compare would miss cases that share a signature (a
|
|
230
|
+
// preset touching only params the build never reads) and, worse, could hit on
|
|
231
|
+
// params that merely look equal. The seed's params are layered over
|
|
232
|
+
// part.defaults first because a caller's `{}` and the defaults case's
|
|
233
|
+
// `{...part.defaults}` build identical geometry but hash differently
|
|
234
|
+
// (JSON.stringify({}) is not JSON.stringify(defaults), and relevanceHash reads
|
|
235
|
+
// params[k] straight through). The view is checked too: measure()'s output is
|
|
236
|
+
// per-view, and a seed from another view would be a silent wrong answer.
|
|
237
|
+
//
|
|
238
|
+
// Non-default measure options (a custom `gapThreshold`) are the caller's
|
|
239
|
+
// responsibility: seed only a measurement taken the way verify would take it.
|
|
240
|
+
if (seed?.result && (seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
|
|
241
|
+
memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
|
|
242
|
+
}
|
|
243
|
+
|
|
188
244
|
const measureCase = (params) => {
|
|
189
245
|
const key = signature(params);
|
|
190
246
|
if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
|
|
4
4
|
// probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
|
-
import { viewSubParts } from "./
|
|
6
|
+
import { viewSubParts } from "./part-model.js";
|
|
7
7
|
import { resolveDerived } from "./derive.js";
|
|
8
8
|
|
|
9
9
|
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The pure part model: which sub-parts a view shows, which of those are exportable,
|
|
2
|
+
// how a part's params resolve, and how one posed sub-part solid is built. No async,
|
|
3
|
+
// no worker protocol, no kernel boot — just synchronous functions over a
|
|
4
|
+
// PartDefinition, a kernel handle, and params.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately a LEAF of the framework graph. buildPosed is the single definition of
|
|
7
|
+
// "a posed sub-part solid", so the worker job loop (jobs.js), the collision check
|
|
8
|
+
// (assembly.js), the relevance probe (param-deps.js), and the headless oracle
|
|
9
|
+
// (oracle/) all call it. Keeping those four functions out of jobs.js — which is
|
|
10
|
+
// async, imports the kernels, and pulls in the whole export stack — is what lets the
|
|
11
|
+
// oracle depend on the part model without an import cycle back through the job loop.
|
|
12
|
+
import { resolveDerived } from "./derive.js";
|
|
13
|
+
|
|
14
|
+
// Names of the sub-parts a view shows: declared in the view and enabled for these
|
|
15
|
+
// params. Order follows Object.keys(part.parts) (definition order).
|
|
16
|
+
export function viewSubParts(part, view, params) {
|
|
17
|
+
return Object.keys(part.parts).filter((name) => {
|
|
18
|
+
const sp = part.parts[name];
|
|
19
|
+
const inView = sp.views.includes(view);
|
|
20
|
+
const on = sp.enabled ? !!sp.enabled(params) : true;
|
|
21
|
+
return inView && on;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
|
|
26
|
+
// flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
|
|
27
|
+
// placeholders, etc.). They still show in the viewer; they're just never written to
|
|
28
|
+
// an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
|
|
29
|
+
export function exportSubParts(part, view, params) {
|
|
30
|
+
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolve a part's effective params + derived values for a build: the user's params
|
|
34
|
+
// layered over the part defaults, and derive() run once over the result.
|
|
35
|
+
export function resolveParams(part, params) {
|
|
36
|
+
const p = { ...part.defaults, ...params };
|
|
37
|
+
return { p, d: resolveDerived(part, p) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build one sub-part and apply its optional place() for the given purpose/view.
|
|
41
|
+
// `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
|
|
42
|
+
// sub-part solid" — the worker, the collision check, and the test harness all call
|
|
43
|
+
// it, so display/export poses can never drift between the app and its tests.
|
|
44
|
+
export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
|
|
45
|
+
const sp = part.parts[name];
|
|
46
|
+
const solid = sp.build(kernel, p, d, onProgress);
|
|
47
|
+
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
48
|
+
}
|
|
@@ -4,18 +4,26 @@
|
|
|
4
4
|
// user is working with an agent.
|
|
5
5
|
import { createPromptBanner } from "./prompt-banner.js";
|
|
6
6
|
import { formatSelection } from "../selection/format.js";
|
|
7
|
+
import { PICK_SERVER_DEFAULT_URL } from "./endpoint.js";
|
|
7
8
|
|
|
8
|
-
export function createPickRequestClient({ serverUrl = "
|
|
9
|
+
export function createPickRequestClient({ serverUrl = PICK_SERVER_DEFAULT_URL, token = "", viewer, part, getContext }) {
|
|
9
10
|
let active = null; // { id, index } of the agent prompt we're waiting on
|
|
10
11
|
const banner = createPromptBanner({ viewer, part, getContext });
|
|
11
12
|
|
|
13
|
+
// Every route on the pick-server is token-gated. POSTs carry it as a header;
|
|
14
|
+
// EventSource cannot set headers, so the stream carries it in the query string.
|
|
12
15
|
const postJson = (path, body) =>
|
|
13
16
|
fetch(`${serverUrl}${path}`, {
|
|
14
|
-
method: "POST",
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/json", ...(token ? { "x-pick-token": token } : {}) },
|
|
19
|
+
body: JSON.stringify(body),
|
|
15
20
|
}).catch(() => banner.message("⚠ couldn't reach pick-server — click not sent"));
|
|
16
21
|
|
|
17
22
|
// --- agent prompts over SSE -------------------------------------------------
|
|
18
|
-
const
|
|
23
|
+
const eventsUrl = token
|
|
24
|
+
? `${serverUrl}/events?token=${encodeURIComponent(token)}`
|
|
25
|
+
: `${serverUrl}/events`;
|
|
26
|
+
const es = new globalThis.EventSource(eventsUrl);
|
|
19
27
|
es.addEventListener("prompt", (e) => {
|
|
20
28
|
const v = JSON.parse(e.data);
|
|
21
29
|
active = { id: v.id, index: v.index };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Shared constants + loopback checks for request-a-pick. Deliberately free of both
|
|
2
|
+
// node: imports and DOM access: the browser client, mount.js, the Node server, and
|
|
3
|
+
// the CLI all need these, and the browser side must never pull in node:http.
|
|
4
|
+
//
|
|
5
|
+
// Why loopback matters: the pick-server streams the agent's prompts and receives the
|
|
6
|
+
// user's selection (including live parameter values). Both ends must be pinned to the
|
|
7
|
+
// local machine — an arbitrary `?pickserver=https://evil.example` would ship every
|
|
8
|
+
// click off-box, and an arbitrary reflected CORS origin would let any page the user
|
|
9
|
+
// visits read the stream.
|
|
10
|
+
export const PICK_SERVER_DEFAULT_PORT = 4518;
|
|
11
|
+
export const PICK_SERVER_DEFAULT_TIMEOUT_MS = 120000;
|
|
12
|
+
export const PICK_SERVER_DEFAULT_HOST = "127.0.0.1";
|
|
13
|
+
export const PICK_SERVER_DEFAULT_URL = `http://${PICK_SERVER_DEFAULT_HOST}:${PICK_SERVER_DEFAULT_PORT}`;
|
|
14
|
+
|
|
15
|
+
// The whole 127/8 block plus the IPv6 loopback and the `localhost` name. Anything
|
|
16
|
+
// else (including 0.0.0.0 and names that merely resolve to 127.0.0.1) is rejected —
|
|
17
|
+
// a DNS name is exactly the DNS-rebinding vector we are guarding against.
|
|
18
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1", "[::1]"]);
|
|
19
|
+
const isLoopbackHostname = (h) =>
|
|
20
|
+
LOOPBACK_HOSTNAMES.has(h) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
21
|
+
|
|
22
|
+
// `origin` is an HTTP Origin header value: scheme://host[:port], no path.
|
|
23
|
+
export function isLoopbackOrigin(origin) {
|
|
24
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
25
|
+
let u;
|
|
26
|
+
try { u = new URL(origin); } catch { return false; }
|
|
27
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
28
|
+
return isLoopbackHostname(u.hostname);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `host` is an HTTP Host header value: host[:port], no scheme. A Host that names
|
|
32
|
+
// anything but loopback means the request arrived through a rebound DNS name.
|
|
33
|
+
export function isLoopbackHost(host, port) {
|
|
34
|
+
if (typeof host !== "string" || host === "") return false;
|
|
35
|
+
let u;
|
|
36
|
+
try { u = new URL(`http://${host}`); } catch { return false; }
|
|
37
|
+
if (!isLoopbackHostname(u.hostname)) return false;
|
|
38
|
+
// An explicit port must be ours; a bare host means port 80, which we never bind.
|
|
39
|
+
return u.port !== "" && Number(u.port) === Number(port);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isLoopbackUrl(url) {
|
|
43
|
+
if (typeof url !== "string" || url === "") return false;
|
|
44
|
+
let u;
|
|
45
|
+
try { u = new URL(url); } catch { return false; }
|
|
46
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
47
|
+
return isLoopbackHostname(u.hostname);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Normalise a user-supplied `?pickserver=` value to an origin we are willing to talk
|
|
51
|
+
// to. Anything non-loopback falls back to the default and reports why, so a tampered
|
|
52
|
+
// URL degrades to "talks to the local server" rather than "exfiltrates every click".
|
|
53
|
+
export function resolvePickServerUrl(raw, { onReject } = {}) {
|
|
54
|
+
if (typeof raw !== "string" || raw === "") return PICK_SERVER_DEFAULT_URL;
|
|
55
|
+
if (!isLoopbackUrl(raw)) {
|
|
56
|
+
onReject?.(raw);
|
|
57
|
+
return PICK_SERVER_DEFAULT_URL;
|
|
58
|
+
}
|
|
59
|
+
return raw.replace(/\/+$/, ""); // paths are appended verbatim; no double slash
|
|
60
|
+
}
|
|
@@ -1 +1,7 @@
|
|
|
1
1
|
export { createPickRequestClient } from "./client.js";
|
|
2
|
+
// Browser-safe half of the endpoint contract (no node: imports) — mount.js needs the
|
|
3
|
+
// loopback check and the default URL, and must not reach into the Node server module.
|
|
4
|
+
export {
|
|
5
|
+
resolvePickServerUrl, isLoopbackUrl,
|
|
6
|
+
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_URL,
|
|
7
|
+
} from "./endpoint.js";
|
|
@@ -1,26 +1,155 @@
|
|
|
1
1
|
// src/framework/pick-request/server.js
|
|
2
2
|
// The Node side of request-a-pick: an http+SSE server holding one active batch,
|
|
3
3
|
// a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
|
|
4
|
+
//
|
|
5
|
+
// Threat model. While this is listening, any page the developer visits can reach
|
|
6
|
+
// http://127.0.0.1:<port> from their browser. Everything here exists to make that
|
|
7
|
+
// harmless:
|
|
8
|
+
// * a per-process bearer token gates every route (the browser gets it through the
|
|
9
|
+
// app URL, the CLI through ~/.partforge/pick-<port>.token),
|
|
10
|
+
// * Origin is never reflected unless it is loopback, and `*` is never emitted, so
|
|
11
|
+
// a foreign page cannot read the SSE stream or any response,
|
|
12
|
+
// * Host must name loopback, so a rebound DNS name pointing at 127.0.0.1 is refused,
|
|
13
|
+
// * bodies are capped, and
|
|
14
|
+
// * /resolve payloads are shape-checked and stripped of control characters before
|
|
15
|
+
// they can reach the agent's stdout — that print is a prompt-injection channel.
|
|
4
16
|
import { createServer, request as httpRequest } from "node:http";
|
|
17
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
18
|
import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
|
|
6
19
|
import { formatSelection } from "../selection/format.js";
|
|
20
|
+
import {
|
|
21
|
+
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
|
|
22
|
+
isLoopbackOrigin, isLoopbackHost,
|
|
23
|
+
} from "./endpoint.js";
|
|
7
24
|
|
|
8
|
-
|
|
9
|
-
|
|
25
|
+
export {
|
|
26
|
+
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
|
|
27
|
+
PICK_SERVER_DEFAULT_URL,
|
|
28
|
+
} from "./endpoint.js";
|
|
29
|
+
|
|
30
|
+
const MAX_BODY_BYTES = 256 * 1024; // no route needs more; anything bigger is abuse
|
|
31
|
+
const MAX_SELECTION_CHARS = 16 * 1024;
|
|
32
|
+
const MAX_STRING_CHARS = 512;
|
|
33
|
+
const MAX_PARAM_KEYS = 200;
|
|
34
|
+
const MAX_PROMPTS = 32;
|
|
35
|
+
const MAX_PROMPT_CHARS = 2000;
|
|
36
|
+
|
|
37
|
+
// Control characters (plus the Unicode line separators) are what let injected text
|
|
38
|
+
// forge extra CLI lines in the agent's stdout. Fold them to spaces rather than drop
|
|
39
|
+
// them, so "a\nb" cannot silently become the single token "ab".
|
|
40
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
|
|
41
|
+
export const sanitizeText = (s, max = MAX_STRING_CHARS) =>
|
|
42
|
+
String(s).replace(CONTROL_CHARS, " ").slice(0, max);
|
|
43
|
+
|
|
44
|
+
export function mintPickToken() {
|
|
45
|
+
return randomBytes(32).toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tokenMatches = (given, expected) => {
|
|
49
|
+
const a = Buffer.from(typeof given === "string" ? given : "");
|
|
50
|
+
const b = Buffer.from(expected);
|
|
51
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// EventSource cannot set headers, so the token must also be accepted in the query
|
|
55
|
+
// string; POSTs prefer a header. Both are equally secret — the URL never leaves the
|
|
56
|
+
// machine.
|
|
57
|
+
const tokenFrom = (req, query) => {
|
|
58
|
+
const header = req.headers["x-pick-token"];
|
|
59
|
+
if (typeof header === "string" && header) return header;
|
|
60
|
+
const auth = req.headers.authorization;
|
|
61
|
+
if (typeof auth === "string" && /^bearer\s+/i.test(auth)) return auth.replace(/^bearer\s+/i, "").trim();
|
|
62
|
+
return query.get("token") ?? "";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isVec3 = (v) => Array.isArray(v) && v.length === 3
|
|
66
|
+
&& v.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
67
|
+
|
|
68
|
+
// Whitelist the Selection shape resolveSelection() produces. Unknown keys are dropped
|
|
69
|
+
// rather than passed through: whatever survives here is printed to the agent, so the
|
|
70
|
+
// set of things that can reach it must be closed, not open.
|
|
71
|
+
function sanitizeSelection(selection) {
|
|
72
|
+
if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
|
|
73
|
+
return { error: "selection must be an object" };
|
|
74
|
+
}
|
|
75
|
+
let encoded;
|
|
76
|
+
try { encoded = JSON.stringify(selection); } catch { return { error: "selection is not serialisable" }; }
|
|
77
|
+
if (!encoded || encoded.length > MAX_SELECTION_CHARS) return { error: "selection too large" };
|
|
78
|
+
if (typeof selection.subPart !== "string") return { error: "selection.subPart must be a string" };
|
|
79
|
+
|
|
80
|
+
const out = { subPart: sanitizeText(selection.subPart) };
|
|
81
|
+
for (const key of ["point", "normal"]) {
|
|
82
|
+
if (selection[key] === undefined) continue;
|
|
83
|
+
if (!isVec3(selection[key])) return { error: `selection.${key} must be 3 finite numbers` };
|
|
84
|
+
out[key] = [...selection[key]];
|
|
85
|
+
}
|
|
86
|
+
if (selection.params !== undefined) {
|
|
87
|
+
const p = selection.params;
|
|
88
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return { error: "selection.params must be an object" };
|
|
89
|
+
const keys = Object.keys(p);
|
|
90
|
+
if (keys.length > MAX_PARAM_KEYS) return { error: "selection.params has too many keys" };
|
|
91
|
+
out.params = {};
|
|
92
|
+
for (const k of keys) {
|
|
93
|
+
const v = p[k];
|
|
94
|
+
const ok = (typeof v === "number" && Number.isFinite(v)) || typeof v === "boolean" || v === null
|
|
95
|
+
|| typeof v === "string";
|
|
96
|
+
if (!ok) return { error: `selection.params.${sanitizeText(k, 40)} must be a primitive` };
|
|
97
|
+
out.params[sanitizeText(k, 80)] = typeof v === "string" ? sanitizeText(v) : v;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (selection.feature !== undefined) {
|
|
101
|
+
const f = selection.feature;
|
|
102
|
+
if (!f || typeof f !== "object" || Array.isArray(f) || typeof f.label !== "string") {
|
|
103
|
+
return { error: "selection.feature must be { label }" };
|
|
104
|
+
}
|
|
105
|
+
out.feature = { label: sanitizeText(f.label) };
|
|
106
|
+
}
|
|
107
|
+
return { selection: out };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanitizePrompts(prompts) {
|
|
111
|
+
if (!Array.isArray(prompts) || prompts.length === 0) return { error: "prompts must be a non-empty array" };
|
|
112
|
+
if (prompts.length > MAX_PROMPTS) return { error: "prompts must be a non-empty array of at most 32 strings" };
|
|
113
|
+
if (!prompts.every((p) => typeof p === "string")) return { error: "prompts must be a non-empty array of strings" };
|
|
114
|
+
return { prompts: prompts.map((p) => sanitizeText(p, MAX_PROMPT_CHARS)) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const corsHeaders = (allowOrigin) => (allowOrigin
|
|
118
|
+
// `vary` because the same URL answers differently per Origin — never let a cache
|
|
119
|
+
// hand a foreign page a response minted for the local app.
|
|
120
|
+
? { "access-control-allow-origin": allowOrigin, vary: "origin" }
|
|
121
|
+
: { vary: "origin" });
|
|
122
|
+
|
|
123
|
+
const json = (res, code, obj, allowOrigin) => {
|
|
124
|
+
res.writeHead(code, { "content-type": "application/json", ...corsHeaders(allowOrigin) });
|
|
10
125
|
res.end(JSON.stringify(obj));
|
|
11
126
|
};
|
|
127
|
+
|
|
12
128
|
const readBody = (req) => new Promise((resolve_) => {
|
|
13
129
|
let b = "";
|
|
14
|
-
|
|
130
|
+
let size = 0;
|
|
131
|
+
let settled = false;
|
|
132
|
+
const settle = (v) => { if (!settled) { settled = true; resolve_(v); } };
|
|
133
|
+
req.on("data", (c) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
size += c.length;
|
|
136
|
+
if (size > MAX_BODY_BYTES) { settle({ _tooLarge: true }); return; }
|
|
137
|
+
b += c;
|
|
138
|
+
});
|
|
15
139
|
req.on("end", () => {
|
|
16
|
-
if (!b) {
|
|
17
|
-
try {
|
|
140
|
+
if (!b) { settle({}); return; }
|
|
141
|
+
try { settle(JSON.parse(b)); } catch { settle({ _parseError: true }); }
|
|
18
142
|
});
|
|
143
|
+
req.on("error", () => settle({ _parseError: true }));
|
|
19
144
|
});
|
|
20
145
|
|
|
21
|
-
export function createPickServer({
|
|
146
|
+
export function createPickServer({
|
|
147
|
+
port = PICK_SERVER_DEFAULT_PORT,
|
|
148
|
+
timeoutMs = PICK_SERVER_DEFAULT_TIMEOUT_MS,
|
|
149
|
+
token = mintPickToken(),
|
|
150
|
+
} = {}) {
|
|
22
151
|
let batch = null; // the one active batch (or null)
|
|
23
|
-
let pending = null; // { res, timer } — the held POST /request response
|
|
152
|
+
let pending = null; // { res, timer, allowOrigin } — the held POST /request response
|
|
24
153
|
const sseClients = new Set();
|
|
25
154
|
const allSockets = new Set(); // track every socket for forceful teardown
|
|
26
155
|
|
|
@@ -30,34 +159,66 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
30
159
|
const finish = () => { // resolve the held /request with the result
|
|
31
160
|
if (pending) {
|
|
32
161
|
clearTimeout(pending.timer);
|
|
33
|
-
json(pending.res, 200, result(batch));
|
|
162
|
+
json(pending.res, 200, result(batch), pending.allowOrigin);
|
|
34
163
|
pending = null;
|
|
35
164
|
}
|
|
36
165
|
sse("cleared", {});
|
|
37
166
|
batch = null;
|
|
38
167
|
};
|
|
39
168
|
|
|
169
|
+
// The body was over the cap. readBody already stopped buffering, so memory is
|
|
170
|
+
// bounded; drain the rest so the 413 reaches the client cleanly instead of being
|
|
171
|
+
// RST'd away by an immediate destroy, and hang up on anyone still streaming after.
|
|
172
|
+
const tooLarge = (req, res, allowOrigin) => {
|
|
173
|
+
req.resume();
|
|
174
|
+
res.writeHead(413, { "content-type": "application/json", connection: "close", ...corsHeaders(allowOrigin) });
|
|
175
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
176
|
+
const kill = setTimeout(() => { if (!req.readableEnded) req.destroy(); }, 250);
|
|
177
|
+
kill.unref?.(); // a pending hang-up must never hold the process open
|
|
178
|
+
req.on("end", () => clearTimeout(kill));
|
|
179
|
+
};
|
|
180
|
+
|
|
40
181
|
const server = createServer(async (req, res) => {
|
|
182
|
+
const bound = server.address()?.port ?? port;
|
|
183
|
+
// 1. DNS-rebinding guard. A foreign name that resolves to 127.0.0.1 still carries
|
|
184
|
+
// its own Host, so this refuses the request before any state is touched.
|
|
185
|
+
if (!isLoopbackHost(req.headers.host, bound)) return json(res, 400, { error: "bad host" }, null);
|
|
186
|
+
|
|
187
|
+
// 2. Origin. Absent means a native client (the CLI); present must be loopback. An
|
|
188
|
+
// arbitrary origin is never reflected, and `*` is never emitted.
|
|
41
189
|
const origin = req.headers.origin;
|
|
190
|
+
if (origin !== undefined && !isLoopbackOrigin(origin)) {
|
|
191
|
+
return json(res, 403, { error: "origin not allowed" }, null);
|
|
192
|
+
}
|
|
193
|
+
const allowOrigin = origin ?? null;
|
|
194
|
+
|
|
195
|
+
// 3. Preflight is answered before the token check on purpose: browsers never put
|
|
196
|
+
// credentials on an OPTIONS, and a 401 here would mask the real error.
|
|
42
197
|
if (req.method === "OPTIONS") {
|
|
43
198
|
res.writeHead(204, {
|
|
44
|
-
|
|
199
|
+
...corsHeaders(allowOrigin),
|
|
45
200
|
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
46
|
-
"access-control-allow-headers": "content-type",
|
|
201
|
+
"access-control-allow-headers": "content-type,authorization,x-pick-token",
|
|
202
|
+
"access-control-max-age": "600",
|
|
47
203
|
});
|
|
48
204
|
return res.end();
|
|
49
205
|
}
|
|
50
|
-
const url = req.url.split("?")[0];
|
|
51
206
|
|
|
52
|
-
|
|
53
|
-
|
|
207
|
+
const [path, rawQuery = ""] = req.url.split("?");
|
|
208
|
+
// 4. Token on every route.
|
|
209
|
+
if (!tokenMatches(tokenFrom(req, new URLSearchParams(rawQuery)), token)) {
|
|
210
|
+
return json(res, 401, { error: "unauthorized" }, allowOrigin);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (req.method === "POST" && path === "/request") {
|
|
214
|
+
if (batch) return json(res, 409, { status: "busy" }, allowOrigin);
|
|
54
215
|
const body = await readBody(req);
|
|
55
|
-
if (body.
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
batch = createBatch(
|
|
60
|
-
pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
|
|
216
|
+
if (body._tooLarge) return tooLarge(req, res, allowOrigin);
|
|
217
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
|
|
218
|
+
const checked = sanitizePrompts(body.prompts);
|
|
219
|
+
if (checked.error) return json(res, 400, { error: checked.error }, allowOrigin);
|
|
220
|
+
batch = createBatch(checked.prompts);
|
|
221
|
+
pending = { res, allowOrigin, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
|
|
61
222
|
// A dropped client (Ctrl-C'd CLI) must free the slot immediately, not wedge
|
|
62
223
|
// every new request behind 409-busy until timeoutMs. Drop `pending` first so
|
|
63
224
|
// finish() never writes to the dead socket. (This also fires after a normal
|
|
@@ -71,10 +232,10 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
71
232
|
sse("prompt", view(batch));
|
|
72
233
|
return; // held open until finish()
|
|
73
234
|
}
|
|
74
|
-
if (req.method === "GET" &&
|
|
235
|
+
if (req.method === "GET" && path === "/events") {
|
|
75
236
|
res.writeHead(200, {
|
|
76
237
|
"content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
|
|
77
|
-
|
|
238
|
+
...corsHeaders(allowOrigin),
|
|
78
239
|
});
|
|
79
240
|
res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
|
|
80
241
|
sseClients.add(res);
|
|
@@ -82,25 +243,32 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
82
243
|
req.on("close", () => sseClients.delete(res));
|
|
83
244
|
return;
|
|
84
245
|
}
|
|
85
|
-
if (req.method === "POST" &&
|
|
246
|
+
if (req.method === "POST" && path === "/resolve") {
|
|
86
247
|
const body = await readBody(req);
|
|
87
|
-
if (body.
|
|
248
|
+
if (body._tooLarge) return tooLarge(req, res, allowOrigin);
|
|
249
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
|
|
88
250
|
const { id, index, selection } = body;
|
|
251
|
+
if (typeof id !== "string" || !Number.isInteger(index) || index < 0) {
|
|
252
|
+
return json(res, 400, { error: "id must be a string and index a non-negative integer" }, allowOrigin);
|
|
253
|
+
}
|
|
254
|
+
const checked = sanitizeSelection(selection);
|
|
255
|
+
if (checked.error) return json(res, 400, { error: checked.error }, allowOrigin);
|
|
89
256
|
if (batch && id === batch.id) {
|
|
90
|
-
resolve(batch, index, selection);
|
|
257
|
+
resolve(batch, index, checked.selection);
|
|
91
258
|
if (view(batch).status === "collecting") sse("prompt", view(batch));
|
|
92
259
|
else finish();
|
|
93
260
|
}
|
|
94
|
-
return json(res, 200, { ok: true },
|
|
261
|
+
return json(res, 200, { ok: true }, allowOrigin);
|
|
95
262
|
}
|
|
96
|
-
if (req.method === "POST" &&
|
|
263
|
+
if (req.method === "POST" && path === "/cancel") {
|
|
97
264
|
const body = await readBody(req);
|
|
98
|
-
if (body.
|
|
265
|
+
if (body._tooLarge) return tooLarge(req, res, allowOrigin);
|
|
266
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
|
|
99
267
|
const { id } = body;
|
|
100
268
|
if (batch && id === batch.id) { cancel(batch); finish(); }
|
|
101
|
-
return json(res, 200, { ok: true },
|
|
269
|
+
return json(res, 200, { ok: true }, allowOrigin);
|
|
102
270
|
}
|
|
103
|
-
return json(res, 404, { error: "not found" },
|
|
271
|
+
return json(res, 404, { error: "not found" }, allowOrigin);
|
|
104
272
|
});
|
|
105
273
|
|
|
106
274
|
server.on("connection", (socket) => {
|
|
@@ -109,7 +277,8 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
109
277
|
});
|
|
110
278
|
|
|
111
279
|
return {
|
|
112
|
-
|
|
280
|
+
token,
|
|
281
|
+
start: () => new Promise((res_) => server.listen(port, PICK_SERVER_DEFAULT_HOST, () => res_({ port: server.address().port }))),
|
|
113
282
|
stop: () => new Promise((res_) => {
|
|
114
283
|
// If a batch is active, cancel it and resolve the held /request cleanly
|
|
115
284
|
// so any awaiting requestPicks() gets a result instead of a socket hang-up.
|
|
@@ -124,15 +293,31 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
|
124
293
|
|
|
125
294
|
// CLI client: POST the prompts and await the held response (blocks until the batch
|
|
126
295
|
// reaches a terminal status server-side). Fails fast with a hint if nothing answers.
|
|
127
|
-
export function requestPicks({
|
|
296
|
+
export function requestPicks({
|
|
297
|
+
port = PICK_SERVER_DEFAULT_PORT, host = PICK_SERVER_DEFAULT_HOST, prompts, token,
|
|
298
|
+
}) {
|
|
128
299
|
return new Promise((resolve_, reject) => {
|
|
129
300
|
const payload = JSON.stringify({ prompts });
|
|
130
301
|
const req = httpRequest(
|
|
131
|
-
{
|
|
302
|
+
{
|
|
303
|
+
host,
|
|
304
|
+
port,
|
|
305
|
+
path: "/request",
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: {
|
|
308
|
+
"content-type": "application/json",
|
|
309
|
+
"content-length": Buffer.byteLength(payload),
|
|
310
|
+
...(token ? { "x-pick-token": token } : {}),
|
|
311
|
+
},
|
|
312
|
+
},
|
|
132
313
|
(res) => {
|
|
133
314
|
let b = "";
|
|
134
315
|
res.on("data", (c) => (b += c));
|
|
135
316
|
res.on("end", () => {
|
|
317
|
+
if (res.statusCode === 401) {
|
|
318
|
+
reject(new Error(`pick-server on ${host}:${port} rejected the token (start it with \`partforge pick-serve\`, or pass --token)`));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
136
321
|
try {
|
|
137
322
|
resolve_(JSON.parse(b));
|
|
138
323
|
} catch {
|
|
@@ -147,9 +332,12 @@ export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
|
|
|
147
332
|
}
|
|
148
333
|
|
|
149
334
|
// Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
|
|
335
|
+
// Every string here originated in the browser, so it is sanitised again on the way
|
|
336
|
+
// out — the server already stripped control characters, but this print is the last
|
|
337
|
+
// gate before text lands in an agent's context.
|
|
150
338
|
export function formatPickResult({ status, picks }) {
|
|
151
339
|
const safePicks = Array.isArray(picks) ? picks : [];
|
|
152
|
-
const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
|
|
340
|
+
const lines = [`status: ${sanitizeText(status, 40)} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
|
|
153
341
|
for (const { prompt, selection } of safePicks) {
|
|
154
342
|
let summary;
|
|
155
343
|
try {
|
|
@@ -161,7 +349,7 @@ export function formatPickResult({ status, picks }) {
|
|
|
161
349
|
} catch {
|
|
162
350
|
summary = JSON.stringify(selection);
|
|
163
351
|
}
|
|
164
|
-
lines.push(`• "${prompt}" → ${summary}`);
|
|
352
|
+
lines.push(`• "${sanitizeText(prompt, MAX_PROMPT_CHARS)}" → ${sanitizeText(summary, MAX_SELECTION_CHARS)}`);
|
|
165
353
|
}
|
|
166
354
|
lines.push("", JSON.stringify({ status, picks }, null, 2));
|
|
167
355
|
return lines.join("\n");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Where `partforge pick-serve` leaves the session token so that `partforge pick`,
|
|
2
|
+
// which runs in a *different* process, can authenticate without the agent copying it
|
|
3
|
+
// by hand. Node-only — never imported by the browser client.
|
|
4
|
+
//
|
|
5
|
+
// The file is the token's only at-rest home: 0600 inside a 0700 directory under the
|
|
6
|
+
// user's home, not a world-readable temp dir, and removed when the server stops.
|
|
7
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const dir = () => join(homedir(), ".partforge");
|
|
12
|
+
export const pickTokenPath = (port) => join(dir(), `pick-${port}.token`);
|
|
13
|
+
|
|
14
|
+
export function savePickToken(port, token) {
|
|
15
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
16
|
+
writeFileSync(pickTokenPath(port), token, { mode: 0o600 });
|
|
17
|
+
return pickTokenPath(port);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadPickToken(port) {
|
|
21
|
+
try {
|
|
22
|
+
const t = readFileSync(pickTokenPath(port), "utf8").trim();
|
|
23
|
+
return t || null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearPickToken(port) {
|
|
30
|
+
try { rmSync(pickTokenPath(port)); } catch { /* already gone — nothing to clean */ }
|
|
31
|
+
}
|