@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,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP App resource the asset viewer is served under.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the tool registry, which stamps it into `kiln_present`'s `_meta`,
|
|
5
|
+
* and by the protocol core, which answers `resources/read` for it before the
|
|
6
|
+
* engine has loaded. Neither of those may import the other, so the literal
|
|
7
|
+
* lives here on its own.
|
|
8
|
+
*/
|
|
9
|
+
export declare const KILN_ASSET_WIDGET_URI = "ui://kiln/asset-v5.html";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP App resource the asset viewer is served under.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the tool registry, which stamps it into `kiln_present`'s `_meta`,
|
|
5
|
+
* and by the protocol core, which answers `resources/read` for it before the
|
|
6
|
+
* engine has loaded. Neither of those may import the other, so the literal
|
|
7
|
+
* lives here on its own.
|
|
8
|
+
*/
|
|
9
|
+
export const KILN_ASSET_WIDGET_URI = 'ui://kiln/asset-v5.html';
|
package/lib/cli-render-mode.js
CHANGED
|
@@ -39,12 +39,19 @@ export function makeLazyRenderPort(start, token, sourceFingerprint, initialUrl)
|
|
|
39
39
|
catch (error) {
|
|
40
40
|
if (execution?.signal?.aborted || error.name === 'TimeoutError')
|
|
41
41
|
throw error;
|
|
42
|
-
|
|
42
|
+
// A different capture may already have replaced this failed connection.
|
|
43
|
+
// Its healthy socket must not make us rethrow a late error from the old one.
|
|
44
|
+
if (resolving !== pending)
|
|
45
|
+
return (await resolve()).port(req, execution);
|
|
46
|
+
const health = await readRenderServiceHealth(url, {
|
|
43
47
|
token,
|
|
44
48
|
signal: execution?.signal,
|
|
45
|
-
})
|
|
46
|
-
throw error;
|
|
49
|
+
});
|
|
47
50
|
execution?.signal?.throwIfAborted();
|
|
51
|
+
if (resolving !== pending)
|
|
52
|
+
return (await resolve()).port(req, execution);
|
|
53
|
+
if (health.kind !== 'absent')
|
|
54
|
+
throw error;
|
|
48
55
|
// Concurrent captures can all observe the old socket disappearing. Only
|
|
49
56
|
// the first retires its connection; the others share the replacement.
|
|
50
57
|
if (resolving === pending)
|
package/lib/discovery/index.d.ts
CHANGED
|
@@ -6,4 +6,4 @@ export type { DiscoveryRequest } from "./query-schema.js";
|
|
|
6
6
|
export { createDiscoveryService } from "./service.js";
|
|
7
7
|
export type { DiscoveryIndex, DiscoveryResponse } from "./service.js";
|
|
8
8
|
/** Share one immutable catalog/index per process; host capabilities stay live. */
|
|
9
|
-
export declare function createDiscovery(capabilities?: () => Promise<unknown>, notes?: () => Promise<string[]
|
|
9
|
+
export declare function createDiscovery(capabilities?: () => Promise<unknown>, notes?: () => Promise<string[]>, capabilitiesCall?: string): (input: unknown) => Promise<import("./service.js").DiscoveryResponse>;
|
package/lib/discovery/index.js
CHANGED
|
@@ -11,10 +11,10 @@ let snapshot;
|
|
|
11
11
|
export function createDiscovery(capabilities = async () => ({
|
|
12
12
|
host: 'No host capability provider was supplied.',
|
|
13
13
|
discovery: { mode: 'lexical', offline: true, requiresModel: false },
|
|
14
|
-
}), notes = async () => []) {
|
|
14
|
+
}), notes = async () => [], capabilitiesCall = 'capabilities:true') {
|
|
15
15
|
snapshot ??= (() => {
|
|
16
16
|
const entries = listDiscoveryEntries();
|
|
17
17
|
return { entries, index: createLexicalDiscoveryIndex(entries) };
|
|
18
18
|
})();
|
|
19
|
-
return createDiscoveryService(snapshot.entries, snapshot.index, capabilities, REMOVED_AUTHORING_HELPERS, notes);
|
|
19
|
+
return createDiscoveryService(snapshot.entries, snapshot.index, capabilities, REMOVED_AUTHORING_HELPERS, notes, capabilitiesCall);
|
|
20
20
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Search a host-supplied catalog without importing Node's catalog construction. */
|
|
2
|
+
export { createDiscoveryService } from "./service.js";
|
|
3
|
+
export type { DiscoveryIndex, DiscoveryResponse, DiscoverySearchEvidence } from "./service.js";
|
|
4
|
+
export { createLexicalDiscoveryIndex, DISCOVERY_INDEX_VERSION } from "./lexical-index.js";
|
|
5
|
+
export { discoveryEntrySchema, parseCatalog } from "./catalog-schema.js";
|
|
6
|
+
export type { DiscoveryEntry, OperationContract } from "./catalog-schema.js";
|
|
7
|
+
export { discoveryInputSchema, parseDiscoveryRequest } from "./query-schema.js";
|
|
8
|
+
export type { DiscoveryRequest } from "./query-schema.js";
|
|
9
|
+
export { REMOVED_AUTHORING_HELPERS } from "../geometry-catalog.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Search a host-supplied catalog without importing Node's catalog construction. */
|
|
2
|
+
export { createDiscoveryService } from "./service.js";
|
|
3
|
+
export { createLexicalDiscoveryIndex, DISCOVERY_INDEX_VERSION } from "./lexical-index.js";
|
|
4
|
+
export { discoveryEntrySchema, parseCatalog } from "./catalog-schema.js";
|
|
5
|
+
export { discoveryInputSchema, parseDiscoveryRequest } from "./query-schema.js";
|
|
6
|
+
export { REMOVED_AUTHORING_HELPERS } from "../geometry-catalog.js";
|
|
@@ -53,5 +53,7 @@ export interface DiscoveryResponse {
|
|
|
53
53
|
}
|
|
54
54
|
export declare function createDiscoveryService(source: unknown, index: DiscoveryIndex, capabilities: () => Promise<unknown>, retirements?: Readonly<Record<string, string>>,
|
|
55
55
|
/** Host notes for the overview, such as the project an omitted `projectId` selects. */
|
|
56
|
-
notes?: () => Promise<string[]
|
|
56
|
+
notes?: () => Promise<string[]>,
|
|
57
|
+
/** Host-selected capability entrypoint, never a model-provided instruction. */
|
|
58
|
+
capabilitiesCall?: string): (input: unknown) => Promise<DiscoveryResponse>;
|
|
57
59
|
export {};
|
package/lib/discovery/service.js
CHANGED
|
@@ -7,7 +7,9 @@ const DETAIL_RESULT_BUDGET = 15_000;
|
|
|
7
7
|
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
8
8
|
export function createDiscoveryService(source, index, capabilities, retirements = {},
|
|
9
9
|
/** Host notes for the overview, such as the project an omitted `projectId` selects. */
|
|
10
|
-
notes = async () => []
|
|
10
|
+
notes = async () => [],
|
|
11
|
+
/** Host-selected capability entrypoint, never a model-provided instruction. */
|
|
12
|
+
capabilitiesCall = 'capabilities:true') {
|
|
11
13
|
const entries = parseCatalog(source);
|
|
12
14
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
13
15
|
const byName = new Map(entries.filter((entry) => entry.kind !== 'recipe').map((entry) => [entry.name, entry]));
|
|
@@ -34,7 +36,7 @@ notes = async () => []) {
|
|
|
34
36
|
'Parts auto-add to opts.parent; do not wrap createPart in parent.add. Part rotation uses degrees.',
|
|
35
37
|
'Custom THREE.BufferGeometry and ordinary JavaScript functions remain available. Recipes offer guidance and never restrict modeling.',
|
|
36
38
|
'Send source once and reuse programRef for reads, edits, inspection and rendering. Review geometry, materials and destination constraints separately.',
|
|
37
|
-
|
|
39
|
+
`Search for the operation you need in ordinary language, then fetch exact contracts with ids. Use ${capabilitiesCall} for the current host.`,
|
|
38
40
|
],
|
|
39
41
|
};
|
|
40
42
|
const error = (mode, code, message) => ({
|
package/lib/engine-identity.d.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `src/__tests__/package-contents.test.ts` fails if it disagrees with the manifest
|
|
27
27
|
* or with any plugin manifest, which is what keeps the literal honest.
|
|
28
28
|
*/
|
|
29
|
-
export declare const ENGINE_VERSION = "1.
|
|
29
|
+
export declare const ENGINE_VERSION = "1.2.0";
|
|
30
30
|
/**
|
|
31
31
|
* Where this engine was loaded from, as a `file://` URL of the package root.
|
|
32
32
|
*
|
package/lib/engine-identity.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `src/__tests__/package-contents.test.ts` fails if it disagrees with the manifest
|
|
27
27
|
* or with any plugin manifest, which is what keeps the literal honest.
|
|
28
28
|
*/
|
|
29
|
-
export const ENGINE_VERSION = '1.
|
|
29
|
+
export const ENGINE_VERSION = '1.2.0';
|
|
30
30
|
/**
|
|
31
31
|
* Where this engine was loaded from, as a `file://` URL of the package root.
|
|
32
32
|
*
|
|
@@ -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.js";
|
|
@@ -9,6 +9,7 @@ const DEFAULT_BWRAP_PATH = '/usr/local/bin/bwrap';
|
|
|
9
9
|
const DEFAULT_SETPRIV_PATH = '/usr/bin/setpriv';
|
|
10
10
|
const DEFAULT_PRLIMIT_PATH = '/usr/bin/prlimit';
|
|
11
11
|
const DEFAULT_NODE_PATH = '/usr/local/bin/node';
|
|
12
|
+
const ENV_PATH = '/usr/bin/env';
|
|
12
13
|
const READINESS_VERSION = 'kiln.evaluator.isolation-readiness.v1';
|
|
13
14
|
const TRANSPORT_VERSION = 'kiln.evaluator.isolation-transport.v1';
|
|
14
15
|
const TRANSPORT_STDOUT_MARKER = 'kiln-evaluator-transport-boot-v1\n';
|
|
@@ -99,7 +100,7 @@ function isolatedEvaluatorLaunchWithLoader(workerPath, host = {}, loader = 'tsx'
|
|
|
99
100
|
const setprivPath = requiredAbsolutePath(host.setprivPath ?? DEFAULT_SETPRIV_PATH);
|
|
100
101
|
const prlimitPath = requiredAbsolutePath(host.prlimitPath ?? DEFAULT_PRLIMIT_PATH);
|
|
101
102
|
const nodePath = requiredAbsolutePath(host.nodePath ?? DEFAULT_NODE_PATH);
|
|
102
|
-
for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath]) {
|
|
103
|
+
for (const executable of [bwrapPath, setprivPath, prlimitPath, nodePath, ENV_PATH]) {
|
|
103
104
|
if (!pathExists(executable))
|
|
104
105
|
isolationUnavailable();
|
|
105
106
|
}
|
|
@@ -136,11 +137,19 @@ function isolatedEvaluatorLaunchWithLoader(workerPath, host = {}, loader = 'tsx'
|
|
|
136
137
|
for (const mount of runtimeMounts(runtimeRoot, pathExists)) {
|
|
137
138
|
bwrapArgs.push('--ro-bind', mount, mount);
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
+
// Remove the legacy mutation accessor. Throw mode breaks the Khronos
|
|
141
|
+
// validator's feature detection before any GLB can be validated.
|
|
142
|
+
const nodeArgs = [nodePath, '--max-old-space-size=512', '--disable-proto=delete'];
|
|
140
143
|
if (loader === 'tsx')
|
|
141
144
|
nodeArgs.push('--import', 'tsx');
|
|
142
145
|
nodeArgs.push(resolvedWorkerPath);
|
|
143
|
-
bwrapArgs.push('--chdir', runtimeRoot,
|
|
146
|
+
bwrapArgs.push('--chdir', runtimeRoot,
|
|
147
|
+
// Bubblewrap passes inherited fds to the command. The spawn caller supplies
|
|
148
|
+
// only stdio and the fd3 protocol pipe; no descriptor-preservation flag exists.
|
|
149
|
+
'--', prlimitPath, '--cpu=65:65', '--as=6442450944:6442450944', '--fsize=100663296:100663296', '--nofile=64:64', '--nproc=64:64', '--',
|
|
150
|
+
// Bubblewrap adds PWD after --clearenv. Clear the final worker environment
|
|
151
|
+
// again instead of weakening the exact two-variable readiness invariant.
|
|
152
|
+
ENV_PATH, '-i', 'NODE_ENV=production', 'NO_COLOR=1', ...nodeArgs);
|
|
144
153
|
return {
|
|
145
154
|
command: setprivPath,
|
|
146
155
|
args: [
|
|
@@ -342,7 +351,9 @@ async function runIsolationProbe(launch) {
|
|
|
342
351
|
}
|
|
343
352
|
/** Fail-closed boot/readiness proof. Package presence alone is not accepted. */
|
|
344
353
|
export async function assertIsolatedEvaluatorReady(host = {}) {
|
|
345
|
-
if (typeof process.getuid !== 'function' ||
|
|
354
|
+
if (typeof process.getuid !== 'function' ||
|
|
355
|
+
process.getuid() === 0 ||
|
|
356
|
+
process.geteuid?.() !== process.getuid()) {
|
|
346
357
|
throw new EvaluatorIsolationReadinessError('invariant-namespace');
|
|
347
358
|
}
|
|
348
359
|
const workers = isolatedWorkerUrls(import.meta.url);
|
|
@@ -353,6 +364,9 @@ export async function assertIsolatedEvaluatorReady(host = {}) {
|
|
|
353
364
|
try {
|
|
354
365
|
transportLaunch = isolatedEvaluatorLaunchWithLoader(transportPath, host, 'module');
|
|
355
366
|
probeLaunch = isolatedEvaluatorLaunch(probePath, host);
|
|
367
|
+
// Trusted parent evidence is passed as bounded probe arguments, never
|
|
368
|
+
// through the source worker's environment or generated-source protocol.
|
|
369
|
+
probeLaunch.args.push('--kiln-host-uid', String(process.getuid()), '--kiln-host-userns', readlinkSync('/proc/self/ns/user'));
|
|
356
370
|
}
|
|
357
371
|
catch {
|
|
358
372
|
throw new EvaluatorIsolationReadinessError('wrapper-launch');
|
|
@@ -1,2 +1,13 @@
|
|
|
1
|
+
export interface ProbeHostIdentity {
|
|
2
|
+
uid: number;
|
|
3
|
+
userNamespace: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseProbeHostIdentity(argv: readonly string[]): ProbeHostIdentity | undefined;
|
|
6
|
+
export declare function processUserNamespaceIsIsolated(current: {
|
|
7
|
+
uid: number;
|
|
8
|
+
effectiveUid: number;
|
|
9
|
+
userNamespace?: string;
|
|
10
|
+
uidMap?: string;
|
|
11
|
+
}, host: ProbeHostIdentity | undefined): boolean;
|
|
1
12
|
/** Require every capability set exposed by /proc to be present and empty. */
|
|
2
13
|
export declare function processCapabilitiesAreEmpty(status: string | undefined): boolean;
|
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
const CAPABILITY_STATUS_FIELDS = ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'];
|
|
2
|
+
const namespacePattern = /^user:\[[0-9]{1,20}\](?![\s\S])/;
|
|
3
|
+
const nonRootUid = (uid) => Number.isSafeInteger(uid) && uid > 0 && uid < 0xffff_ffff;
|
|
4
|
+
export function parseProbeHostIdentity(argv) {
|
|
5
|
+
if (argv.length !== 4 ||
|
|
6
|
+
argv[0] !== '--kiln-host-uid' ||
|
|
7
|
+
argv[2] !== '--kiln-host-userns' ||
|
|
8
|
+
!argv[1] ||
|
|
9
|
+
!/^[1-9][0-9]{0,9}(?![\s\S])/.test(argv[1]) ||
|
|
10
|
+
!argv[3] ||
|
|
11
|
+
!namespacePattern.test(argv[3]))
|
|
12
|
+
return undefined;
|
|
13
|
+
const uid = Number(argv[1]);
|
|
14
|
+
return nonRootUid(uid) ? { uid, userNamespace: argv[3] } : undefined;
|
|
15
|
+
}
|
|
16
|
+
export function processUserNamespaceIsIsolated(current, host) {
|
|
17
|
+
if (!host ||
|
|
18
|
+
!nonRootUid(host.uid) ||
|
|
19
|
+
!namespacePattern.test(host.userNamespace) ||
|
|
20
|
+
current.uid !== host.uid ||
|
|
21
|
+
current.effectiveUid !== host.uid ||
|
|
22
|
+
typeof current.userNamespace !== 'string' ||
|
|
23
|
+
!namespacePattern.test(current.userNamespace) ||
|
|
24
|
+
current.userNamespace === host.userNamespace ||
|
|
25
|
+
typeof current.uidMap !== 'string' ||
|
|
26
|
+
current.uidMap.length > 256)
|
|
27
|
+
return false;
|
|
28
|
+
const map = /^\s*([0-9]{1,10})\s+([0-9]{1,10})\s+([0-9]{1,10})\s*$/.exec(current.uidMap);
|
|
29
|
+
if (!map)
|
|
30
|
+
return false;
|
|
31
|
+
const [, inside, outside, count] = map.map(Number);
|
|
32
|
+
// --disable-userns creates a second user namespace. With --dev, that maps
|
|
33
|
+
// our non-root UID to UID 0 in Bubblewrap's intermediate namespace, not host
|
|
34
|
+
// root. Require a different namespace, the exact unprivileged caller identity
|
|
35
|
+
// and a single-ID map; never accept an initial-namespace or broad mapping.
|
|
36
|
+
return inside === host.uid && count === 1 && (outside === 0 || outside === host.uid);
|
|
37
|
+
}
|
|
2
38
|
/** Require every capability set exposed by /proc to be present and empty. */
|
|
3
39
|
export function processCapabilitiesAreEmpty(status) {
|
|
4
40
|
if (typeof status !== 'string')
|
|
@@ -1,9 +1,9 @@
|
|
|
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.js";
|
|
5
5
|
import { writeFileSync } from 'node:fs';
|
|
6
|
-
import { processCapabilitiesAreEmpty } from "./probe-invariants.js";
|
|
6
|
+
import { parseProbeHostIdentity, processCapabilitiesAreEmpty, processUserNamespaceIsIsolated, } from "./probe-invariants.js";
|
|
7
7
|
const VERSION = 'kiln.evaluator.isolation-readiness.v1';
|
|
8
8
|
async function deniedRead(path) {
|
|
9
9
|
try {
|
|
@@ -85,12 +85,15 @@ function groupedFailure(failedChecks) {
|
|
|
85
85
|
return 'invariant-generated-policy';
|
|
86
86
|
}
|
|
87
87
|
function generatedDenials() {
|
|
88
|
+
if (Object.hasOwn(Object.prototype, '__proto__'))
|
|
89
|
+
return false;
|
|
88
90
|
const prelude = "const meta = { name: 'probe' }; function build(){ return createRoot('probe'); }";
|
|
89
91
|
const probes = [
|
|
90
92
|
`const leak = process.env; ${prelude}`,
|
|
91
93
|
`const leak = fetch('http://169.254.169.254/'); ${prelude}`,
|
|
92
94
|
`const leak = globalThis.process.mainModule.require('fs'); ${prelude}`,
|
|
93
95
|
`const leak = globalThis.process.mainModule.require('child_process'); ${prelude}`,
|
|
96
|
+
`const leak = ({}).constructor.constructor('return process')(); ${prelude}`,
|
|
94
97
|
];
|
|
95
98
|
return probes.every((source) => !validate(source).valid);
|
|
96
99
|
}
|
|
@@ -98,11 +101,20 @@ async function main() {
|
|
|
98
101
|
const status = await optionalRead('/proc/self/status');
|
|
99
102
|
const uidMap = await optionalRead('/proc/self/uid_map');
|
|
100
103
|
const envKeys = Object.keys(process.env).sort();
|
|
101
|
-
const
|
|
104
|
+
const userNamespace = await readlink('/proc/self/ns/user').catch(() => undefined);
|
|
105
|
+
const hostIdentity = parseProbeHostIdentity(process.argv.slice(2));
|
|
102
106
|
const interfaceMap = isolationInterfaces();
|
|
103
107
|
const interfaces = interfaceMap ? Object.values(interfaceMap).flat().filter(Boolean) : undefined;
|
|
104
108
|
const checks = [
|
|
105
|
-
[
|
|
109
|
+
[
|
|
110
|
+
'user-namespace',
|
|
111
|
+
processUserNamespaceIsIsolated({
|
|
112
|
+
uid: process.getuid?.() ?? -1,
|
|
113
|
+
effectiveUid: process.geteuid?.() ?? -1,
|
|
114
|
+
uidMap,
|
|
115
|
+
userNamespace,
|
|
116
|
+
}, hostIdentity),
|
|
117
|
+
],
|
|
106
118
|
['no-new-privileges', typeof status === 'string' && /^NoNewPrivs:\s+1$/m.test(status)],
|
|
107
119
|
['capabilities-empty', processCapabilitiesAreEmpty(status)],
|
|
108
120
|
['environment-exact', envKeys.join(',') === 'NODE_ENV,NO_COLOR'],
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostnames that name this machine's loopback interface, spelled as `URL.hostname`
|
|
3
|
+
* reports them. The local viewer answers these Host names, and loopback HTTP is the
|
|
4
|
+
* only plain-HTTP download location hosts accept.
|
|
5
|
+
*/
|
|
6
|
+
export declare const LOOPBACK_HOSTNAMES: readonly string[];
|
package/lib/loopback.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostnames that name this machine's loopback interface, spelled as `URL.hostname`
|
|
3
|
+
* reports them. The local viewer answers these Host names, and loopback HTTP is the
|
|
4
|
+
* only plain-HTTP download location hosts accept.
|
|
5
|
+
*/
|
|
6
|
+
export const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol half of the Kiln MCP server, shared by the thin stdio entry
|
|
3
|
+
* (`src/mcp-server.ts`) and the in-process server the tests build
|
|
4
|
+
* (`createKilnMcpServer` in `src/mcp-engine.ts`): identity, instructions,
|
|
5
|
+
* the request handlers over a tool manifest, cache hints and the stdin guard.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here imports the engine. That is the whole point: the entry bundles
|
|
8
|
+
* this module with a generated manifest and loads `mcp-engine` on the first
|
|
9
|
+
* call that needs it, so the handshake, `server/discover` and the tool list
|
|
10
|
+
* are answered in the time it takes Node to load the protocol library, not
|
|
11
|
+
* the time it takes to load three, zod, sharp and the registry. Codex gives
|
|
12
|
+
* an optional server one second before it goes on without its tools.
|
|
13
|
+
*
|
|
14
|
+
* The handlers are written on the low-level `Server` rather than `McpServer`
|
|
15
|
+
* because the advertised definitions must be exact bytes from the manifest
|
|
16
|
+
* (stable across processes, no library-side schema conversion at startup)
|
|
17
|
+
* and because the capabilities must say only what Kiln does: the list of
|
|
18
|
+
* tools and resources is fixed for a build, so no `listChanged` is
|
|
19
|
+
* advertised and none is acknowledged on `subscriptions/listen`.
|
|
20
|
+
*/
|
|
21
|
+
import { Server, type Transport } from '@modelcontextprotocol/server';
|
|
22
|
+
import { type ServeStdioOptions, type StdioServerHandle } from '@modelcontextprotocol/server/stdio';
|
|
23
|
+
import type { AssetLink } from "./assets-resources.js";
|
|
24
|
+
/** Server identity reported in the handshake and stamped into every 2026-07-28 result. */
|
|
25
|
+
export declare const MCP_SERVER_NAME = "kiln";
|
|
26
|
+
export declare const MCP_SERVER_VERSION = "1.2.0";
|
|
27
|
+
/**
|
|
28
|
+
* The spec's optional `instructions` field, returned by `initialize` and
|
|
29
|
+
* `server/discover`.
|
|
30
|
+
*
|
|
31
|
+
* This is the only orientation channel that survives every install shape. A
|
|
32
|
+
* user who configures the server by hand and opens an empty folder has no
|
|
33
|
+
* AGENTS.md, no CLAUDE.md and no registered skills -- but the skills do ship
|
|
34
|
+
* beside the server, so the useful thing to say is that they exist and where.
|
|
35
|
+
*
|
|
36
|
+
* At most 700 characters, with the essentials inside the first 512 (v1 contract
|
|
37
|
+
* rule 5): the text rides on every session start in every client, and the
|
|
38
|
+
* harnesses that cut it cut from the end. It names no absolute path, so the
|
|
39
|
+
* same bytes serve every installation.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MCP_SERVER_INSTRUCTIONS = "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.";
|
|
42
|
+
export declare const MCP_OPERATION_INSTRUCTIONS = "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.";
|
|
43
|
+
/** One MCP content block. Mirrors the SDK's `CallToolResult['content']` element. */
|
|
44
|
+
export type KilnContentBlock = AssetLink | {
|
|
45
|
+
type: 'text';
|
|
46
|
+
text: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'image';
|
|
49
|
+
data: string;
|
|
50
|
+
mimeType: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The structurally-typed subset of the SDK's `CallToolResult` Kiln produces.
|
|
54
|
+
*
|
|
55
|
+
* Declared locally rather than imported so `runTool` stays usable by callers
|
|
56
|
+
* that are not holding an SDK type (the parity test drives it directly).
|
|
57
|
+
*
|
|
58
|
+
* A type alias, not an interface, and that is load-bearing: the SDK's
|
|
59
|
+
* `CallToolResult` carries an index signature for protocol passthrough fields,
|
|
60
|
+
* and TypeScript grants an implicit index signature to type aliases but not to
|
|
61
|
+
* interfaces.
|
|
62
|
+
*/
|
|
63
|
+
export type KilnToolResult = {
|
|
64
|
+
content: KilnContentBlock[];
|
|
65
|
+
isError?: boolean;
|
|
66
|
+
structuredContent?: Record<string, unknown>;
|
|
67
|
+
_meta?: Record<string, unknown>;
|
|
68
|
+
};
|
|
69
|
+
/** One `resources/read` content entry: text for source and JSON, base64 for everything else. */
|
|
70
|
+
export type KilnResourceContents = {
|
|
71
|
+
uri: string;
|
|
72
|
+
mimeType: string;
|
|
73
|
+
_meta?: Record<string, unknown>;
|
|
74
|
+
} & ({
|
|
75
|
+
text: string;
|
|
76
|
+
} | {
|
|
77
|
+
blob: string;
|
|
78
|
+
});
|
|
79
|
+
/** A tool definition exactly as `tools/list` advertises it. */
|
|
80
|
+
export interface KilnMcpManifestTool {
|
|
81
|
+
name: string;
|
|
82
|
+
description: string;
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object';
|
|
85
|
+
[key: string]: unknown;
|
|
86
|
+
};
|
|
87
|
+
annotations?: {
|
|
88
|
+
readOnlyHint: boolean;
|
|
89
|
+
destructiveHint: boolean;
|
|
90
|
+
idempotentHint: boolean;
|
|
91
|
+
openWorldHint: boolean;
|
|
92
|
+
};
|
|
93
|
+
_meta?: Record<string, unknown>;
|
|
94
|
+
outputSchema?: {
|
|
95
|
+
[key: string]: unknown;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A fixed resource exactly as `resources/list` advertises it. */
|
|
99
|
+
export interface KilnMcpManifestResource {
|
|
100
|
+
uri: string;
|
|
101
|
+
name: string;
|
|
102
|
+
mimeType: string;
|
|
103
|
+
description: string;
|
|
104
|
+
_meta?: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
/** A resource template exactly as `resources/templates/list` advertises it. */
|
|
107
|
+
export interface KilnMcpManifestTemplate {
|
|
108
|
+
name: string;
|
|
109
|
+
uriTemplate: string;
|
|
110
|
+
description: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Everything a client can learn without calling a tool. Generated from the
|
|
114
|
+
* registry by `scripts/generate-mcp-manifest.ts` into
|
|
115
|
+
* `src/generated/mcp-manifest.json`, which the entry bundles; a test proves
|
|
116
|
+
* the committed file equals what the registry builds.
|
|
117
|
+
*/
|
|
118
|
+
export interface KilnMcpManifest {
|
|
119
|
+
kind: 'kiln.mcp-manifest.v1';
|
|
120
|
+
tools: KilnMcpManifestTool[];
|
|
121
|
+
resources: KilnMcpManifestResource[];
|
|
122
|
+
resourceTemplates: KilnMcpManifestTemplate[];
|
|
123
|
+
}
|
|
124
|
+
/** What the engine supplies once loaded: the calls that need it. */
|
|
125
|
+
export interface KilnMcpHost {
|
|
126
|
+
callTool(name: string, args: Record<string, unknown>, request: {
|
|
127
|
+
signal: AbortSignal;
|
|
128
|
+
}): Promise<KilnToolResult>;
|
|
129
|
+
readResource(uri: string): Promise<KilnResourceContents>;
|
|
130
|
+
}
|
|
131
|
+
/** Presentation hints for the asset viewer app, hidden from the language model. */
|
|
132
|
+
export declare const KILN_WIDGET_META: Record<string, unknown>;
|
|
133
|
+
export declare const KILN_WIDGET_RESOURCE: KilnMcpManifestResource;
|
|
134
|
+
export declare const KILN_RESOURCE_TEMPLATES: {
|
|
135
|
+
readonly assetFile: {
|
|
136
|
+
readonly name: 'asset-file';
|
|
137
|
+
readonly uriTemplate: 'kiln://assets/{collection}/{asset}/{revision}/{file}';
|
|
138
|
+
readonly description: 'Saved GLB/source/preview/manifest/bundle, or derived runtime GLB and metadata sidecar.';
|
|
139
|
+
};
|
|
140
|
+
readonly projectPackage: {
|
|
141
|
+
readonly name: 'project-package';
|
|
142
|
+
readonly uriTemplate: 'kiln://projects/{project}/{revision}/{profile}';
|
|
143
|
+
readonly description: 'Exact editable project bundle or runtime derivative ZIP.';
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Definitions, templates and the viewer page are fixed for a build, and a new
|
|
148
|
+
* build restarts the process, so a client may hold them for a day. Asset and
|
|
149
|
+
* project files are a user's own data: never shared, and re-read on demand.
|
|
150
|
+
*/
|
|
151
|
+
export declare const DEFINITION_TTL_MS = 86400000;
|
|
152
|
+
/** Only what Kiln does: no `listChanged`, because the lists never change within a build. */
|
|
153
|
+
export declare function kilnServerCapabilities(): {
|
|
154
|
+
tools: {};
|
|
155
|
+
resources: {};
|
|
156
|
+
extensions: {
|
|
157
|
+
"io.modelcontextprotocol/ui": {
|
|
158
|
+
mimeTypes: string[];
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
export interface KilnServerOptions {
|
|
163
|
+
manifest: KilnMcpManifest;
|
|
164
|
+
/**
|
|
165
|
+
* Supplies the engine on first use. The entry loads `mcp-engine` here; the
|
|
166
|
+
* in-process server resolves immediately. A rejection is reported as a tool
|
|
167
|
+
* result naming the next step, and the next call tries again.
|
|
168
|
+
*/
|
|
169
|
+
host: () => Promise<KilnMcpHost>;
|
|
170
|
+
/** Runs once, after the first `tools/list` has been answered on this instance. */
|
|
171
|
+
afterFirstToolList?: () => void;
|
|
172
|
+
/** The viewer page served for the widget resource; defaults to the packaged `dist/viewer/chat.html`. */
|
|
173
|
+
widgetHtml?: () => Promise<string>;
|
|
174
|
+
instructions?: string;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Rule 9 of the v1 contract: a diagnostic names the cause and the next call
|
|
178
|
+
* and carries no absolute local path. Raw filesystem errors do carry one, so
|
|
179
|
+
* the loading diagnostics pass through here before they reach a tool result.
|
|
180
|
+
*/
|
|
181
|
+
export declare function withoutLocalPaths(text: string): string;
|
|
182
|
+
export declare function errorMessage(error: unknown): string;
|
|
183
|
+
/** Build the server over a manifest; every call that needs the engine goes through `host()`. */
|
|
184
|
+
export declare function createKilnServer(options: KilnServerOptions): Server;
|
|
185
|
+
/** Options the MCP executable accepts on its command line. */
|
|
186
|
+
export interface KilnMcpArguments {
|
|
187
|
+
requirementsFile?: string;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Only explicit host startup configuration can supply a standalone session's
|
|
191
|
+
* policy. Parsed before the engine, because a wrong command line is the host's
|
|
192
|
+
* mistake and the only honest answer is an exit with the message.
|
|
193
|
+
*/
|
|
194
|
+
export declare function parseMcpArguments(argv: readonly string[]): KilnMcpArguments;
|
|
195
|
+
/**
|
|
196
|
+
* Refuses a request that belongs to neither era: no per-request protocol
|
|
197
|
+
* version (so not a 2026-07-28 request) and no `initialize` before it (so not a
|
|
198
|
+
* 2025 session either). Under 2026-07-28 a request missing a required `_meta`
|
|
199
|
+
* field is malformed and answered -32602; under 2025-11-25 nothing but a ping
|
|
200
|
+
* may precede the handshake. The library would otherwise pin the connection to
|
|
201
|
+
* a 2025 instance and serve it, which hides a broken client.
|
|
202
|
+
*/
|
|
203
|
+
export declare function guardedStdioTransport(inner?: Transport): Transport;
|
|
204
|
+
/**
|
|
205
|
+
* Serve over this process's stdio, both eras: a 2025 client opens with
|
|
206
|
+
* `initialize`, a 2026-07-28 client with `server/discover` or any request
|
|
207
|
+
* carrying the per-request envelope. The library owns the era decision; Kiln
|
|
208
|
+
* adds the pre-handshake guard above.
|
|
209
|
+
*/
|
|
210
|
+
export declare function serveKilnStdio(options: KilnServerOptions, stdio?: Pick<ServeStdioOptions, 'onerror'>): StdioServerHandle;
|