partforge 0.41.0 → 0.45.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 +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- 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 +254 -0
- package/src/framework/animation.js +271 -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/capture-build.js +59 -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 +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- 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 +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- 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 +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /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}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
|
|
5
5
|
// delivered pose) and is re-stamped current — no worker job. Everything else
|
|
6
6
|
// falls through to the normal regen loop.
|
|
7
|
-
import { viewSubParts } from "./
|
|
7
|
+
import { viewSubParts } from "./part-model.js";
|
|
8
8
|
import { probePoses } from "./pose-probe.js";
|
|
9
9
|
import { poseDelta } from "./geometry/pose.js";
|
|
10
10
|
|
|
@@ -28,6 +28,17 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
|
|
|
28
28
|
stamps[name] = probeFor().get(name);
|
|
29
29
|
},
|
|
30
30
|
|
|
31
|
+
// Drop a subpart's stamp: the mesh in the viewer is no longer known to
|
|
32
|
+
// correspond to any probed pose. Used when meshes are SHOWN without being
|
|
33
|
+
// recorded — a build delivered stale because animation frames kept bumping
|
|
34
|
+
// the version is displayed best-effort, but its geometry was not built at
|
|
35
|
+
// the live params, so no stamp may describe it. Without this the next edit
|
|
36
|
+
// would re-pose that newer mesh off the PREVIOUS delivery's stamp, i.e.
|
|
37
|
+
// apply a delta measured against geometry that is no longer on screen.
|
|
38
|
+
forget(name) {
|
|
39
|
+
delete stamps[name];
|
|
40
|
+
},
|
|
41
|
+
|
|
31
42
|
// Re-pose every visible stale subpart whose base geometry is unchanged.
|
|
32
43
|
// Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
|
|
33
44
|
// count: a slider drag repairs the same subpart on every input event, so only
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
|
|
2
|
+
// whose token solids carry (a) a content-hash chain built with the shared h() and
|
|
3
|
+
// (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
|
|
4
|
+
// The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
|
|
5
|
+
// are never compared to backend hashes, so they only need to be stable and to
|
|
6
|
+
// fold every geometry-affecting argument.
|
|
7
|
+
//
|
|
8
|
+
// Trust model: any query op (boundingBox/volume/…) during a build marks that
|
|
9
|
+
// subpart untrusted — a query result could feed geometry OR pose, and the probe
|
|
10
|
+
// returns dummies, so neither hash stability nor pose values can be believed.
|
|
11
|
+
// A FUNCTION passed as (or nested inside) an op argument is untrusted for the
|
|
12
|
+
// same reason the OCCT backend refuses to hash function selectors (see `selKey`
|
|
13
|
+
// in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
|
|
14
|
+
// same source text at every value of `p.z`, so hashing it would hold baseHash
|
|
15
|
+
// stable while the real geometry changed — precisely the false-positive the fast
|
|
16
|
+
// path must never make. Untrusted subparts simply take the normal regen path.
|
|
17
|
+
import { h } from "./geometry/solid-hash.js";
|
|
18
|
+
import { addSugar } from "./geometry/solid-sugar.js";
|
|
19
|
+
import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
20
|
+
import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
|
|
21
|
+
|
|
22
|
+
const NAN3 = () => [NaN, NaN, NaN];
|
|
23
|
+
|
|
24
|
+
function makeProbeSession() {
|
|
25
|
+
const state = { count: 0, queried: false, unhashable: false };
|
|
26
|
+
const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
|
|
27
|
+
|
|
28
|
+
// Queries return dummies AND poison trust (see module comment).
|
|
29
|
+
const QUERY_DUMMIES = {
|
|
30
|
+
boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
|
|
31
|
+
volume: () => NaN,
|
|
32
|
+
genus: () => NaN,
|
|
33
|
+
isEmpty: () => false,
|
|
34
|
+
area: () => NaN,
|
|
35
|
+
toRegions: () => [],
|
|
36
|
+
simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
|
|
37
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
|
|
38
|
+
toSTL: () => new ArrayBuffer(0),
|
|
39
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Operand tokens fold into a hash key by their own (pose-folded) hash; plain
|
|
43
|
+
// data canonicalizes via h(). Functions can't be hashed at all (see the module
|
|
44
|
+
// comment), so they poison trust AND get a per-call unique key — belt and
|
|
45
|
+
// braces, so the hash can't collide even before the trust check is consulted.
|
|
46
|
+
//
|
|
47
|
+
// The walk mirrors h()'s `canon` exactly (array → elements, other object → own
|
|
48
|
+
// enumerable values), because a function nested inside an options object —
|
|
49
|
+
// `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
|
|
50
|
+
// by canon, not by the top-level argument check.
|
|
51
|
+
let unhashable = 0;
|
|
52
|
+
const argKey = (a) => {
|
|
53
|
+
if (a && a.__poseToken) return a.__folded();
|
|
54
|
+
if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
|
|
55
|
+
if (Array.isArray(a)) return a.map(argKey);
|
|
56
|
+
if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
|
|
57
|
+
return a;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function token(hash, pose) {
|
|
61
|
+
const folded = () => (pose.length ? h("posed", hash, pose) : hash);
|
|
62
|
+
const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
|
|
63
|
+
const t = {
|
|
64
|
+
__poseToken: true,
|
|
65
|
+
__folded: folded,
|
|
66
|
+
_hash: hash,
|
|
67
|
+
_pose: pose,
|
|
68
|
+
// The rigid vocabulary stays out of the hash: recorded as pending steps,
|
|
69
|
+
// exactly like the OCCT backend's pose-lazy wrap. All transform sugar
|
|
70
|
+
// (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
|
|
71
|
+
translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
|
|
72
|
+
rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
|
|
73
|
+
clone: () => t, // tokens are immutable — sharing is safe
|
|
74
|
+
// regions() is a data-returning query in disguise: on a real backend the
|
|
75
|
+
// scission ARRAY LENGTH is param-dependent data, so a build branching on
|
|
76
|
+
// `regions().length` could hold baseHash stable while geometry changed.
|
|
77
|
+
// It therefore poisons trust like any other query; the single token is
|
|
78
|
+
// still returned so op chains on regions()[0] don't crash mid-probe.
|
|
79
|
+
regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
|
|
80
|
+
};
|
|
81
|
+
for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
|
|
82
|
+
t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
|
|
83
|
+
// Every other contract op folds pose + args into a fresh hash. Generated from
|
|
84
|
+
// the kernel-contract lists so new ops can never silently drift out of the probe.
|
|
85
|
+
for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
|
|
86
|
+
t[op] ??= foldOp(op);
|
|
87
|
+
return addSugar(t);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Kernel: catch-all factory — any op makes a fresh token hashed from its args.
|
|
91
|
+
const kernelQueries = {
|
|
92
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
93
|
+
cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
|
|
94
|
+
cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
|
|
95
|
+
};
|
|
96
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
97
|
+
const kernel = new Proxy({}, {
|
|
98
|
+
get(_t, key) {
|
|
99
|
+
if (ignore(key)) return undefined;
|
|
100
|
+
if (key in kernelQueries) return kernelQueries[key];
|
|
101
|
+
return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { kernel, state };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
|
109
|
+
const stepsFinite = (steps) => steps.every((st) =>
|
|
110
|
+
st.t === "translate"
|
|
111
|
+
? finiteVec(st.v)
|
|
112
|
+
: Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
|
|
113
|
+
|
|
114
|
+
// Probe ONE sub-part's build+place at explicit params/purpose. The shared
|
|
115
|
+
// primitive under probePoses (pose-probe.js) and the lint place/animation
|
|
116
|
+
// rules — kept free of jobs.js imports so the lint import closure stays pure
|
|
117
|
+
// (test/lint-purity.test.js). Never throws; a failing/queried/weird sub-part
|
|
118
|
+
// yields { trusted: false }.
|
|
119
|
+
export function probeSubPartPose(sp, { view, purpose = "display", p, d }) {
|
|
120
|
+
try {
|
|
121
|
+
const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
|
|
122
|
+
let s = sp.build(kernel, p, d);
|
|
123
|
+
if (sp.place) s = sp.place(s, { view, purpose, p, d });
|
|
124
|
+
const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
|
|
125
|
+
return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
|
|
126
|
+
} catch {
|
|
127
|
+
return { trusted: false };
|
|
128
|
+
}
|
|
129
|
+
}
|