@instruktlabs/kiln 1.0.0 → 1.1.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +48 -3
  5. package/CONTRIBUTING.md +16 -7
  6. package/README.md +95 -381
  7. package/dist/agent-run.mjs +1 -1
  8. package/dist/build.json +40 -28
  9. package/dist/cli.mjs +18 -14
  10. package/dist/mcp-engine.mjs +15 -11
  11. package/dist/mcp-server.mjs +1 -1
  12. package/docs/architecture.md +6 -3
  13. package/docs/chatgpt.md +20 -5
  14. package/docs/examples.md +40 -170
  15. package/docs/install.md +100 -35
  16. package/docs/migration.md +20 -0
  17. package/docs/sdk.md +26 -5
  18. package/lib/asset-widget-uri.d.ts +9 -0
  19. package/lib/asset-widget-uri.js +9 -0
  20. package/lib/cli-render-mode.js +10 -3
  21. package/lib/discovery/portable.d.ts +9 -0
  22. package/lib/discovery/portable.js +6 -0
  23. package/lib/engine-identity.d.ts +1 -1
  24. package/lib/engine-identity.js +1 -1
  25. package/lib/evaluator/isolation.js +19 -5
  26. package/lib/evaluator/probe-invariants.d.ts +11 -0
  27. package/lib/evaluator/probe-invariants.js +36 -0
  28. package/lib/evaluator/probe-worker.js +16 -4
  29. package/lib/loopback.d.ts +6 -0
  30. package/lib/loopback.js +6 -0
  31. package/lib/mcp-core.d.ts +209 -0
  32. package/lib/mcp-core.js +278 -0
  33. package/lib/mcp-engine.d.ts +57 -0
  34. package/lib/mcp-engine.js +284 -0
  35. package/lib/mcp-manifest.d.ts +15 -0
  36. package/lib/mcp-manifest.js +65 -0
  37. package/lib/mcp.d.ts +5 -0
  38. package/lib/mcp.js +2 -0
  39. package/lib/render-service-host.js +1 -1
  40. package/package.json +22 -1
  41. package/plugin.json +1 -1
  42. package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
  43. package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
  44. package/plugins/kiln-engine/PRIVACY.md +100 -0
  45. package/plugins/kiln-engine/README.md +103 -18
  46. package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
  47. package/plugins/kiln-engine/package-provenance.json +15 -8
  48. package/plugins/kiln-engine/plugin.json +10 -4
  49. package/plugins/kiln-engine/runtime.json +10 -1
  50. package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
  51. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  52. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  53. package/scripts/create-workspace.mjs +71 -13
  54. package/scripts/workspace-config.mjs +239 -0
  55. package/scripts/workspace-project.mjs +384 -0
  56. package/scripts/workspace-transaction.mjs +504 -0
  57. package/skills/kiln-setup-workspace/SKILL.md +17 -18
  58. package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  59. package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  60. package/src/cli-render-mode.ts +9 -9
  61. package/src/cli.ts +3 -3
  62. package/src/discovery/portable.ts +9 -0
  63. package/src/engine-identity.ts +1 -1
  64. package/src/evaluator/isolation.ts +27 -6
  65. package/src/evaluator/probe-invariants.ts +50 -0
  66. package/src/evaluator/probe-worker.ts +22 -4
  67. package/src/mcp.ts +5 -0
  68. package/src/render-service-host.ts +1 -1
@@ -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.
@@ -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
- if (
75
- (
76
- await readRenderServiceHealth(url, {
77
- token,
78
- signal: execution?.signal,
79
- })
80
- ).kind !== 'absent'
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 examples/crate.kiln.js --out crate.glb --views sheet.png
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 examples/crate.kiln.js --render cpu --views sheet.png
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 examples/crate.kiln.js --views sheet.png --backdrop light
89
+ kiln render my-asset.kiln.js --views sheet.png --backdrop light
90
90
  `;
91
91
 
92
92
  interface Args extends WorkspaceSelection {
@@ -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';
@@ -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.0.0';
30
+ export const ENGINE_VERSION = '1.1.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
- const nodeArgs = [nodePath, '--max-old-space-size=512', '--disable-proto=throw'];
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
- '--preserve-fds',
205
- '1',
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 (typeof process.getuid !== 'function' || process.getuid() === 0) {
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 { processCapabilitiesAreEmpty } from './probe-invariants';
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 outsideUid = Number(uidMap?.trim().split(/\s+/)[1]);
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
- ['user-namespace', Number.isInteger(outsideUid) && outsideUid > 0],
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.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().split('\n').slice(-3).join(' ')}` : windowsLaunch ? '; launch the renderer manually to inspect native-driver errors' : ''}`,
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
  }