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.
Files changed (72) hide show
  1. package/README.md +46 -6
  2. package/bin/cli.js +103 -27
  3. package/docs/AUTHORING-PARTS.md +143 -15
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +23 -42
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +113 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/framework/oracle/bvh.js +463 -0
  33. package/src/{testing → framework/oracle}/gaps.js +6 -3
  34. package/src/{testing → framework/oracle}/measure.js +34 -4
  35. package/src/framework/oracle/min-wall.js +98 -0
  36. package/src/{testing → framework/oracle}/verify.js +61 -5
  37. package/src/framework/param-deps.js +1 -1
  38. package/src/framework/part-model.js +48 -0
  39. package/src/framework/pick-request/client.js +11 -3
  40. package/src/framework/pick-request/endpoint.js +60 -0
  41. package/src/framework/pick-request/index.js +6 -0
  42. package/src/framework/pick-request/server.js +222 -34
  43. package/src/framework/pick-request/token-store.js +31 -0
  44. package/src/framework/pose-fast-path.js +12 -1
  45. package/src/framework/pose-probe-core.js +129 -0
  46. package/src/framework/pose-probe.js +7 -123
  47. package/src/framework/regen-loop.js +10 -3
  48. package/src/framework/safe-name.js +26 -0
  49. package/src/framework/verify-metrics.js +19 -6
  50. package/src/framework/view-state.js +25 -21
  51. package/src/framework/view-tabs.js +22 -7
  52. package/src/framework/viewer-controls.js +5 -26
  53. package/src/framework/viewer.js +126 -16
  54. package/src/hinged-box-worker.js +3 -0
  55. package/src/index.js +1 -1
  56. package/src/parts/hinged-box.js +94 -0
  57. package/src/testing/render.js +19 -8
  58. package/src/testing.js +15 -8
  59. package/types/derive.d.ts +14 -0
  60. package/types/geometry.d.ts +117 -0
  61. package/types/index.d.ts +240 -0
  62. package/types/kernel.d.ts +409 -0
  63. package/types/lint.d.ts +85 -0
  64. package/types/part.d.ts +381 -0
  65. package/types/testing.d.ts +362 -0
  66. package/types/worker.d.ts +21 -0
  67. package/src/testing/bvh.js +0 -273
  68. package/src/testing/min-wall.js +0 -38
  69. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  70. /package/src/{testing → framework/oracle}/cases.js +0 -0
  71. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  72. /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 "../framework/param-deps.js";
7
- import { resolveParams } from "../framework/jobs.js";
8
- import { SUBPART_METRICS, VIEW_METRICS } from "../framework/verify-metrics.js";
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
- return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
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
- export function verify(kernel, part, { process, view, measureFn = defaultMeasure } = {}) {
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 "./jobs.js";
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 = "http://127.0.0.1:4518", viewer, part, getContext }) {
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", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
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 es = new globalThis.EventSource(`${serverUrl}/events`);
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
- const json = (res, code, obj, origin) => {
9
- res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
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
- req.on("data", (c) => (b += c));
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) { resolve_({}); return; }
17
- try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
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({ port = 4518, timeoutMs = 120000 } = {}) {
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
- "access-control-allow-origin": origin || "*",
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
- if (req.method === "POST" && url === "/request") {
53
- if (batch) return json(res, 409, { status: "busy" }, origin);
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._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
56
- if (!Array.isArray(body.prompts) || body.prompts.length === 0) {
57
- return json(res, 400, { error: "prompts must be a non-empty array" }, origin);
58
- }
59
- batch = createBatch(body.prompts);
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" && url === "/events") {
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
- "access-control-allow-origin": origin || "*",
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" && url === "/resolve") {
246
+ if (req.method === "POST" && path === "/resolve") {
86
247
  const body = await readBody(req);
87
- if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
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 }, origin);
261
+ return json(res, 200, { ok: true }, allowOrigin);
95
262
  }
96
- if (req.method === "POST" && url === "/cancel") {
263
+ if (req.method === "POST" && path === "/cancel") {
97
264
  const body = await readBody(req);
98
- if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
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 }, origin);
269
+ return json(res, 200, { ok: true }, allowOrigin);
102
270
  }
103
- return json(res, 404, { error: "not found" }, origin);
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
- start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
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({ port = 4518, host = "127.0.0.1", prompts }) {
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
- { host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
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
+ }