@bothat-io/molenkopf 0.1.2
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/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SECURITY.md +36 -0
- package/bin/launcher.js +76 -0
- package/bin/molenkopf.js +4 -0
- package/docs/DEPLOYMENT.md +104 -0
- package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
- package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
- package/docs/MOLENKOPF_USAGE.md +195 -0
- package/docs/PRODUCT_INTENT.md +36 -0
- package/docs/THREAT_MODEL.md +94 -0
- package/molenkopf.config.example.json +68 -0
- package/package.json +98 -0
- package/packages/core/src/auth/password.ts +47 -0
- package/packages/core/src/auth/session.ts +64 -0
- package/packages/core/src/ci/ci-mode.ts +71 -0
- package/packages/core/src/compression/content-classifier.ts +25 -0
- package/packages/core/src/compression/context-compressor.ts +48 -0
- package/packages/core/src/compression/json-compressor.ts +54 -0
- package/packages/core/src/compression/log-compressor.ts +32 -0
- package/packages/core/src/compression/operational-block-compressor.ts +43 -0
- package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
- package/packages/core/src/config/config-policies.ts +146 -0
- package/packages/core/src/config/molenkopf-config.ts +137 -0
- package/packages/core/src/config/provider-config.ts +139 -0
- package/packages/core/src/events/event-bus.ts +88 -0
- package/packages/core/src/identity/api-keys.ts +149 -0
- package/packages/core/src/identity/budget.ts +51 -0
- package/packages/core/src/identity/db.ts +68 -0
- package/packages/core/src/identity/identity-store.ts +175 -0
- package/packages/core/src/identity/identity-validation.ts +102 -0
- package/packages/core/src/identity/key-permissions.ts +18 -0
- package/packages/core/src/identity/pricing.ts +11 -0
- package/packages/core/src/identity/types.ts +87 -0
- package/packages/core/src/identity/usage-snapshot.ts +116 -0
- package/packages/core/src/manifest/audit-activity.ts +74 -0
- package/packages/core/src/manifest/audit-metrics.ts +7 -0
- package/packages/core/src/manifest/audit-safety.ts +113 -0
- package/packages/core/src/manifest/audit-store.ts +189 -0
- package/packages/core/src/manifest/audit-summary.ts +184 -0
- package/packages/core/src/manifest/usage-meter.ts +105 -0
- package/packages/core/src/memory/memory-extractor.ts +57 -0
- package/packages/core/src/memory/memory-graph.ts +55 -0
- package/packages/core/src/pipeline/json-string-spans.ts +143 -0
- package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
- package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
- package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
- package/packages/core/src/plugins/plugin-api.ts +96 -0
- package/packages/core/src/plugins/plugin-catalog.ts +42 -0
- package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
- package/packages/core/src/plugins/plugin-sdk.ts +47 -0
- package/packages/core/src/plugins/static-pipeline.ts +5 -0
- package/packages/core/src/profiles/profile-router.ts +45 -0
- package/packages/core/src/providers/provider-catalog.ts +186 -0
- package/packages/core/src/routing/distribution.ts +31 -0
- package/packages/core/src/security/secret-redactor.ts +139 -0
- package/packages/core/src/security/target-policy.ts +61 -0
- package/packages/core/src/storage/local-paths.ts +6 -0
- package/packages/core/src/storage/private-state.ts +30 -0
- package/packages/core/src/storage/purge-dir.ts +10 -0
- package/packages/core/src/store/retrieval-store.ts +114 -0
- package/packages/core/src/utils/hash.ts +9 -0
- package/packages/core/src/utils/text.ts +18 -0
- package/packages/core/src/utils/tokens.ts +3 -0
- package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
- package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
- package/packages/dashboard/dist/favicon.png +0 -0
- package/packages/dashboard/dist/index.html +15 -0
- package/packages/dashboard/dist/molenkopf-logo.png +0 -0
- package/packages/dashboard/public/favicon.png +0 -0
- package/packages/dashboard/public/molenkopf-logo.png +0 -0
- package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
- package/packages/plugins/context-compressor-plugin/page.html +191 -0
- package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
- package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
- package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
- package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
- package/packages/plugins/shared/audit-projects.ts +32 -0
- package/packages/proxy/src/cli/args.ts +34 -0
- package/packages/proxy/src/cli/config-loader.ts +43 -0
- package/packages/proxy/src/cli/env-file.ts +43 -0
- package/packages/proxy/src/cli/main.ts +132 -0
- package/packages/proxy/src/cli/profile-server.ts +176 -0
- package/packages/proxy/src/cli/target.ts +7 -0
- package/packages/proxy/src/http/agent-drafts.ts +103 -0
- package/packages/proxy/src/http/agent-router.ts +69 -0
- package/packages/proxy/src/http/audit-view.ts +15 -0
- package/packages/proxy/src/http/auth-state.ts +44 -0
- package/packages/proxy/src/http/budget-gate.ts +45 -0
- package/packages/proxy/src/http/budget-warnings.ts +7 -0
- package/packages/proxy/src/http/cli-stream-response.ts +51 -0
- package/packages/proxy/src/http/client-identity.ts +51 -0
- package/packages/proxy/src/http/communication-graph.ts +139 -0
- package/packages/proxy/src/http/control-plane-guard.ts +56 -0
- package/packages/proxy/src/http/dashboard-assets.ts +115 -0
- package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
- package/packages/proxy/src/http/header-utils.ts +65 -0
- package/packages/proxy/src/http/identity-id.ts +11 -0
- package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
- package/packages/proxy/src/http/local-api-auth.ts +120 -0
- package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
- package/packages/proxy/src/http/local-api-identity.ts +194 -0
- package/packages/proxy/src/http/local-api-io.ts +82 -0
- package/packages/proxy/src/http/local-api-keys.ts +126 -0
- package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
- package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
- package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
- package/packages/proxy/src/http/local-api-retention.ts +28 -0
- package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
- package/packages/proxy/src/http/local-api-scope.ts +47 -0
- package/packages/proxy/src/http/local-api-state.ts +180 -0
- package/packages/proxy/src/http/local-api.ts +166 -0
- package/packages/proxy/src/http/password-policy.ts +5 -0
- package/packages/proxy/src/http/plugin-data.ts +38 -0
- package/packages/proxy/src/http/plugin-host.ts +87 -0
- package/packages/proxy/src/http/plugin-modules.ts +1 -0
- package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
- package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
- package/packages/proxy/src/http/provider-access.ts +33 -0
- package/packages/proxy/src/http/provider-http-test.ts +133 -0
- package/packages/proxy/src/http/provider-input.ts +39 -0
- package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
- package/packages/proxy/src/http/provider-test.ts +149 -0
- package/packages/proxy/src/http/proxy-identity.ts +78 -0
- package/packages/proxy/src/http/public-bind.ts +8 -0
- package/packages/proxy/src/http/request-finish.ts +62 -0
- package/packages/proxy/src/http/request-path.ts +8 -0
- package/packages/proxy/src/http/request-policy.ts +46 -0
- package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
- package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
- package/packages/proxy/src/http/runtime-settings.ts +199 -0
- package/packages/proxy/src/http/runtime-state.ts +198 -0
- package/packages/proxy/src/http/server-io.ts +80 -0
- package/packages/proxy/src/http/server-types.ts +17 -0
- package/packages/proxy/src/http/server.ts +190 -0
- package/packages/proxy/src/http/session-secret.ts +19 -0
- package/packages/proxy/src/http/streaming-proxy.ts +88 -0
- package/packages/proxy/src/http/usage-accounting.ts +100 -0
- package/packages/proxy/src/http/usage-restore.ts +15 -0
- package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
- package/packages/proxy/src/runtime/cli-env.ts +22 -0
- package/packages/proxy/src/runtime/cli-executor.ts +134 -0
- package/packages/proxy/src/runtime/cli-provider.ts +162 -0
- package/packages/proxy/src/runtime/cli-request.ts +79 -0
- package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
- package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { AuditManifest } from "../../../core/src/manifest/audit-store.ts";
|
|
2
|
+
import type { EventBus, MolenkopfEvent } from "../../../core/src/events/event-bus.ts";
|
|
3
|
+
import { builtinPluginModules } from "../../../core/src/plugins/builtin-plugin-modules.ts";
|
|
4
|
+
import type { MolenkopfPluginModule, PluginDataContext, PluginJson, PluginLifecycleContext, PluginRuntimeContext } from "../../../core/src/plugins/plugin-api.ts";
|
|
5
|
+
import type { RetrievalStore } from "../../../core/src/store/retrieval-store.ts";
|
|
6
|
+
import { isPluginEnabled, type RuntimeState } from "./runtime-state.ts";
|
|
7
|
+
|
|
8
|
+
type Modules = Record<string, MolenkopfPluginModule>;
|
|
9
|
+
type HookName = "onBoot" | "onStart" | "onEnable" | "onDisable" | "onStop";
|
|
10
|
+
type DataResult = { ok: true; payload: PluginJson } | { ok: false; status: number; error: string };
|
|
11
|
+
|
|
12
|
+
export type PluginHost = {
|
|
13
|
+
boot: () => Promise<void>;
|
|
14
|
+
start: (port?: number) => Promise<void>;
|
|
15
|
+
enable: (id: string) => Promise<void>;
|
|
16
|
+
disable: (id: string) => Promise<void>;
|
|
17
|
+
stop: (reason?: string) => Promise<void>;
|
|
18
|
+
audit: (manifest: AuditManifest) => Promise<void>;
|
|
19
|
+
data: (id: string, ctx: PluginDataContext) => Promise<DataResult>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createPluginHost(state: RuntimeState, deps: { store: RetrievalStore; events: EventBus }, modules: Modules = builtinPluginModules): PluginHost {
|
|
23
|
+
const booted = new Set<string>(), started = new Set<string>();
|
|
24
|
+
let unsubscribe: (() => void) | undefined;
|
|
25
|
+
let eventQueue = Promise.resolve();
|
|
26
|
+
const runtime = (id: string): PluginRuntimeContext => ({ pluginId: id, dataDir: state.dataDir, storage: deps.store, now: () => new Date() });
|
|
27
|
+
const lifecycle = (id: string, hook: HookName, port?: number, reason?: string): PluginLifecycleContext => ({
|
|
28
|
+
pluginId: id, dataDir: state.dataDir, port, reason, now: () => new Date(),
|
|
29
|
+
note: (message) => deps.events.emit("plugin_event", { data: { pluginId: id, hook, message } })
|
|
30
|
+
});
|
|
31
|
+
async function run(id: string, hook: HookName, port?: number, reason?: string): Promise<boolean> {
|
|
32
|
+
const fn = modules[id]?.[hook];
|
|
33
|
+
if (!fn) { setLifecycle(id, hook); return true; }
|
|
34
|
+
try { await fn(lifecycle(id, hook, port, reason), runtime(id)); setLifecycle(id, hook); return true; } catch (error) { failLifecycle(id, hook, error); warn(deps.events, id, hook); return false; }
|
|
35
|
+
}
|
|
36
|
+
async function eachEnabled(task: (id: string) => Promise<void>): Promise<void> {
|
|
37
|
+
for (const id of Object.keys(modules)) if (isPluginEnabled(state, id)) await task(id);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
async boot() {
|
|
41
|
+
for (const id of Object.keys(modules)) if (await run(id, "onBoot")) booted.add(id);
|
|
42
|
+
},
|
|
43
|
+
async start(port) {
|
|
44
|
+
unsubscribe = deps.events.subscribe((event) => { if (event.type !== "plugin_event") eventQueue = eventQueue.then(() => notifyEvent(event)).catch(() => {}); });
|
|
45
|
+
await eachEnabled(async (id) => { if (await run(id, "onStart", port)) started.add(id); });
|
|
46
|
+
},
|
|
47
|
+
async enable(id) {
|
|
48
|
+
if (await run(id, "onEnable", state.port)) started.add(id);
|
|
49
|
+
},
|
|
50
|
+
async disable(id) {
|
|
51
|
+
await run(id, "onDisable", state.port);
|
|
52
|
+
started.delete(id);
|
|
53
|
+
},
|
|
54
|
+
async stop(reason = "server_close") {
|
|
55
|
+
unsubscribe?.();
|
|
56
|
+
for (const id of new Set([...booted, ...started])) await run(id, "onStop", state.port, reason);
|
|
57
|
+
},
|
|
58
|
+
async audit(manifest) {
|
|
59
|
+
await eachEnabled(async (id) => {
|
|
60
|
+
const fn = modules[id]?.onAudit;
|
|
61
|
+
if (fn) try { await fn({ requestId: manifest.requestId, providerId: manifest.providerId ?? "", statusCode: manifest.statusCode ?? 0, manifest: manifest as unknown as PluginJson, note: (message) => deps.events.emit("plugin_event", { data: { pluginId: id, hook: "onAudit", message } }) }, runtime(id)); } catch { warn(deps.events, id, "onAudit"); }
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
async data(id, ctx) {
|
|
65
|
+
const fn = modules[id]?.getData;
|
|
66
|
+
if (!fn) return { ok: false, status: 404, error: "plugin_data_not_found" };
|
|
67
|
+
try { return { ok: true, payload: await fn(ctx, runtime(id)) }; } catch { warn(deps.events, id, "getData"); return { ok: false, status: 500, error: "plugin_data_failed" }; }
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
async function notifyEvent(event: MolenkopfEvent): Promise<void> {
|
|
71
|
+
await eachEnabled(async (id) => {
|
|
72
|
+
const fn = modules[id]?.onEvent;
|
|
73
|
+
if (fn) try { await fn({ event: event.type, data: (event.data ?? {}) as PluginJson, emit: (name, data) => deps.events.emit("plugin_event", { data: { pluginId: id, event: name, ...data } }) }, runtime(id)); } catch { warn(deps.events, id, "onEvent"); }
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function setLifecycle(id: string, hook: HookName): void {
|
|
77
|
+
const status = hook === "onDisable" ? "disabled" : hook === "onStop" ? "stopped" : hook === "onBoot" ? "booted" : "enabled";
|
|
78
|
+
state.pluginLifecycle[id] = { status, hook };
|
|
79
|
+
}
|
|
80
|
+
function failLifecycle(id: string, hook: string, _error: unknown): void {
|
|
81
|
+
state.pluginLifecycle[id] = { status: "error", hook, error: "plugin_hook_failed" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function warn(events: EventBus, pluginId: string, hook: string): void {
|
|
86
|
+
events.emit("warning", { data: { warning: "plugin_hook_failed", pluginId, hook, error: "plugin_hook_failed" } });
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { builtinPluginModules } from "../../../core/src/plugins/builtin-plugin-modules.ts";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// Plugins own their pages: a plugin ships packages/plugins/<id>/page.html and the
|
|
6
|
+
// proxy serves it at /__molenkopf/plugins/<id>/page. The page fetches its own data
|
|
7
|
+
// endpoint (/__molenkopf/plugins/<id>/data) client-side, so a plugin is a real
|
|
8
|
+
// self-contained workspace, not a hardcoded branch in the host.
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pluginsDir = join(here, "..", "..", "..", "plugins");
|
|
12
|
+
const cache = new Map<string, string | null>();
|
|
13
|
+
|
|
14
|
+
export function loadPluginPage(id: string): string | undefined {
|
|
15
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) return undefined;
|
|
16
|
+
if (!cache.has(id)) {
|
|
17
|
+
try {
|
|
18
|
+
cache.set(id, readFileSync(join(pluginsDir, id, "page.html"), "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
cache.set(id, null);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return cache.get(id) ?? undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { redactSecrets } from "../../../core/src/security/secret-redactor.ts";
|
|
2
|
+
import { findPlugin } from "../../../core/src/plugins/plugin-catalog.ts";
|
|
3
|
+
import type { MolenkopfPluginModule, PluginRequestResult } from "../../../core/src/plugins/plugin-api.ts";
|
|
4
|
+
import type { PluginTrafficMutation } from "../../../core/src/plugins/plugin-descriptor.ts";
|
|
5
|
+
import type { RetrievalStore } from "../../../core/src/store/retrieval-store.ts";
|
|
6
|
+
import { builtinPluginModules } from "./plugin-modules.ts";
|
|
7
|
+
|
|
8
|
+
// Plugins are middleware, but not all middleware can mutate traffic. The plugin
|
|
9
|
+
// descriptor is the source of truth for reads, mutations, and toggle state.
|
|
10
|
+
|
|
11
|
+
export type ConsumerUsage = { requests: number; inputTokens: number; outputTokens: number };
|
|
12
|
+
|
|
13
|
+
export type PluginContext = {
|
|
14
|
+
readonly requestId: string;
|
|
15
|
+
readonly method: string;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly consumerId: string;
|
|
18
|
+
providerId: string;
|
|
19
|
+
body: string;
|
|
20
|
+
redactedSecrets: number;
|
|
21
|
+
compressedItems: number;
|
|
22
|
+
savedTokens: number;
|
|
23
|
+
retrievalIds: string[];
|
|
24
|
+
compressorsUsed: string[];
|
|
25
|
+
notes: string[];
|
|
26
|
+
block?: { status: number; error: string };
|
|
27
|
+
usageOf: (consumerId: string) => ConsumerUsage;
|
|
28
|
+
note: (message: string) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PluginMiddleware = { id: string; mutates?: PluginTrafficMutation[]; run: (ctx: PluginContext, deps: { store: RetrievalStore }) => Promise<void> | void };
|
|
32
|
+
|
|
33
|
+
export const builtinMiddlewares: PluginMiddleware[] = Object.entries(builtinPluginModules)
|
|
34
|
+
.filter(([id]) => findPlugin(id)?.hooks.includes("request:body:rewrite"))
|
|
35
|
+
.map(([id, module]) => middlewareFromModule(id, module));
|
|
36
|
+
|
|
37
|
+
// Runs the enabled middlewares in order. Stops early if one blocks the request.
|
|
38
|
+
export async function runRequestPipeline(ctx: PluginContext, enabled: (id: string) => boolean, deps: { store: RetrievalStore }, middlewares: PluginMiddleware[] = builtinMiddlewares): Promise<PluginContext> {
|
|
39
|
+
runCoreRedaction(ctx);
|
|
40
|
+
for (const middleware of middlewares) {
|
|
41
|
+
if (!enabled(middleware.id)) continue;
|
|
42
|
+
const before = snapshot(ctx);
|
|
43
|
+
try { await middleware.run(ctx, deps); } catch { restore(before, ctx); ctx.note(`plugin_hook_failed:${middleware.id}`); continue; }
|
|
44
|
+
enforceCapabilities(middleware, before, ctx);
|
|
45
|
+
if (ctx.block) break;
|
|
46
|
+
}
|
|
47
|
+
return ctx;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function middlewareFromModule(id: string, module: MolenkopfPluginModule): PluginMiddleware {
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
async run(ctx, deps) {
|
|
54
|
+
if (!module.onRequest) return;
|
|
55
|
+
const result = await module.onRequest({
|
|
56
|
+
requestId: ctx.requestId,
|
|
57
|
+
method: ctx.method,
|
|
58
|
+
path: ctx.path,
|
|
59
|
+
consumerId: ctx.consumerId,
|
|
60
|
+
providerId: ctx.providerId,
|
|
61
|
+
body: ctx.body,
|
|
62
|
+
usageOf: ctx.usageOf,
|
|
63
|
+
note: ctx.note
|
|
64
|
+
}, { pluginId: id, storage: deps.store, now: () => new Date() });
|
|
65
|
+
if (result) applyModuleResult(ctx, result);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyModuleResult(ctx: PluginContext, result: PluginRequestResult): void {
|
|
71
|
+
if (result.body !== undefined) ctx.body = result.body;
|
|
72
|
+
if (result.providerId !== undefined) ctx.providerId = result.providerId;
|
|
73
|
+
if (result.block !== undefined) ctx.block = result.block;
|
|
74
|
+
if (result.redactedSecrets !== undefined) ctx.redactedSecrets += result.redactedSecrets;
|
|
75
|
+
if (result.compressedItems !== undefined) ctx.compressedItems += result.compressedItems;
|
|
76
|
+
if (result.savedTokens !== undefined) ctx.savedTokens += result.savedTokens;
|
|
77
|
+
if (result.retrievalIds) ctx.retrievalIds.push(...result.retrievalIds);
|
|
78
|
+
if (result.compressorsUsed) ctx.compressorsUsed.push(...result.compressorsUsed);
|
|
79
|
+
if (result.notes) result.notes.forEach(ctx.note);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runCoreRedaction(ctx: PluginContext): void {
|
|
83
|
+
const redacted = redactSecrets(ctx.body);
|
|
84
|
+
ctx.body = redacted.text;
|
|
85
|
+
ctx.redactedSecrets += redacted.redactions.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type PipelineSnapshot = Omit<PluginContext, "usageOf" | "note">;
|
|
89
|
+
|
|
90
|
+
function snapshot(ctx: PluginContext): PipelineSnapshot {
|
|
91
|
+
return { ...ctx, retrievalIds: [...ctx.retrievalIds], compressorsUsed: [...ctx.compressorsUsed], notes: [...ctx.notes], block: ctx.block ? { ...ctx.block } : undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function enforceCapabilities(middleware: PluginMiddleware, before: PipelineSnapshot, ctx: PluginContext): void {
|
|
95
|
+
const allowed = middleware.mutates ?? findPlugin(middleware.id)?.traffic.mutates ?? ["none"];
|
|
96
|
+
if (ctx.body !== before.body && !canMutateBody(allowed)) return failCapability(middleware.id, "body", before, ctx);
|
|
97
|
+
if (ctx.providerId !== before.providerId && !allowed.includes("route")) return failCapability(middleware.id, "route", before, ctx);
|
|
98
|
+
if (blockChanged(before.block, ctx.block) && !allowed.includes("block")) return failCapability(middleware.id, "block", before, ctx);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function canMutateBody(allowed: PluginTrafficMutation[]): boolean {
|
|
102
|
+
return allowed.includes("mask") || allowed.includes("transform") || allowed.includes("augment-context");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function blockChanged(before: PluginContext["block"], after: PluginContext["block"]): boolean {
|
|
106
|
+
return (before?.status ?? 0) !== (after?.status ?? 0) || (before?.error ?? "") !== (after?.error ?? "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function failCapability(id: string, mutation: string, before: PipelineSnapshot, ctx: PluginContext): void {
|
|
110
|
+
restore(before, ctx);
|
|
111
|
+
ctx.block = { status: 500, error: "plugin_capability_violation" };
|
|
112
|
+
ctx.note(`plugin_capability_violation:${id}:${mutation}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function restore(before: PipelineSnapshot, ctx: PluginContext): void {
|
|
116
|
+
ctx.body = before.body;
|
|
117
|
+
ctx.providerId = before.providerId;
|
|
118
|
+
ctx.redactedSecrets = before.redactedSecrets;
|
|
119
|
+
ctx.compressedItems = before.compressedItems;
|
|
120
|
+
ctx.savedTokens = before.savedTokens;
|
|
121
|
+
ctx.retrievalIds = [...before.retrievalIds];
|
|
122
|
+
ctx.compressorsUsed = [...before.compressorsUsed];
|
|
123
|
+
ctx.notes = [...before.notes];
|
|
124
|
+
ctx.block = before.block ? { ...before.block } : undefined;
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IdentityStore } from "../../../core/src/identity/identity-store.ts";
|
|
2
|
+
import type { User } from "../../../core/src/identity/types.ts";
|
|
3
|
+
|
|
4
|
+
export type ProviderAllowlist = "*" | string[];
|
|
5
|
+
|
|
6
|
+
export function effectiveProviderAllowlist(identity: IdentityStore, owner: User | undefined, teamIds: string[], keyScopes: string[] | undefined): ProviderAllowlist {
|
|
7
|
+
const teamAllowlist = owner?.role === "admin" ? "*" : allowlistForTeams(identity, teamIds);
|
|
8
|
+
const keyAllowlist = cleanAllowlist(keyScopes);
|
|
9
|
+
if (!keyAllowlist) return teamAllowlist;
|
|
10
|
+
if (teamAllowlist === "*") return keyAllowlist;
|
|
11
|
+
return keyAllowlist.filter((id) => teamAllowlist.includes(id));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function providerAllowedForClient(client: { allowedProviderIds?: ProviderAllowlist }, providerId: string): boolean {
|
|
15
|
+
const allowed = client.allowedProviderIds;
|
|
16
|
+
if (allowed && allowed !== "*" && !allowed.includes(providerId)) return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function allowlistForTeams(identity: IdentityStore, teamIds: string[]): ProviderAllowlist {
|
|
21
|
+
const providers = new Set<string>();
|
|
22
|
+
for (const teamId of teamIds) {
|
|
23
|
+
const allowed = identity.getTeam(teamId)?.allowedProviders;
|
|
24
|
+
if (allowed === "*") return "*";
|
|
25
|
+
if (Array.isArray(allowed)) for (const providerId of allowed) providers.add(providerId);
|
|
26
|
+
}
|
|
27
|
+
return [...providers];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cleanAllowlist(value: string[] | undefined): string[] | undefined {
|
|
31
|
+
if (!value) return undefined;
|
|
32
|
+
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
|
33
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { request as httpRequest, type IncomingHttpHeaders, type OutgoingHttpHeaders } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
4
|
+
import { resolveConnectTarget, type ConnectTarget } from "../../../core/src/security/target-policy.ts";
|
|
5
|
+
import { buildForwardHeaders, missingProviderCredential } from "./header-utils.ts";
|
|
6
|
+
|
|
7
|
+
type Check = { status: "ok" | "failed" | "missing" | "unknown" | "blocked"; message: string };
|
|
8
|
+
type SmokeSpec = { method: "GET" | "POST"; path: string; protocol: string; body?: Record<string, unknown> };
|
|
9
|
+
|
|
10
|
+
export async function providerHttpTest(provider: ProviderConfig) {
|
|
11
|
+
const base = {
|
|
12
|
+
providerId: provider.id,
|
|
13
|
+
kind: provider.kind,
|
|
14
|
+
runtime: provider.runtime ?? provider.kind,
|
|
15
|
+
protocol: protocolOf(provider),
|
|
16
|
+
auth: authCheck(provider),
|
|
17
|
+
model: { status: "unknown", message: "Not tested yet" } as Check,
|
|
18
|
+
permission: { status: "unknown", message: "HTTP providers do not use host CLI permissions" } as Check,
|
|
19
|
+
http: { statusCode: 0, path: "", method: "" }
|
|
20
|
+
};
|
|
21
|
+
if (base.auth.status === "missing") return base;
|
|
22
|
+
const spec = smokeSpec(provider);
|
|
23
|
+
const headers = buildForwardHeaders(new Headers({ "content-type": "application/json" }), provider);
|
|
24
|
+
if (spec.protocol === "anthropic-messages") headers.set("anthropic-version", "2023-06-01");
|
|
25
|
+
const url = smokeUrl(provider.target, spec);
|
|
26
|
+
try {
|
|
27
|
+
const checked = await resolveConnectTarget(url, { path: "provider test target", allowPrivate: provider.kind === "local" });
|
|
28
|
+
const response = await pinnedRequest(checked, {
|
|
29
|
+
method: spec.method,
|
|
30
|
+
headers,
|
|
31
|
+
body: spec.body ? JSON.stringify(spec.body) : undefined
|
|
32
|
+
});
|
|
33
|
+
const redirect = await blockedRedirect(response, url, provider.kind === "local");
|
|
34
|
+
if (redirect) return { ...base, model: redirect, http: { statusCode: response.status, path: spec.path, method: spec.method } };
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
auth: authFromStatus(response.status, base.auth),
|
|
38
|
+
model: statusCheck(response.status, spec),
|
|
39
|
+
http: { statusCode: response.status, path: spec.path, method: spec.method }
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { ...base, model: { status: "failed", message: safeError(error) } as Check, http: { statusCode: 0, path: spec.path, method: spec.method } };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ProviderHttpResponse = { status: number; headers: Headers };
|
|
47
|
+
|
|
48
|
+
async function pinnedRequest(checked: ConnectTarget, init: { method: string; headers: Headers; body?: string }): Promise<ProviderHttpResponse> {
|
|
49
|
+
const transport = checked.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const request = transport(checked.url, { method: init.method, headers: outgoingHeaders(init.headers, checked.url.host), lookup: pinnedLookup(checked.address, checked.family) }, (response) => {
|
|
52
|
+
response.resume();
|
|
53
|
+
response.on("end", () => resolve({ status: response.statusCode ?? 0, headers: responseHeaders(response.headers) }));
|
|
54
|
+
response.on("error", reject);
|
|
55
|
+
});
|
|
56
|
+
request.setTimeout(15000, () => request.destroy(new Error("provider test timed out after 15000ms")));
|
|
57
|
+
request.on("error", reject);
|
|
58
|
+
request.end(init.body);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function blockedRedirect(response: ProviderHttpResponse, url: string, allowPrivate: boolean): Promise<Check | undefined> {
|
|
63
|
+
if (response.status < 300 || response.status > 399) return undefined;
|
|
64
|
+
const location = response.headers.get("location");
|
|
65
|
+
if (!location) return undefined;
|
|
66
|
+
try { await resolveConnectTarget(new URL(location, url).toString(), { path: "provider redirect", allowPrivate }); return undefined; }
|
|
67
|
+
catch { return { status: "blocked", message: "Provider redirect target is not allowed" }; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function authCheck(provider: ProviderConfig): Check {
|
|
71
|
+
if (missingProviderCredential(provider)) return { status: "missing", message: "Provider credential is missing" };
|
|
72
|
+
return { status: "ok", message: provider.authScheme === "none" ? "No credential required" : "Provider credential policy is configured" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function authFromStatus(status: number, current: Check): Check {
|
|
76
|
+
if (status === 401 || status === 403) return { status: "failed", message: `Upstream rejected credentials with HTTP ${status}` };
|
|
77
|
+
return current;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function statusCheck(status: number, spec: SmokeSpec): Check {
|
|
81
|
+
const message = `${spec.protocol} smoke ${spec.method} ${spec.path} returned HTTP ${status}`;
|
|
82
|
+
return status >= 200 && status < 300 ? { status: "ok", message } : { status: "failed", message };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function smokeSpec(provider: ProviderConfig): SmokeSpec {
|
|
86
|
+
const protocol = protocolOf(provider);
|
|
87
|
+
if (protocol === "ollama-tags") return { method: "GET", path: "/api/tags", protocol };
|
|
88
|
+
if (protocol === "anthropic-messages") return {
|
|
89
|
+
method: "POST", path: "/messages", protocol,
|
|
90
|
+
body: { model: "claude-3-5-haiku-latest", max_tokens: 1, messages: [{ role: "user", content: "Reply OK" }] }
|
|
91
|
+
};
|
|
92
|
+
if (protocol === "openai-chat") return { method: "GET", path: "/models", protocol };
|
|
93
|
+
return { method: "POST", path: "/responses", protocol, body: { model: "gpt-4.1-mini", input: "Reply OK", max_output_tokens: 1 } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function protocolOf(provider: ProviderConfig): NonNullable<ProviderConfig["protocol"]> {
|
|
97
|
+
if (provider.protocol) return provider.protocol;
|
|
98
|
+
if (provider.kind === "local" && provider.target.includes("11434")) return "ollama-tags";
|
|
99
|
+
if (provider.kind === "local") return "openai-chat";
|
|
100
|
+
return provider.authScheme === "x-api-key" || provider.target.includes("anthropic") ? "anthropic-messages" : "openai-responses";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function smokeUrl(target: string, spec: SmokeSpec): string {
|
|
104
|
+
if (spec.protocol === "ollama-tags") return new URL(spec.path, new URL(target).origin).toString();
|
|
105
|
+
const base = target.endsWith("/") ? target : `${target}/`;
|
|
106
|
+
return new URL(spec.path.replace(/^\//, ""), base).toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function safeError(error: unknown): string {
|
|
110
|
+
return error instanceof Error ? error.message.slice(0, 180) : String(error).slice(0, 180);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function outgoingHeaders(headers: Headers, host: string): OutgoingHttpHeaders {
|
|
114
|
+
const out: OutgoingHttpHeaders = {};
|
|
115
|
+
headers.forEach((value, key) => { out[key] = value; });
|
|
116
|
+
out.host = host;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function responseHeaders(headers: IncomingHttpHeaders): Headers {
|
|
121
|
+
const out = new Headers();
|
|
122
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
123
|
+
if (Array.isArray(value)) for (const item of value) out.append(key, item);
|
|
124
|
+
else if (value !== undefined) out.set(key, String(value));
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pinnedLookup(address: string, family: 4 | 6) {
|
|
130
|
+
return (_hostname: string, _options: unknown, callback: (error: NodeJS.ErrnoException | null, address: string, family: number) => void) => {
|
|
131
|
+
callback(null, address, family);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
2
|
+
import { validateProviderTarget } from "../../../core/src/security/target-policy.ts";
|
|
3
|
+
|
|
4
|
+
export function buildProviderFromInput(id: string, name: string, body: Record<string, unknown>): { provider: ProviderConfig } | { error: string } {
|
|
5
|
+
const kind = String(body.kind ?? "openai");
|
|
6
|
+
if (!knownKind(kind)) return { error: "invalid_kind" };
|
|
7
|
+
const credential = typeof body.credential === "string" && body.credential.trim() ? body.credential.trim() : undefined;
|
|
8
|
+
const credentialEnv = typeof body.credentialEnv === "string" && body.credentialEnv.trim() ? body.credentialEnv.trim() : undefined;
|
|
9
|
+
if (credentialEnv && !validEnv(credentialEnv)) return { error: "invalid_credential_env" };
|
|
10
|
+
if (kind === "cli-claude" || kind === "cli-codex") {
|
|
11
|
+
const runtime = kind === "cli-codex" ? "codex" : "claude";
|
|
12
|
+
return { provider: { id, name, kind: "cli", target: `cli://${id}`, runtime, cliCommand: runtime, cliArgs: runtime === "codex" ? ["exec"] : ["--print"], cliInputMode: "stdin", authScheme: "none", credentialRef: "none", enabled: true } };
|
|
13
|
+
}
|
|
14
|
+
const target = providerTarget(kind, body);
|
|
15
|
+
const providerKind = kind === "local" || kind === "ollama" ? "local" : "api";
|
|
16
|
+
try { validateProviderTarget(target, { path: "provider target", allowPrivate: providerKind === "local" }); } catch { return { error: "invalid_target" }; }
|
|
17
|
+
const authScheme = kind === "anthropic" ? "x-api-key" : providerKind === "local" ? "none" : credential || credentialEnv ? "bearer" : "none";
|
|
18
|
+
return { provider: { id, name, kind: providerKind, target, authScheme, protocol: providerProtocol(kind), credentialValue: credential, credentialEnv, credentialRef: credential ? "inline" : credentialEnv ? `env:${credentialEnv}` : "none", enabled: true } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validEnv(value: string): boolean {
|
|
22
|
+
return /^[A-Z_][A-Z0-9_]*$/i.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function providerTarget(kind: string, body: Record<string, unknown>): string {
|
|
26
|
+
const target = typeof body.target === "string" ? body.target.trim() : "";
|
|
27
|
+
return target || (kind === "ollama" ? "http://127.0.0.1:11434/v1" : "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function providerProtocol(kind: string): ProviderConfig["protocol"] {
|
|
31
|
+
if (kind === "anthropic") return "anthropic-messages";
|
|
32
|
+
if (kind === "ollama") return "ollama-tags";
|
|
33
|
+
if (kind === "local") return "openai-chat";
|
|
34
|
+
return "openai-responses";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function knownKind(kind: string): boolean {
|
|
38
|
+
return ["openai", "openai-compatible", "anthropic", "local", "ollama", "cli-claude", "cli-codex"].includes(kind);
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
2
|
+
import type { RoutingMode, RuntimeState } from "./runtime-state.ts";
|
|
3
|
+
|
|
4
|
+
type Snapshot = {
|
|
5
|
+
providers: ProviderConfig[];
|
|
6
|
+
providerWeights: Record<string, number>;
|
|
7
|
+
activeProviderId: string;
|
|
8
|
+
providerSelectedAt?: string;
|
|
9
|
+
routingMode: RoutingMode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function snapshotProviderRouting(state: RuntimeState): Snapshot {
|
|
13
|
+
return {
|
|
14
|
+
providers: state.providers.slice(),
|
|
15
|
+
providerWeights: { ...state.providerWeights },
|
|
16
|
+
activeProviderId: state.activeProviderId,
|
|
17
|
+
providerSelectedAt: state.providerSelectedAt,
|
|
18
|
+
routingMode: state.routingMode
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function restoreProviderRouting(state: RuntimeState, snapshot: Snapshot): void {
|
|
23
|
+
state.providers = snapshot.providers;
|
|
24
|
+
state.providerWeights = snapshot.providerWeights;
|
|
25
|
+
state.activeProviderId = snapshot.activeProviderId;
|
|
26
|
+
state.providerSelectedAt = snapshot.providerSelectedAt;
|
|
27
|
+
state.routingMode = snapshot.routingMode;
|
|
28
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
6
|
+
import { isCliProvider, runCliProvider } from "../runtime/cli-provider.ts";
|
|
7
|
+
import { cliErrorDiagnostics, safeCliMessage, successfulCliLifecycle } from "../runtime/cli-diagnostics.ts";
|
|
8
|
+
import { runtimeProfileFromImport, writeRuntimeProfileFiles } from "../runtime/runtime-profile.ts";
|
|
9
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
10
|
+
import { missingProviderCredential } from "./header-utils.ts";
|
|
11
|
+
import { readJson, writeJson } from "./local-api-io.ts";
|
|
12
|
+
import { providerHttpTest } from "./provider-http-test.ts";
|
|
13
|
+
import { runtimeAuthProvider, writeRuntimeAuthFiles } from "./runtime-auth-registry.ts";
|
|
14
|
+
import { issueRuntimeAuthProof } from "./runtime-auth-proof.ts";
|
|
15
|
+
|
|
16
|
+
type Check = { status: "ok" | "failed" | "missing" | "unknown" | "blocked"; message: string };
|
|
17
|
+
const RUNTIME_TEST_BYTES = 256 * 1024;
|
|
18
|
+
|
|
19
|
+
export async function testRuntimeProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
20
|
+
const result = await selectedProviderTest(req, state, "runtime");
|
|
21
|
+
writeJson(res, statusOfTestResult(result), result);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function testProvider(req: IncomingMessage, res: ServerResponse, state: RuntimeState) {
|
|
25
|
+
const result = await selectedProviderTest(req, state, "auto");
|
|
26
|
+
writeJson(res, statusOfTestResult(result), result);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function selectedProviderTest(req: IncomingMessage, state: RuntimeState, mode: "auto" | "runtime") {
|
|
30
|
+
const body = await readJson(req, mode === "runtime" ? RUNTIME_TEST_BYTES : undefined);
|
|
31
|
+
if (mode === "runtime" && hasDraftRuntimeAuth(body)) {
|
|
32
|
+
const result = await draftRuntimeProviderTest(body);
|
|
33
|
+
return statusOfTestResult(result) === 200 ? { ...result, importProof: issueRuntimeAuthProof(state, body) } : result;
|
|
34
|
+
}
|
|
35
|
+
const id = typeof body.id === "string" && body.id.trim() ? body.id.trim() : state.activeProviderId;
|
|
36
|
+
const provider = state.providers.find((item) => item.id === id);
|
|
37
|
+
if (!provider) return { error: "unknown_provider" };
|
|
38
|
+
if (provider.enabled === false) return { error: "provider_disabled" };
|
|
39
|
+
if (mode === "auto" && !isCliProvider(provider)) return providerHttpTest(provider);
|
|
40
|
+
return providerRuntimeTest(provider);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statusOfTestResult(result: any): number {
|
|
44
|
+
if (result.error === "invalid_runtime" || result.error === "missing_auth_json" || result.error === "invalid_auth_json" || result.error === "invalid_runtime_profile") return 400;
|
|
45
|
+
if (result.error === "unknown_provider") return 404;
|
|
46
|
+
if (result.error === "provider_disabled") return 409;
|
|
47
|
+
if (result.permission?.status === "blocked" || result.model?.status === "failed" || result.auth?.status === "failed") return 502;
|
|
48
|
+
return 200;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function draftRuntimeProviderTest(body: Record<string, unknown>) {
|
|
52
|
+
const runtime = runtimeValue(body.runtime);
|
|
53
|
+
if (!runtime) return { error: "invalid_runtime" };
|
|
54
|
+
const authJson = authJsonText(body);
|
|
55
|
+
if (!authJson) return { error: "missing_auth_json" };
|
|
56
|
+
if (!parseJsonObject(authJson)) return { error: "invalid_auth_json" };
|
|
57
|
+
let runtimeProfile;
|
|
58
|
+
try {
|
|
59
|
+
runtimeProfile = runtimeProfileFromImport(body, runtime);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { error: error instanceof Error ? error.message : "invalid_runtime_profile" };
|
|
62
|
+
}
|
|
63
|
+
const root = await mkdtemp(join(tmpdir(), "molenkopf-runtime-test-"));
|
|
64
|
+
try {
|
|
65
|
+
const authDir = join(root, "runtime-auth", "draft");
|
|
66
|
+
await writeRuntimeAuthFiles(authDir, runtime, authJson.endsWith("\n") ? authJson : `${authJson}\n`);
|
|
67
|
+
await writeRuntimeProfileFiles(authDir, runtimeProfile);
|
|
68
|
+
const provider = runtimeAuthProvider("draft", "Draft runtime provider", runtime, authDir, "runtime-auth:draft", runtimeProfile.profile);
|
|
69
|
+
return { ...(await providerRuntimeTest(provider)), draft: true };
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasDraftRuntimeAuth(body: Record<string, unknown>): boolean {
|
|
76
|
+
return typeof body.runtime === "string" && "authJson" in body;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runtimeValue(value: unknown): "claude" | "codex" | undefined {
|
|
80
|
+
return value === "claude" || value === "codex" ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function authJsonText(body: Record<string, unknown>): string {
|
|
84
|
+
if (typeof body.authJson === "string" && body.authJson.trim()) return body.authJson.trim();
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(text);
|
|
91
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function providerRuntimeTest(provider: ProviderConfig) {
|
|
98
|
+
const base = {
|
|
99
|
+
providerId: provider.id,
|
|
100
|
+
runtime: provider.runtime ?? provider.kind,
|
|
101
|
+
auth: authCheck(provider),
|
|
102
|
+
model: { status: "unknown", message: "Not tested yet" } as Check,
|
|
103
|
+
permission: permissionCheck(provider),
|
|
104
|
+
lifecycle: { state: "unknown", events: [] as string[] }
|
|
105
|
+
};
|
|
106
|
+
if (base.auth.status === "missing") return base;
|
|
107
|
+
if (!isCliProvider(provider)) return { ...base, model: { status: "unknown", message: "Only local CLI providers are tested here" } as Check };
|
|
108
|
+
try {
|
|
109
|
+
const checkedProvider = { ...provider, cliTimeoutMs: Math.min(provider.cliTimeoutMs ?? 30000, 30000) };
|
|
110
|
+
const result = await runCliProvider(checkedProvider, JSON.stringify({ input: "Reply with OK only. Molenkopf provider test." }), `test-${Date.now()}`);
|
|
111
|
+
return { ...base, model: modelCheck(result.body), permission: { ...base.permission, message: "No host permission block was observed during this read-only test" }, lifecycle: successfulCliLifecycle() };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = safeCliMessage(error);
|
|
114
|
+
const cli = cliErrorDiagnostics(error);
|
|
115
|
+
if (cli.permissionBlocked) return { ...base, model: { status: "failed", message: "CLI did not complete" } as Check, permission: { status: "blocked", message: "Local CLI reported a permission prompt" } as Check, lifecycle: cli.lifecycle };
|
|
116
|
+
if (cli.class === "auth_failure") return { ...base, auth: { status: "failed", message } as Check, model: { status: "failed", message: "CLI did not complete" } as Check, lifecycle: cli.lifecycle };
|
|
117
|
+
return { ...base, auth: authFromError(message, base.auth), model: { status: "failed", message } as Check, lifecycle: cli.lifecycle };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function authCheck(provider: ProviderConfig): Check {
|
|
122
|
+
if (missingProviderCredential(provider)) return { status: "missing", message: "Provider credential is missing" };
|
|
123
|
+
if (provider.runtimeAuthDir) return { status: "ok", message: "Imported auth directory is configured" };
|
|
124
|
+
if (provider.kind === "cli") return { status: "unknown", message: "Local CLI auth is managed by the installed client" };
|
|
125
|
+
return { status: "ok", message: "Provider credential policy is configured" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function permissionCheck(provider: ProviderConfig): Check {
|
|
129
|
+
if (provider.runtime === "claude") return { status: "unknown", message: "Outer Claude harness permissions are separate from imported auth" };
|
|
130
|
+
if (provider.runtime === "codex") return { status: "unknown", message: "Outer Codex harness permissions are separate from imported auth" };
|
|
131
|
+
return { status: "unknown", message: "No imported runtime permission profile" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function modelCheck(body: Buffer): Check {
|
|
135
|
+
const text = body.toString("utf8");
|
|
136
|
+
try {
|
|
137
|
+
const json = JSON.parse(text) as { output_text?: string; content?: { text?: string }[] };
|
|
138
|
+
const output = json.output_text ?? json.content?.map((item) => item.text).filter(Boolean).join(" ");
|
|
139
|
+
return output ? { status: "ok", message: "Model produced a non-empty response" } : { status: "failed", message: "Model response had no text" };
|
|
140
|
+
} catch {
|
|
141
|
+
return text.trim() ? { status: "ok", message: "Model produced a non-empty response" } : { status: "failed", message: "Model response was empty" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function authFromError(message: string, current: Check): Check {
|
|
146
|
+
return /output_class:auth_failure|not logged in|please run \/login|auth|authentication|credentials/i.test(message)
|
|
147
|
+
? { status: "failed", message }
|
|
148
|
+
: current;
|
|
149
|
+
}
|