@instruktlabs/kiln 1.0.0 → 1.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +16 -7
- package/README.md +95 -381
- package/dist/agent-run.mjs +220 -150
- package/dist/build.json +40 -28
- package/dist/cli.mjs +242 -163
- package/dist/mcp-engine.mjs +412 -154
- package/dist/mcp-server.mjs +1 -1
- package/docs/architecture.md +6 -3
- package/docs/chatgpt.md +20 -5
- package/docs/examples.md +40 -170
- package/docs/install.md +100 -35
- package/docs/migration.md +20 -0
- package/docs/sdk.md +49 -5
- package/docs/tools-operations.md +1529 -0
- package/lib/asset-widget-uri.d.ts +9 -0
- package/lib/asset-widget-uri.js +9 -0
- package/lib/cli-render-mode.js +10 -3
- package/lib/discovery/index.d.ts +1 -1
- package/lib/discovery/index.js +2 -2
- package/lib/discovery/portable.d.ts +9 -0
- package/lib/discovery/portable.js +6 -0
- package/lib/discovery/service.d.ts +3 -1
- package/lib/discovery/service.js +4 -2
- package/lib/engine-identity.d.ts +1 -1
- package/lib/engine-identity.js +1 -1
- package/lib/evaluator/isolation.js +19 -5
- package/lib/evaluator/probe-invariants.d.ts +11 -0
- package/lib/evaluator/probe-invariants.js +36 -0
- package/lib/evaluator/probe-worker.js +16 -4
- package/lib/loopback.d.ts +6 -0
- package/lib/loopback.js +6 -0
- package/lib/mcp-core.d.ts +210 -0
- package/lib/mcp-core.js +279 -0
- package/lib/mcp-engine.d.ts +59 -0
- package/lib/mcp-engine.js +289 -0
- package/lib/mcp-manifest.d.ts +15 -0
- package/lib/mcp-manifest.js +65 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +2 -0
- package/lib/render-service-host.js +1 -1
- package/lib/tools/discovery.d.ts +1 -1
- package/lib/tools/discovery.js +11 -9
- package/lib/tools/materials.d.ts +1 -1
- package/lib/tools/materials.js +44 -14
- package/lib/tools/operation-contract.d.ts +8 -0
- package/lib/tools/operation-contract.js +37 -0
- package/lib/tools/operations.d.ts +3 -0
- package/lib/tools/operations.js +154 -0
- package/lib/tools/registry.d.ts +6 -2
- package/lib/tools/registry.js +71 -23
- package/package.json +23 -1
- package/plugin.json +1 -1
- package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
- package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
- package/plugins/kiln-engine/PRIVACY.md +100 -0
- package/plugins/kiln-engine/README.md +103 -18
- package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
- package/plugins/kiln-engine/package-provenance.json +15 -8
- package/plugins/kiln-engine/plugin.json +10 -4
- package/plugins/kiln-engine/runtime.json +10 -1
- package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/scripts/create-workspace.mjs +71 -13
- package/scripts/workspace-config.mjs +239 -0
- package/scripts/workspace-project.mjs +384 -0
- package/scripts/workspace-transaction.mjs +504 -0
- package/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/src/cli-render-mode.ts +9 -9
- package/src/cli.ts +3 -3
- package/src/discovery/index.ts +2 -0
- package/src/discovery/portable.ts +9 -0
- package/src/discovery/service.ts +3 -1
- package/src/engine-identity.ts +1 -1
- package/src/evaluator/isolation.ts +27 -6
- package/src/evaluator/probe-invariants.ts +50 -0
- package/src/evaluator/probe-worker.ts +22 -4
- package/src/mcp-core.ts +3 -0
- package/src/mcp-engine.ts +11 -1
- package/src/mcp.ts +5 -0
- package/src/render-service-host.ts +1 -1
- package/src/tools/discovery.ts +20 -10
- package/src/tools/materials.ts +59 -15
- package/src/tools/operation-contract.ts +51 -0
- package/src/tools/operations.ts +177 -0
- package/src/tools/registry.ts +100 -20
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Local renderer setup
|
|
2
|
+
|
|
3
|
+
Setup asks for render mode `auto`: textured or metallic scenes use a compatible GPU service, starting one lazily when available. Ordinary untextured scenes with zero metalness and no advanced material extensions use CPU views, even with a GPU available. CPU views support silhouette, proportion and contact review, but cannot confirm PBR appearance. Use CLI `--render gpu` when material review is required, including roughness on nonmetallic surfaces.
|
|
4
|
+
|
|
5
|
+
The renderer code is included with Kiln; its native GPU dependency is optional at the Kiln package root. For material-dependent tasks, check `node kiln.mjs service status` before starting the authoring session. If dependencies are missing or incompatible, reinstall the official Kiln package with optional dependencies enabled. npm 11 may print an `install-scripts` warning for the optional `webgpu` package; its postinstall only clears the macOS download quarantine from the prebuilt Dawn binary the package ships, so Windows and Linux need nothing and the warning is not an install failure. Read `runtime` from `.kiln/workspace.json` to identify the installation. A source checkout uses `bun install --frozen-lockfile` at that root. Setup checks dependency resolution without loading native code; ready dependencies alone do not prove the device can render.
|
|
6
|
+
|
|
7
|
+
Once dependencies are available, local CLI/MCP auto mode starts a managed service lazily for a view that needs PBR shading. Compatible clients share it; it survives the initiating session and exits after five minutes without admitted uploads, queued jobs or active renders. From the installation root, `node --import ./render-service/src/register-hooks.mjs render-service/src/server.mjs` starts a manual service that remains running while idle. It binds loopback; widening `HOST` requires `RENDER_SERVICE_TOKEN`.
|
|
8
|
+
|
|
9
|
+
`node kiln.mjs service status` reports installation, verified health and lifetime. `node kiln.mjs service start` joins a compatible service or starts a managed one the way an on-demand view does, and prints its port, renderer, pid, lifetime and build; it starts nothing when a stale, foreign or unknown listener holds the socket. After dependency repair or a cached startup failure, call `kiln_renderer({action:"reprobe"})` inside the MCP session to refresh its existing route. It neither installs dependencies nor starts a renderer; the next view starts one if needed. CLI `service reprobe` checks only its own process. Restart the session after changing Kiln, environment variables or credentials. Unknown or incompatible listeners are reported and left alone. `service stop` explicitly stops a verified local service. If no usable GPU is available, report the material-review limitation and continue on CPU views when that fits the task.
|
|
10
|
+
|
|
11
|
+
A `KILN_RENDER_PORT_URL` whose origin is exactly the shared local socket (`http://127.0.0.1:8000` by default) takes the local route: it starts on demand and restarts after an idle exit. For rendering on another device, configure the MCP server process's `KILN_RENDER_PORT_URL` and, when needed, `KILN_RENDER_TOKEN` through the chosen harness. CLI accepts `--render-port URL` and reads `KILN_RENDER_TOKEN`. Start a compatible Kiln renderer on that device; its wider bind requires `RENDER_SERVICE_TOKEN` unless an authenticated proxy is explicitly configured. Manage the remote service on its own device. Verify the selected route with capabilities and require an actual `viewFidelity` receipt for material evidence.
|
package/src/cli-render-mode.ts
CHANGED
|
@@ -71,16 +71,16 @@ export function makeLazyRenderPort(
|
|
|
71
71
|
return await port(req, execution);
|
|
72
72
|
} catch (error) {
|
|
73
73
|
if (execution?.signal?.aborted || (error as Error).name === 'TimeoutError') throw error;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
throw error;
|
|
74
|
+
// A different capture may already have replaced this failed connection.
|
|
75
|
+
// Its healthy socket must not make us rethrow a late error from the old one.
|
|
76
|
+
if (resolving !== pending) return (await resolve()).port(req, execution);
|
|
77
|
+
const health = await readRenderServiceHealth(url, {
|
|
78
|
+
token,
|
|
79
|
+
signal: execution?.signal,
|
|
80
|
+
});
|
|
83
81
|
execution?.signal?.throwIfAborted();
|
|
82
|
+
if (resolving !== pending) return (await resolve()).port(req, execution);
|
|
83
|
+
if (health.kind !== 'absent') throw error;
|
|
84
84
|
// Concurrent captures can all observe the old socket disappearing. Only
|
|
85
85
|
// the first retires its connection; the others share the replacement.
|
|
86
86
|
if (resolving === pending) resolving = undefined;
|
package/src/cli.ts
CHANGED
|
@@ -82,11 +82,11 @@ EXPORT ENVIRONMENT
|
|
|
82
82
|
KILN_INDEX_POLICY indexed (default) | asBuilt (requires optimization off)
|
|
83
83
|
|
|
84
84
|
EXAMPLES
|
|
85
|
-
kiln render
|
|
85
|
+
kiln render my-asset.kiln.js --out asset.glb --views sheet.png
|
|
86
86
|
kiln generate "a weathered wooden crate" --out crate.glb --views sheet.png
|
|
87
|
-
kiln render
|
|
87
|
+
kiln render my-asset.kiln.js --render cpu --views sheet.png
|
|
88
88
|
kiln render p_RETURNED_HANDLE --capture cameras.json --views chosen.png
|
|
89
|
-
kiln render
|
|
89
|
+
kiln render my-asset.kiln.js --views sheet.png --backdrop light
|
|
90
90
|
`;
|
|
91
91
|
|
|
92
92
|
interface Args extends WorkspaceSelection {
|
package/src/discovery/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export function createDiscovery(
|
|
|
21
21
|
discovery: { mode: 'lexical', offline: true, requiresModel: false },
|
|
22
22
|
}),
|
|
23
23
|
notes: () => Promise<string[]> = async () => [],
|
|
24
|
+
capabilitiesCall = 'capabilities:true',
|
|
24
25
|
) {
|
|
25
26
|
snapshot ??= (() => {
|
|
26
27
|
const entries = listDiscoveryEntries();
|
|
@@ -32,5 +33,6 @@ export function createDiscovery(
|
|
|
32
33
|
capabilities,
|
|
33
34
|
REMOVED_AUTHORING_HELPERS,
|
|
34
35
|
notes,
|
|
36
|
+
capabilitiesCall,
|
|
35
37
|
);
|
|
36
38
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Search a host-supplied catalog without importing Node's catalog construction. */
|
|
2
|
+
export { createDiscoveryService } from './service';
|
|
3
|
+
export type { DiscoveryIndex, DiscoveryResponse, DiscoverySearchEvidence } from './service';
|
|
4
|
+
export { createLexicalDiscoveryIndex, DISCOVERY_INDEX_VERSION } from './lexical-index';
|
|
5
|
+
export { discoveryEntrySchema, parseCatalog } from './catalog-schema';
|
|
6
|
+
export type { DiscoveryEntry, OperationContract } from './catalog-schema';
|
|
7
|
+
export { discoveryInputSchema, parseDiscoveryRequest } from './query-schema';
|
|
8
|
+
export type { DiscoveryRequest } from './query-schema';
|
|
9
|
+
export { REMOVED_AUTHORING_HELPERS } from '../geometry-catalog';
|
package/src/discovery/service.ts
CHANGED
|
@@ -63,6 +63,8 @@ export function createDiscoveryService(
|
|
|
63
63
|
retirements: Readonly<Record<string, string>> = {},
|
|
64
64
|
/** Host notes for the overview, such as the project an omitted `projectId` selects. */
|
|
65
65
|
notes: () => Promise<string[]> = async () => [],
|
|
66
|
+
/** Host-selected capability entrypoint, never a model-provided instruction. */
|
|
67
|
+
capabilitiesCall = 'capabilities:true',
|
|
66
68
|
): (input: unknown) => Promise<DiscoveryResponse> {
|
|
67
69
|
const entries = parseCatalog(source);
|
|
68
70
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
@@ -94,7 +96,7 @@ export function createDiscoveryService(
|
|
|
94
96
|
'Parts auto-add to opts.parent; do not wrap createPart in parent.add. Part rotation uses degrees.',
|
|
95
97
|
'Custom THREE.BufferGeometry and ordinary JavaScript functions remain available. Recipes offer guidance and never restrict modeling.',
|
|
96
98
|
'Send source once and reuse programRef for reads, edits, inspection and rendering. Review geometry, materials and destination constraints separately.',
|
|
97
|
-
|
|
99
|
+
`Search for the operation you need in ordinary language, then fetch exact contracts with ids. Use ${capabilitiesCall} for the current host.`,
|
|
98
100
|
],
|
|
99
101
|
};
|
|
100
102
|
const error = (
|
package/src/engine-identity.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* `src/__tests__/package-contents.test.ts` fails if it disagrees with the manifest
|
|
28
28
|
* or with any plugin manifest, which is what keeps the literal honest.
|
|
29
29
|
*/
|
|
30
|
-
export const ENGINE_VERSION = '1.
|
|
30
|
+
export const ENGINE_VERSION = '1.2.0';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Where this engine was loaded from, as a `file://` URL of the package root.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readlinkSync } from 'node:fs';
|
|
3
3
|
import { posix } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { isolatedWorkerUrls } from './worker-urls';
|
|
@@ -17,6 +17,7 @@ const DEFAULT_BWRAP_PATH = '/usr/local/bin/bwrap';
|
|
|
17
17
|
const DEFAULT_SETPRIV_PATH = '/usr/bin/setpriv';
|
|
18
18
|
const DEFAULT_PRLIMIT_PATH = '/usr/bin/prlimit';
|
|
19
19
|
const DEFAULT_NODE_PATH = '/usr/local/bin/node';
|
|
20
|
+
const ENV_PATH = '/usr/bin/env';
|
|
20
21
|
const READINESS_VERSION = 'kiln.evaluator.isolation-readiness.v1';
|
|
21
22
|
const TRANSPORT_VERSION = 'kiln.evaluator.isolation-transport.v1';
|
|
22
23
|
const TRANSPORT_STDOUT_MARKER = 'kiln-evaluator-transport-boot-v1\n';
|
|
@@ -159,7 +160,7 @@ function isolatedEvaluatorLaunchWithLoader(
|
|
|
159
160
|
const setprivPath = requiredAbsolutePath(host.setprivPath ?? DEFAULT_SETPRIV_PATH);
|
|
160
161
|
const prlimitPath = requiredAbsolutePath(host.prlimitPath ?? DEFAULT_PRLIMIT_PATH);
|
|
161
162
|
const nodePath = requiredAbsolutePath(host.nodePath ?? DEFAULT_NODE_PATH);
|
|
162
|
-
for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath]) {
|
|
163
|
+
for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath, ENV_PATH]) {
|
|
163
164
|
if (!pathExists(executable)) isolationUnavailable();
|
|
164
165
|
}
|
|
165
166
|
const resolvedWorkerPath = workerInsideRuntime(workerPath, runtimeRoot);
|
|
@@ -195,14 +196,16 @@ function isolatedEvaluatorLaunchWithLoader(
|
|
|
195
196
|
for (const mount of runtimeMounts(runtimeRoot, pathExists)) {
|
|
196
197
|
bwrapArgs.push('--ro-bind', mount, mount);
|
|
197
198
|
}
|
|
198
|
-
|
|
199
|
+
// Remove the legacy mutation accessor. Throw mode breaks the Khronos
|
|
200
|
+
// validator's feature detection before any GLB can be validated.
|
|
201
|
+
const nodeArgs = [nodePath, '--max-old-space-size=512', '--disable-proto=delete'];
|
|
199
202
|
if (loader === 'tsx') nodeArgs.push('--import', 'tsx');
|
|
200
203
|
nodeArgs.push(resolvedWorkerPath);
|
|
201
204
|
bwrapArgs.push(
|
|
202
205
|
'--chdir',
|
|
203
206
|
runtimeRoot,
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// Bubblewrap passes inherited fds to the command. The spawn caller supplies
|
|
208
|
+
// only stdio and the fd3 protocol pipe; no descriptor-preservation flag exists.
|
|
206
209
|
'--',
|
|
207
210
|
prlimitPath,
|
|
208
211
|
'--cpu=65:65',
|
|
@@ -211,6 +214,12 @@ function isolatedEvaluatorLaunchWithLoader(
|
|
|
211
214
|
'--nofile=64:64',
|
|
212
215
|
'--nproc=64:64',
|
|
213
216
|
'--',
|
|
217
|
+
// Bubblewrap adds PWD after --clearenv. Clear the final worker environment
|
|
218
|
+
// again instead of weakening the exact two-variable readiness invariant.
|
|
219
|
+
ENV_PATH,
|
|
220
|
+
'-i',
|
|
221
|
+
'NODE_ENV=production',
|
|
222
|
+
'NO_COLOR=1',
|
|
214
223
|
...nodeArgs,
|
|
215
224
|
);
|
|
216
225
|
|
|
@@ -435,7 +444,11 @@ async function runIsolationProbe(
|
|
|
435
444
|
export async function assertIsolatedEvaluatorReady(
|
|
436
445
|
host: IsolatedEvaluatorHost = {},
|
|
437
446
|
): Promise<EvaluatorIsolationReadiness> {
|
|
438
|
-
if (
|
|
447
|
+
if (
|
|
448
|
+
typeof process.getuid !== 'function' ||
|
|
449
|
+
process.getuid() === 0 ||
|
|
450
|
+
process.geteuid?.() !== process.getuid()
|
|
451
|
+
) {
|
|
439
452
|
throw new EvaluatorIsolationReadinessError('invariant-namespace');
|
|
440
453
|
}
|
|
441
454
|
const workers = isolatedWorkerUrls(import.meta.url);
|
|
@@ -446,6 +459,14 @@ export async function assertIsolatedEvaluatorReady(
|
|
|
446
459
|
try {
|
|
447
460
|
transportLaunch = isolatedEvaluatorLaunchWithLoader(transportPath, host, 'module');
|
|
448
461
|
probeLaunch = isolatedEvaluatorLaunch(probePath, host);
|
|
462
|
+
// Trusted parent evidence is passed as bounded probe arguments, never
|
|
463
|
+
// through the source worker's environment or generated-source protocol.
|
|
464
|
+
probeLaunch.args.push(
|
|
465
|
+
'--kiln-host-uid',
|
|
466
|
+
String(process.getuid()),
|
|
467
|
+
'--kiln-host-userns',
|
|
468
|
+
readlinkSync('/proc/self/ns/user'),
|
|
469
|
+
);
|
|
449
470
|
} catch {
|
|
450
471
|
throw new EvaluatorIsolationReadinessError('wrapper-launch');
|
|
451
472
|
}
|
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
const CAPABILITY_STATUS_FIELDS = ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'] as const;
|
|
2
2
|
|
|
3
|
+
export interface ProbeHostIdentity {
|
|
4
|
+
uid: number;
|
|
5
|
+
userNamespace: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const namespacePattern = /^user:\[[0-9]{1,20}\](?![\s\S])/;
|
|
9
|
+
const nonRootUid = (uid: number) => Number.isSafeInteger(uid) && uid > 0 && uid < 0xffff_ffff;
|
|
10
|
+
|
|
11
|
+
export function parseProbeHostIdentity(argv: readonly string[]): ProbeHostIdentity | undefined {
|
|
12
|
+
if (
|
|
13
|
+
argv.length !== 4 ||
|
|
14
|
+
argv[0] !== '--kiln-host-uid' ||
|
|
15
|
+
argv[2] !== '--kiln-host-userns' ||
|
|
16
|
+
!argv[1] ||
|
|
17
|
+
!/^[1-9][0-9]{0,9}(?![\s\S])/.test(argv[1]) ||
|
|
18
|
+
!argv[3] ||
|
|
19
|
+
!namespacePattern.test(argv[3])
|
|
20
|
+
)
|
|
21
|
+
return undefined;
|
|
22
|
+
const uid = Number(argv[1]);
|
|
23
|
+
return nonRootUid(uid) ? { uid, userNamespace: argv[3] } : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function processUserNamespaceIsIsolated(
|
|
27
|
+
current: { uid: number; effectiveUid: number; userNamespace?: string; uidMap?: string },
|
|
28
|
+
host: ProbeHostIdentity | undefined,
|
|
29
|
+
): boolean {
|
|
30
|
+
if (
|
|
31
|
+
!host ||
|
|
32
|
+
!nonRootUid(host.uid) ||
|
|
33
|
+
!namespacePattern.test(host.userNamespace) ||
|
|
34
|
+
current.uid !== host.uid ||
|
|
35
|
+
current.effectiveUid !== host.uid ||
|
|
36
|
+
typeof current.userNamespace !== 'string' ||
|
|
37
|
+
!namespacePattern.test(current.userNamespace) ||
|
|
38
|
+
current.userNamespace === host.userNamespace ||
|
|
39
|
+
typeof current.uidMap !== 'string' ||
|
|
40
|
+
current.uidMap.length > 256
|
|
41
|
+
)
|
|
42
|
+
return false;
|
|
43
|
+
const map = /^\s*([0-9]{1,10})\s+([0-9]{1,10})\s+([0-9]{1,10})\s*$/.exec(current.uidMap);
|
|
44
|
+
if (!map) return false;
|
|
45
|
+
const [, inside, outside, count] = map.map(Number);
|
|
46
|
+
// --disable-userns creates a second user namespace. With --dev, that maps
|
|
47
|
+
// our non-root UID to UID 0 in Bubblewrap's intermediate namespace, not host
|
|
48
|
+
// root. Require a different namespace, the exact unprivileged caller identity
|
|
49
|
+
// and a single-ID map; never accept an initial-namespace or broad mapping.
|
|
50
|
+
return inside === host.uid && count === 1 && (outside === 0 || outside === host.uid);
|
|
51
|
+
}
|
|
52
|
+
|
|
3
53
|
/** Require every capability set exposed by /proc to be present and empty. */
|
|
4
54
|
export function processCapabilitiesAreEmpty(status: string | undefined): boolean {
|
|
5
55
|
if (typeof status !== 'string') return false;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { connect } from 'node:net';
|
|
3
3
|
import { networkInterfaces } from 'node:os';
|
|
4
4
|
import { validate } from '../validation';
|
|
5
5
|
import { writeFileSync } from 'node:fs';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
parseProbeHostIdentity,
|
|
8
|
+
processCapabilitiesAreEmpty,
|
|
9
|
+
processUserNamespaceIsIsolated,
|
|
10
|
+
} from './probe-invariants';
|
|
7
11
|
|
|
8
12
|
const VERSION = 'kiln.evaluator.isolation-readiness.v1';
|
|
9
13
|
type ProbeFailure =
|
|
@@ -105,12 +109,14 @@ function groupedFailure(failedChecks: readonly string[]): ProbeFailure {
|
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
function generatedDenials(): boolean {
|
|
112
|
+
if (Object.hasOwn(Object.prototype, '__proto__')) return false;
|
|
108
113
|
const prelude = "const meta = { name: 'probe' }; function build(){ return createRoot('probe'); }";
|
|
109
114
|
const probes = [
|
|
110
115
|
`const leak = process.env; ${prelude}`,
|
|
111
116
|
`const leak = fetch('http://169.254.169.254/'); ${prelude}`,
|
|
112
117
|
`const leak = globalThis.process.mainModule.require('fs'); ${prelude}`,
|
|
113
118
|
`const leak = globalThis.process.mainModule.require('child_process'); ${prelude}`,
|
|
119
|
+
`const leak = ({}).constructor.constructor('return process')(); ${prelude}`,
|
|
114
120
|
];
|
|
115
121
|
return probes.every((source) => !validate(source).valid);
|
|
116
122
|
}
|
|
@@ -119,11 +125,23 @@ async function main(): Promise<void> {
|
|
|
119
125
|
const status = await optionalRead('/proc/self/status');
|
|
120
126
|
const uidMap = await optionalRead('/proc/self/uid_map');
|
|
121
127
|
const envKeys = Object.keys(process.env).sort();
|
|
122
|
-
const
|
|
128
|
+
const userNamespace = await readlink('/proc/self/ns/user').catch(() => undefined);
|
|
129
|
+
const hostIdentity = parseProbeHostIdentity(process.argv.slice(2));
|
|
123
130
|
const interfaceMap = isolationInterfaces();
|
|
124
131
|
const interfaces = interfaceMap ? Object.values(interfaceMap).flat().filter(Boolean) : undefined;
|
|
125
132
|
const checks: Array<[string, boolean]> = [
|
|
126
|
-
[
|
|
133
|
+
[
|
|
134
|
+
'user-namespace',
|
|
135
|
+
processUserNamespaceIsIsolated(
|
|
136
|
+
{
|
|
137
|
+
uid: process.getuid?.() ?? -1,
|
|
138
|
+
effectiveUid: process.geteuid?.() ?? -1,
|
|
139
|
+
uidMap,
|
|
140
|
+
userNamespace,
|
|
141
|
+
},
|
|
142
|
+
hostIdentity,
|
|
143
|
+
),
|
|
144
|
+
],
|
|
127
145
|
['no-new-privileges', typeof status === 'string' && /^NoNewPrivs:\s+1$/m.test(status)],
|
|
128
146
|
['capabilities-empty', processCapabilitiesAreEmpty(status)],
|
|
129
147
|
['environment-exact', envKeys.join(',') === 'NODE_ENV,NO_COLOR'],
|
package/src/mcp-core.ts
CHANGED
|
@@ -65,6 +65,9 @@ export const MCP_SERVER_VERSION = ENGINE_VERSION;
|
|
|
65
65
|
export const MCP_SERVER_INSTRUCTIONS =
|
|
66
66
|
"Kiln turns JavaScript you write into GLB assets and returns rendered views. Work by reference: send a program once to kiln_validate or kiln_render, keep the returned programRef exactly, and use it for every later render, inspect, kiln_source and kiln_edit call; never resend a program to change part of it. Call kiln_discover first for helper contracts, with capabilities:true for the host's runtime, project and camera facts. Read viewFidelity before judging materials: a CPU view shows shape, not material; GPU views need the render service in render-service/. Agent Skills for authoring, refining, QA and scenes ship in skills/ beside this server's dist/; read the matching SKILL.md first.";
|
|
67
67
|
|
|
68
|
+
export const MCP_OPERATION_INSTRUCTIONS =
|
|
69
|
+
'Kiln turns authored JavaScript into editable GLB assets and rendered views. Send source once to kiln_validate or kiln_render, then preserve the exact programRef for later reads, edits and renders. Use kiln_discover for helper contracts and full input shapes; use kiln_capabilities for host configuration and limits. Read viewFidelity before judging materials: CPU geometry views are not material evidence. Search saved assets with kiln_assets_search; reopen exact source with kiln_assets_restore. Browse materials with kiln_material_search and create with kiln_material_create. Save only when requested. Pin collection, asset and revision IDs for reuse and export.';
|
|
70
|
+
|
|
68
71
|
/** One MCP content block. Mirrors the SDK's `CallToolResult['content']` element. */
|
|
69
72
|
export type KilnContentBlock =
|
|
70
73
|
| AssetLink
|
package/src/mcp-engine.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type KilnResourceContents,
|
|
27
27
|
type KilnToolResult,
|
|
28
28
|
withoutLocalPaths,
|
|
29
|
+
MCP_OPERATION_INSTRUCTIONS,
|
|
29
30
|
} from './mcp-core';
|
|
30
31
|
import { buildMcpManifest } from './mcp-manifest';
|
|
31
32
|
import { localProgramStore } from './program-store-node';
|
|
@@ -33,6 +34,7 @@ import { validateRequirementsBinding } from './requirements-context';
|
|
|
33
34
|
import { describeInputError, schemaFields } from './tools/actions';
|
|
34
35
|
import {
|
|
35
36
|
createKilnProgramToolRegistry,
|
|
37
|
+
createKilnOperationToolRegistry,
|
|
36
38
|
type KilnToolContext,
|
|
37
39
|
type KilnToolDef,
|
|
38
40
|
} from './tools/registry';
|
|
@@ -42,6 +44,8 @@ export type { KilnToolResult } from './mcp-core';
|
|
|
42
44
|
|
|
43
45
|
/** Optional wire features whose support is not negotiated by core MCP. */
|
|
44
46
|
export type KilnMcpCompatibilityOptions = {
|
|
47
|
+
/** Opt-in domain operation presentation generated from the shared registry. */
|
|
48
|
+
toolPresentation?: 'grouped' | 'operations';
|
|
45
49
|
/**
|
|
46
50
|
* Add core `resource_link` blocks for saved artifact files.
|
|
47
51
|
*
|
|
@@ -160,7 +164,10 @@ export function createKilnToolHost(
|
|
|
160
164
|
};
|
|
161
165
|
},
|
|
162
166
|
};
|
|
163
|
-
const defs =
|
|
167
|
+
const defs =
|
|
168
|
+
options.toolPresentation === 'operations'
|
|
169
|
+
? createKilnOperationToolRegistry(requestContext)
|
|
170
|
+
: kilnMcpToolDefs(requestContext);
|
|
164
171
|
const byName = new Map(defs.map((def) => [def.name, def] as const));
|
|
165
172
|
return {
|
|
166
173
|
defs,
|
|
@@ -259,6 +266,9 @@ export function createKilnMcpServer(
|
|
|
259
266
|
): Server {
|
|
260
267
|
const host = createKilnToolHost(context, options);
|
|
261
268
|
return createKilnServer({
|
|
269
|
+
...(options.toolPresentation === 'operations'
|
|
270
|
+
? { instructions: MCP_OPERATION_INSTRUCTIONS }
|
|
271
|
+
: {}),
|
|
262
272
|
manifest: buildMcpManifest(host.defs, context),
|
|
263
273
|
host: async () => host,
|
|
264
274
|
});
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Node embedding API. The stdio executable retains its separate lazy entry. */
|
|
2
|
+
export { createKilnMcpServer, createKilnToolHost, kilnMcpToolDefs } from './mcp-engine';
|
|
3
|
+
export type { KilnMcpCompatibilityOptions, KilnToolResult } from './mcp-engine';
|
|
4
|
+
export type { KilnMcpHost, KilnContentBlock, KilnResourceContents } from './mcp-core';
|
|
5
|
+
export type { KilnToolContext, KilnToolDef } from './tools/registry';
|
|
@@ -264,7 +264,7 @@ export async function startLocalRenderService(
|
|
|
264
264
|
// A failed spawn has no such race; absent/unknown never count as success.
|
|
265
265
|
if (spawnError || (exitedAt !== undefined && Date.now() - exitedAt >= 1500))
|
|
266
266
|
throw new Error(
|
|
267
|
-
`render service exited during startup${spawnError ? `: ${spawnError.message}` : ''}${stderr.trim() ? `: ${stderr.trim()
|
|
267
|
+
`render service exited during startup${spawnError ? `: ${spawnError.message}` : ''}${stderr.trim() ? `: ${stderr.trim()}` : windowsLaunch ? '; launch the renderer manually to inspect native-driver errors' : ''}`,
|
|
268
268
|
);
|
|
269
269
|
await new Promise((done) => setTimeout(done, 250));
|
|
270
270
|
}
|
package/src/tools/discovery.ts
CHANGED
|
@@ -11,10 +11,14 @@ import { resolveGltfExporter } from '../community-exporter';
|
|
|
11
11
|
import { approvedTextureCatalogV1 } from '../material-resources';
|
|
12
12
|
|
|
13
13
|
/** Shared native tool definition; all transports use the same Discovery service. */
|
|
14
|
-
export function createKilnDiscoveryDef(
|
|
14
|
+
export function createKilnDiscoveryDef(
|
|
15
|
+
context: KilnToolContext,
|
|
16
|
+
presentation: 'grouped' | 'operations' = 'grouped',
|
|
17
|
+
): KilnToolDef {
|
|
15
18
|
const run = createDiscovery(
|
|
16
|
-
() => currentCapabilities(context),
|
|
19
|
+
() => currentCapabilities(context, presentation),
|
|
17
20
|
async () => projectNotes(context),
|
|
21
|
+
presentation === 'operations' ? 'kiln_capabilities({})' : 'capabilities:true',
|
|
18
22
|
);
|
|
19
23
|
return {
|
|
20
24
|
name: 'kiln_discover',
|
|
@@ -48,7 +52,10 @@ function projectNotes(context: KilnToolContext): string[] {
|
|
|
48
52
|
: [];
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
async function currentCapabilities(
|
|
55
|
+
async function currentCapabilities(
|
|
56
|
+
context: KilnToolContext,
|
|
57
|
+
presentation: 'grouped' | 'operations',
|
|
58
|
+
) {
|
|
52
59
|
const externalEvaluator =
|
|
53
60
|
Boolean(context.evaluatorPort) || context.evaluatorProfile === 'evaluator-required';
|
|
54
61
|
const approvedTextures = context.approvedTextureResources
|
|
@@ -102,12 +109,15 @@ async function currentCapabilities(context: KilnToolContext) {
|
|
|
102
109
|
// you -- so the workspace guide's "do not substitute it silently" had
|
|
103
110
|
// nothing to check against. Compare with `runtime` in .kiln/workspace.json.
|
|
104
111
|
engine: engineIdentity(),
|
|
105
|
-
project:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
project:
|
|
113
|
+
presentation === 'operations' && !context.projectStore
|
|
114
|
+
? { available: false }
|
|
115
|
+
: {
|
|
116
|
+
configured: configuredProject(context),
|
|
117
|
+
select:
|
|
118
|
+
'projectId per call; omitted uses the configured project, null selects standalone work',
|
|
119
|
+
read: 'kiln_project { action: "get", projectId } returns the brief, design, inventory and material pins',
|
|
120
|
+
},
|
|
111
121
|
renderer,
|
|
112
122
|
execution:
|
|
113
123
|
context.localExecution ??
|
|
@@ -128,7 +138,7 @@ async function currentCapabilities(context: KilnToolContext) {
|
|
|
128
138
|
available: Boolean(context.assetLibrary),
|
|
129
139
|
collections: context.assetLibrary?.collections() ?? [],
|
|
130
140
|
save: 'kiln_save persists exact GLB, source and provenance; draft renders do not populate collections',
|
|
131
|
-
resume: 'kiln_assets action=restore imports a saved revision into the current program store
|
|
141
|
+
resume: `${presentation === 'operations' ? 'kiln_assets_restore' : 'kiln_assets action=restore'} imports a saved revision into the current program store`,
|
|
132
142
|
downloads: 'kiln_export returns GLB/source/ZIP resource links; client presentation varies',
|
|
133
143
|
viewer:
|
|
134
144
|
'kiln_present opens a saved revision in supporting chat clients with 3D viewing and downloads; kiln view opens a local collection or standalone GLB/ZIP',
|
package/src/tools/materials.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createMaterialRecordV1, decodeMaterialLibraryPayload } from '../materia
|
|
|
14
14
|
import { createMaterialPresetDraft, listMaterialPresets } from '../material-presets';
|
|
15
15
|
import { nestedRecordDescription, requireActionFields } from './actions';
|
|
16
16
|
import type { KilnToolDef } from './registry';
|
|
17
|
+
import type { KilnActionContract } from './operation-contract';
|
|
17
18
|
|
|
18
19
|
const MATERIAL_ACTIONS = [
|
|
19
20
|
'presets',
|
|
@@ -24,13 +25,43 @@ const MATERIAL_ACTIONS = [
|
|
|
24
25
|
'import',
|
|
25
26
|
] as const;
|
|
26
27
|
type MaterialAction = (typeof MATERIAL_ACTIONS)[number];
|
|
27
|
-
const
|
|
28
|
-
presets: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const MATERIAL_CONTRACT: Record<MaterialAction, KilnActionContract> = {
|
|
29
|
+
presets: {
|
|
30
|
+
fields: ['tag'],
|
|
31
|
+
required: [],
|
|
32
|
+
description: 'List shipped material recipes, optionally filtered by tag.',
|
|
33
|
+
},
|
|
34
|
+
'create-preset': {
|
|
35
|
+
fields: ['presetId', 'seed', 'creator', 'license', 'size', 'materialId'],
|
|
36
|
+
required: ['presetId', 'seed', 'creator', 'license'],
|
|
37
|
+
description:
|
|
38
|
+
'Create an immutable material from a shipped preset with explicit seed, creator and license.',
|
|
39
|
+
},
|
|
40
|
+
list: {
|
|
41
|
+
fields: ['tag'],
|
|
42
|
+
required: [],
|
|
43
|
+
description: 'List saved immutable material revisions, optionally filtered by tag.',
|
|
44
|
+
},
|
|
45
|
+
get: {
|
|
46
|
+
fields: ['materialId', 'revisionId'],
|
|
47
|
+
required: ['materialId'],
|
|
48
|
+
description:
|
|
49
|
+
'Read a material revision, provenance and portable specification. Omit revisionId only when exactly one revision exists.',
|
|
50
|
+
},
|
|
51
|
+
'create-procedural': {
|
|
52
|
+
fields: ['draft'],
|
|
53
|
+
required: ['draft'],
|
|
54
|
+
shapes: { draft: 'shape:material-draft' },
|
|
55
|
+
description:
|
|
56
|
+
'Create an immutable material from a bounded procedural draft. Does not fetch URLs or execute source.',
|
|
57
|
+
},
|
|
58
|
+
import: {
|
|
59
|
+
fields: ['payload'],
|
|
60
|
+
required: ['payload'],
|
|
61
|
+
shapes: { payload: 'shape:material-import' },
|
|
62
|
+
description:
|
|
63
|
+
'Import complete normalized material records and embedded PNGs. Does not fetch URLs or execute source.',
|
|
64
|
+
},
|
|
34
65
|
};
|
|
35
66
|
const record = (description: string) =>
|
|
36
67
|
z.record(z.string(), z.unknown()).optional().describe(description);
|
|
@@ -74,9 +105,9 @@ const materialResult = (material: MaterialManifestV1) => ({
|
|
|
74
105
|
material,
|
|
75
106
|
portableSpec: materialLibraryPortableSpec(material),
|
|
76
107
|
});
|
|
77
|
-
const unknownMaterial = (materialId: string, revisionId
|
|
108
|
+
const unknownMaterial = (materialId: string, revisionId: string | undefined, listCall: string) =>
|
|
78
109
|
new Error(
|
|
79
|
-
`Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}.
|
|
110
|
+
`Unknown material ${materialId}${revisionId ? ` at ${revisionId}` : ''}. ${listCall} lists the materials in this workspace with their revision IDs.`,
|
|
80
111
|
);
|
|
81
112
|
/**
|
|
82
113
|
* The revision a `get` without `revisionId` means: the material's only one.
|
|
@@ -84,13 +115,17 @@ const unknownMaterial = (materialId: string, revisionId?: string) =>
|
|
|
84
115
|
* caller to choose (two live sessions of 1 October 2026 called get with the
|
|
85
116
|
* id alone and were refused).
|
|
86
117
|
*/
|
|
87
|
-
async function onlyRevision(
|
|
118
|
+
async function onlyRevision(
|
|
119
|
+
library: MaterialLibrary,
|
|
120
|
+
materialId: string,
|
|
121
|
+
listCall: string,
|
|
122
|
+
): Promise<string> {
|
|
88
123
|
const revisions = (await library.list())
|
|
89
124
|
.filter((material) => material.materialId === materialId)
|
|
90
125
|
.map((material) => material.revisionId)
|
|
91
126
|
.sort();
|
|
92
127
|
if (revisions.length === 1) return revisions[0]!;
|
|
93
|
-
if (revisions.length === 0) throw unknownMaterial(materialId);
|
|
128
|
+
if (revisions.length === 0) throw unknownMaterial(materialId, undefined, listCall);
|
|
94
129
|
throw new Error(
|
|
95
130
|
`Material ${materialId} has ${revisions.length} revisions: ${revisions.join(', ')}. Pass revisionId to get one.`,
|
|
96
131
|
);
|
|
@@ -111,12 +146,20 @@ function aliasMaterialId(raw: unknown): unknown {
|
|
|
111
146
|
);
|
|
112
147
|
return { ...rest, materialId: resourceId };
|
|
113
148
|
}
|
|
114
|
-
export function createKilnMaterialDef(
|
|
149
|
+
export function createKilnMaterialDef(
|
|
150
|
+
library: MaterialLibrary,
|
|
151
|
+
presentation: 'grouped' | 'operations' = 'grouped',
|
|
152
|
+
): KilnToolDef {
|
|
153
|
+
const listCall =
|
|
154
|
+
presentation === 'operations'
|
|
155
|
+
? 'kiln_material_search({ scope: "saved" })'
|
|
156
|
+
: "kiln_material { action: 'list' }";
|
|
115
157
|
return {
|
|
116
158
|
name: 'kiln_material',
|
|
117
159
|
description:
|
|
118
160
|
'Manage optional immutable material resources in this workspace. presets discovers shipped architecture, wood, metal, fabric and ground recipes; create-preset bakes one with an explicit seed, creator and license; list returns compact material/revision summaries; get returns full provenance, hashes, map conventions, physical repeat scale and a code-ready portable material spec; create-procedural bakes bounded editable layer recipes including optional height-derived normals; import accepts complete normalized records with embedded PNG bytes. No action downloads URLs or executes source. Pin the returned materialId/revisionId through per-invocation materialDependencies or project dependencies before authored evaluation resolves the resources; no project is required.',
|
|
119
161
|
inputSchema: materialToolInput,
|
|
162
|
+
actionContract: MATERIAL_CONTRACT,
|
|
120
163
|
annotations: {
|
|
121
164
|
readOnlyHint: false,
|
|
122
165
|
destructiveHint: false,
|
|
@@ -126,7 +169,7 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
|
|
|
126
169
|
async run(raw) {
|
|
127
170
|
assertMaterialJson(raw);
|
|
128
171
|
const input = materialToolInput.parse(aliasMaterialId(raw));
|
|
129
|
-
requireActionFields('kiln_material', input.action, input,
|
|
172
|
+
requireActionFields('kiln_material', input.action, input, MATERIAL_CONTRACT[input.action]);
|
|
130
173
|
switch (input.action) {
|
|
131
174
|
case 'presets':
|
|
132
175
|
return {
|
|
@@ -170,13 +213,14 @@ export function createKilnMaterialDef(library: MaterialLibrary): KilnToolDef {
|
|
|
170
213
|
};
|
|
171
214
|
}
|
|
172
215
|
case 'get': {
|
|
173
|
-
const revisionId =
|
|
216
|
+
const revisionId =
|
|
217
|
+
input.revisionId ?? (await onlyRevision(library, input.materialId!, listCall));
|
|
174
218
|
try {
|
|
175
219
|
return materialResult((await library.read(input.materialId!, revisionId)).manifest);
|
|
176
220
|
} catch (error) {
|
|
177
221
|
// Rule 9: an unknown id names the call that lists the known ones.
|
|
178
222
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
179
|
-
throw unknownMaterial(input.materialId!, revisionId);
|
|
223
|
+
throw unknownMaterial(input.materialId!, revisionId, listCall);
|
|
180
224
|
throw error;
|
|
181
225
|
}
|
|
182
226
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ActionRequirements } from './actions';
|
|
3
|
+
|
|
4
|
+
/** Domain action metadata shared by grouped execution and named presentations. */
|
|
5
|
+
export interface KilnActionContract extends ActionRequirements {
|
|
6
|
+
readonly fields: readonly string[];
|
|
7
|
+
readonly description: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function projectActionSchemas<A extends string>(
|
|
11
|
+
name: string,
|
|
12
|
+
input: z.ZodObject,
|
|
13
|
+
actions: Readonly<Record<A, KilnActionContract>>,
|
|
14
|
+
): Record<A, z.ZodObject> {
|
|
15
|
+
if (input.def.checks?.length)
|
|
16
|
+
throw new Error(`${name}: cross-field constraints require an explicit operation schema`);
|
|
17
|
+
const json = z.toJSONSchema(input, { io: 'input' });
|
|
18
|
+
const selector = json.properties?.action as { enum?: unknown } | undefined;
|
|
19
|
+
const expected = Object.keys(actions).sort();
|
|
20
|
+
if (
|
|
21
|
+
!Array.isArray(selector?.enum) ||
|
|
22
|
+
JSON.stringify([...selector.enum].sort()) !== JSON.stringify(expected)
|
|
23
|
+
)
|
|
24
|
+
throw new Error(`${name}: unclassified actions`);
|
|
25
|
+
const classified = new Set([
|
|
26
|
+
'action',
|
|
27
|
+
...Object.values<KilnActionContract>(actions).flatMap((contract) => [...contract.fields]),
|
|
28
|
+
]);
|
|
29
|
+
const unclassified = Object.keys(input.shape).filter((field) => !classified.has(field));
|
|
30
|
+
if (unclassified.length)
|
|
31
|
+
throw new Error(`${name}: Unclassified fields: ${unclassified.join(', ')}`);
|
|
32
|
+
const schemas: [string, z.ZodObject][] = [];
|
|
33
|
+
for (const [action, contract] of Object.entries<KilnActionContract>(actions)) {
|
|
34
|
+
for (const field of contract.required)
|
|
35
|
+
if (!contract.fields.includes(field))
|
|
36
|
+
throw new Error(`${name}.${action}: unmapped required ${field}`);
|
|
37
|
+
for (const field of json.required ?? [])
|
|
38
|
+
if (field !== 'action' && !contract.fields.includes(field))
|
|
39
|
+
throw new Error(`${name}.${action}: would omit required ${field}`);
|
|
40
|
+
const shape: Record<string, z.ZodType> = {};
|
|
41
|
+
for (const field of contract.fields) {
|
|
42
|
+
if (field === 'action' || !Object.hasOwn(input.shape, field))
|
|
43
|
+
throw new Error(`${name}.${action}: missing or reserved field ${field}`);
|
|
44
|
+
classified.add(field);
|
|
45
|
+
const source = input.shape[field]!;
|
|
46
|
+
shape[field] = contract.required.includes(field) ? source.nonoptional() : source;
|
|
47
|
+
}
|
|
48
|
+
schemas.push([action, z.strictObject(shape)]);
|
|
49
|
+
}
|
|
50
|
+
return Object.fromEntries(schemas) as Record<A, z.ZodObject>;
|
|
51
|
+
}
|