@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.
Files changed (147) hide show
  1. package/.env.example +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/SECURITY.md +36 -0
  5. package/bin/launcher.js +76 -0
  6. package/bin/molenkopf.js +4 -0
  7. package/docs/DEPLOYMENT.md +104 -0
  8. package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
  9. package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
  10. package/docs/MOLENKOPF_USAGE.md +195 -0
  11. package/docs/PRODUCT_INTENT.md +36 -0
  12. package/docs/THREAT_MODEL.md +94 -0
  13. package/molenkopf.config.example.json +68 -0
  14. package/package.json +98 -0
  15. package/packages/core/src/auth/password.ts +47 -0
  16. package/packages/core/src/auth/session.ts +64 -0
  17. package/packages/core/src/ci/ci-mode.ts +71 -0
  18. package/packages/core/src/compression/content-classifier.ts +25 -0
  19. package/packages/core/src/compression/context-compressor.ts +48 -0
  20. package/packages/core/src/compression/json-compressor.ts +54 -0
  21. package/packages/core/src/compression/log-compressor.ts +32 -0
  22. package/packages/core/src/compression/operational-block-compressor.ts +43 -0
  23. package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
  24. package/packages/core/src/config/config-policies.ts +146 -0
  25. package/packages/core/src/config/molenkopf-config.ts +137 -0
  26. package/packages/core/src/config/provider-config.ts +139 -0
  27. package/packages/core/src/events/event-bus.ts +88 -0
  28. package/packages/core/src/identity/api-keys.ts +149 -0
  29. package/packages/core/src/identity/budget.ts +51 -0
  30. package/packages/core/src/identity/db.ts +68 -0
  31. package/packages/core/src/identity/identity-store.ts +175 -0
  32. package/packages/core/src/identity/identity-validation.ts +102 -0
  33. package/packages/core/src/identity/key-permissions.ts +18 -0
  34. package/packages/core/src/identity/pricing.ts +11 -0
  35. package/packages/core/src/identity/types.ts +87 -0
  36. package/packages/core/src/identity/usage-snapshot.ts +116 -0
  37. package/packages/core/src/manifest/audit-activity.ts +74 -0
  38. package/packages/core/src/manifest/audit-metrics.ts +7 -0
  39. package/packages/core/src/manifest/audit-safety.ts +113 -0
  40. package/packages/core/src/manifest/audit-store.ts +189 -0
  41. package/packages/core/src/manifest/audit-summary.ts +184 -0
  42. package/packages/core/src/manifest/usage-meter.ts +105 -0
  43. package/packages/core/src/memory/memory-extractor.ts +57 -0
  44. package/packages/core/src/memory/memory-graph.ts +55 -0
  45. package/packages/core/src/pipeline/json-string-spans.ts +143 -0
  46. package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
  47. package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
  48. package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
  49. package/packages/core/src/plugins/plugin-api.ts +96 -0
  50. package/packages/core/src/plugins/plugin-catalog.ts +42 -0
  51. package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
  52. package/packages/core/src/plugins/plugin-sdk.ts +47 -0
  53. package/packages/core/src/plugins/static-pipeline.ts +5 -0
  54. package/packages/core/src/profiles/profile-router.ts +45 -0
  55. package/packages/core/src/providers/provider-catalog.ts +186 -0
  56. package/packages/core/src/routing/distribution.ts +31 -0
  57. package/packages/core/src/security/secret-redactor.ts +139 -0
  58. package/packages/core/src/security/target-policy.ts +61 -0
  59. package/packages/core/src/storage/local-paths.ts +6 -0
  60. package/packages/core/src/storage/private-state.ts +30 -0
  61. package/packages/core/src/storage/purge-dir.ts +10 -0
  62. package/packages/core/src/store/retrieval-store.ts +114 -0
  63. package/packages/core/src/utils/hash.ts +9 -0
  64. package/packages/core/src/utils/text.ts +18 -0
  65. package/packages/core/src/utils/tokens.ts +3 -0
  66. package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
  67. package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
  68. package/packages/dashboard/dist/favicon.png +0 -0
  69. package/packages/dashboard/dist/index.html +15 -0
  70. package/packages/dashboard/dist/molenkopf-logo.png +0 -0
  71. package/packages/dashboard/public/favicon.png +0 -0
  72. package/packages/dashboard/public/molenkopf-logo.png +0 -0
  73. package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
  74. package/packages/plugins/context-compressor-plugin/page.html +191 -0
  75. package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
  76. package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
  77. package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
  78. package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
  79. package/packages/plugins/shared/audit-projects.ts +32 -0
  80. package/packages/proxy/src/cli/args.ts +34 -0
  81. package/packages/proxy/src/cli/config-loader.ts +43 -0
  82. package/packages/proxy/src/cli/env-file.ts +43 -0
  83. package/packages/proxy/src/cli/main.ts +132 -0
  84. package/packages/proxy/src/cli/profile-server.ts +176 -0
  85. package/packages/proxy/src/cli/target.ts +7 -0
  86. package/packages/proxy/src/http/agent-drafts.ts +103 -0
  87. package/packages/proxy/src/http/agent-router.ts +69 -0
  88. package/packages/proxy/src/http/audit-view.ts +15 -0
  89. package/packages/proxy/src/http/auth-state.ts +44 -0
  90. package/packages/proxy/src/http/budget-gate.ts +45 -0
  91. package/packages/proxy/src/http/budget-warnings.ts +7 -0
  92. package/packages/proxy/src/http/cli-stream-response.ts +51 -0
  93. package/packages/proxy/src/http/client-identity.ts +51 -0
  94. package/packages/proxy/src/http/communication-graph.ts +139 -0
  95. package/packages/proxy/src/http/control-plane-guard.ts +56 -0
  96. package/packages/proxy/src/http/dashboard-assets.ts +115 -0
  97. package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
  98. package/packages/proxy/src/http/header-utils.ts +65 -0
  99. package/packages/proxy/src/http/identity-id.ts +11 -0
  100. package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
  101. package/packages/proxy/src/http/local-api-auth.ts +120 -0
  102. package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
  103. package/packages/proxy/src/http/local-api-identity.ts +194 -0
  104. package/packages/proxy/src/http/local-api-io.ts +82 -0
  105. package/packages/proxy/src/http/local-api-keys.ts +126 -0
  106. package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
  107. package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
  108. package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
  109. package/packages/proxy/src/http/local-api-retention.ts +28 -0
  110. package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
  111. package/packages/proxy/src/http/local-api-scope.ts +47 -0
  112. package/packages/proxy/src/http/local-api-state.ts +180 -0
  113. package/packages/proxy/src/http/local-api.ts +166 -0
  114. package/packages/proxy/src/http/password-policy.ts +5 -0
  115. package/packages/proxy/src/http/plugin-data.ts +38 -0
  116. package/packages/proxy/src/http/plugin-host.ts +87 -0
  117. package/packages/proxy/src/http/plugin-modules.ts +1 -0
  118. package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
  119. package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
  120. package/packages/proxy/src/http/provider-access.ts +33 -0
  121. package/packages/proxy/src/http/provider-http-test.ts +133 -0
  122. package/packages/proxy/src/http/provider-input.ts +39 -0
  123. package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
  124. package/packages/proxy/src/http/provider-test.ts +149 -0
  125. package/packages/proxy/src/http/proxy-identity.ts +78 -0
  126. package/packages/proxy/src/http/public-bind.ts +8 -0
  127. package/packages/proxy/src/http/request-finish.ts +62 -0
  128. package/packages/proxy/src/http/request-path.ts +8 -0
  129. package/packages/proxy/src/http/request-policy.ts +46 -0
  130. package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
  131. package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
  132. package/packages/proxy/src/http/runtime-settings.ts +199 -0
  133. package/packages/proxy/src/http/runtime-state.ts +198 -0
  134. package/packages/proxy/src/http/server-io.ts +80 -0
  135. package/packages/proxy/src/http/server-types.ts +17 -0
  136. package/packages/proxy/src/http/server.ts +190 -0
  137. package/packages/proxy/src/http/session-secret.ts +19 -0
  138. package/packages/proxy/src/http/streaming-proxy.ts +88 -0
  139. package/packages/proxy/src/http/usage-accounting.ts +100 -0
  140. package/packages/proxy/src/http/usage-restore.ts +15 -0
  141. package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
  142. package/packages/proxy/src/runtime/cli-env.ts +22 -0
  143. package/packages/proxy/src/runtime/cli-executor.ts +134 -0
  144. package/packages/proxy/src/runtime/cli-provider.ts +162 -0
  145. package/packages/proxy/src/runtime/cli-request.ts +79 -0
  146. package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
  147. package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
@@ -0,0 +1,78 @@
1
+ import { authenticateKey, touchKey } from "../../../core/src/identity/api-keys.ts";
2
+ import type { IdentityStore } from "../../../core/src/identity/identity-store.ts";
3
+ import { agentIdFromHeaders, deriveClientIdentity, safeSubjectId, type ClientIdentity } from "./client-identity.ts";
4
+ import { effectiveProviderAllowlist } from "./provider-access.ts";
5
+
6
+ // Resolves a proxy request to a client identity. A Molenkopf-issued API key
7
+ // maps to its owner user and teams for internal budgets while audit-facing
8
+ // labels stay key based.
9
+
10
+ export type ResolvedIdentity = { client: ClientIdentity; presentedKey: boolean; keyOk: boolean };
11
+
12
+ export function presentedSecret(headers: Headers): string | undefined {
13
+ const local = headers.get("x-molenkopf-token")?.trim();
14
+ if (local?.startsWith("mk_")) return local;
15
+ const auth = headers.get("authorization");
16
+ if (auth) {
17
+ const token = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : auth.trim();
18
+ if (token.startsWith("mk_")) return token;
19
+ }
20
+ const xkey = headers.get("x-api-key")?.trim();
21
+ if (xkey?.startsWith("mk_")) return xkey;
22
+ return undefined;
23
+ }
24
+
25
+ export function stripMolenkopfAuthHeaders(headers: Headers): void {
26
+ headers.delete("x-molenkopf-token");
27
+ if (presentedAuthHeader(headers.get("authorization"))) headers.delete("authorization");
28
+ if (headers.get("x-api-key")?.trim().startsWith("mk_")) headers.delete("x-api-key");
29
+ }
30
+
31
+ function presentedAuthHeader(value: string | null): boolean {
32
+ if (!value) return false;
33
+ const token = value.toLowerCase().startsWith("bearer ") ? value.slice(7).trim() : value.trim();
34
+ return token.startsWith("mk_");
35
+ }
36
+
37
+ export function resolveClientIdentity(identity: IdentityStore | undefined, headers: Headers): ResolvedIdentity {
38
+ const secret = presentedSecret(headers);
39
+ if (identity && secret) {
40
+ const key = authenticateKey(identity, secret);
41
+ if (key) {
42
+ const owner = identity.getUser(key.ownerUserId);
43
+ const teamIds = owner ? scopedTeamIds(owner.teamIds, key.teamId) : [];
44
+ const agentId = agentIdFromHeaders(headers);
45
+ const client: ClientIdentity = {
46
+ id: `user:${safeSubjectId(key.ownerUserId)}`,
47
+ label: key.agentLabel ? `key:${key.id} agent:${safeSubjectId(key.agentLabel)}` : `key:${key.id}`,
48
+ source: "api_key",
49
+ userId: key.ownerUserId,
50
+ agentId,
51
+ keyAgentLabel: key.agentLabel,
52
+ teamIds,
53
+ keyId: key.id,
54
+ project: key.project,
55
+ allowedProviderIds: effectiveProviderAllowlist(identity, owner, teamIds, key.scopes)
56
+ };
57
+ maybeTouch(identity, key);
58
+ return { client, presentedKey: true, keyOk: true };
59
+ }
60
+ return { client: deriveClientIdentity(headers), presentedKey: true, keyOk: false };
61
+ }
62
+ return { client: deriveClientIdentity(headers), presentedKey: Boolean(secret), keyOk: false };
63
+ }
64
+
65
+ function maybeTouch(store: IdentityStore, key: { lastUsedAt?: string }): void {
66
+ const last = key.lastUsedAt ? Date.parse(key.lastUsedAt) : 0;
67
+ if (Date.now() - last > 60_000) {
68
+ touchKey(store, key as any);
69
+ void store.save().catch(() => { /* last-used persistence must not crash auth */ });
70
+ }
71
+ }
72
+
73
+ function scopedTeamIds(ownerTeamIds: string[], keyTeamId: string | undefined): string[] {
74
+ const explicitTeams = ownerTeamIds.filter((id) => id !== "everyone");
75
+ if (!keyTeamId) return explicitTeams.length ? explicitTeams : ownerTeamIds;
76
+ if (keyTeamId === "everyone" && explicitTeams.length) return explicitTeams;
77
+ return ownerTeamIds.includes(keyTeamId) ? [keyTeamId] : [];
78
+ }
@@ -0,0 +1,8 @@
1
+ export function isLoopbackBindHost(host: string): boolean {
2
+ const value = host.toLowerCase();
3
+ return value === "localhost" || value === "::1" || value === "[::1]" || /^127(?:\.|$)/.test(value);
4
+ }
5
+
6
+ export function requirePublicBindFlag(host: string, allowPublicBind?: boolean): void {
7
+ if (!isLoopbackBindHost(host) && !allowPublicBind) throw new Error("public bind requires --allow-public-bind");
8
+ }
@@ -0,0 +1,62 @@
1
+ import type { AuditManifest, AuditStore } from "../../../core/src/manifest/audit-store.ts";
2
+ import type { EventBus } from "../../../core/src/events/event-bus.ts";
3
+ import type { RewriteAudit } from "../../../core/src/pipeline/openai-request-rewriter.ts";
4
+ import type { UsageTotals } from "../../../core/src/manifest/usage-meter.ts";
5
+ import { isPluginEnabled, recordUsage, type RuntimeState } from "./runtime-state.ts";
6
+ import { recordCommunicationGraph } from "./communication-graph.ts";
7
+ import { safeSubjectId, type ClientIdentity } from "./client-identity.ts";
8
+ import type { PluginHost } from "./plugin-host.ts";
9
+
10
+ export async function finishRequest(manifest: AuditManifest, auditStore: AuditStore, events: EventBus, state: RuntimeState, pluginHost?: PluginHost): Promise<void> {
11
+ const stored = auditSafeManifest(manifest);
12
+ await auditStore.write(stored);
13
+ state.requests++;
14
+ state.compressedItems += manifest.compressedItems;
15
+ recordUsage(state, manifest);
16
+ state.latest = stored;
17
+ if (isPluginEnabled(state, "obsidian-graph-plugin")) recordCommunicationGraph(state.communicationGraph, stored);
18
+ await pluginHost?.audit(stored);
19
+ events.emit("request_finished", { requestId: manifest.requestId, data: { statusCode: manifest.statusCode, durationMs: manifest.durationMs } });
20
+ }
21
+
22
+ export function buildManifest(requestId: string, method: string, path: string, target: string, providerId: string, statusCode: number, durationMs: number, client: ClientIdentity, audit?: RewriteAudit, usage?: UsageTotals): AuditManifest {
23
+ return {
24
+ requestId, timestamp: new Date().toISOString(), method, path, targetHost: new URL(target).host, providerId,
25
+ client,
26
+ compressedItems: audit?.compressedItems ?? 0,
27
+ estimatedOriginalTokens: audit?.estimatedOriginalTokens ?? 0,
28
+ estimatedCompressedTokens: audit?.estimatedCompressedTokens ?? 0,
29
+ estimatedSavedTokens: audit?.estimatedSavedTokens ?? 0,
30
+ redactedSecrets: audit?.redactedSecrets ?? 0,
31
+ retrievalIds: audit?.retrievalIds ?? [],
32
+ compressorsUsed: [...new Set(audit?.compressorsUsed ?? [])],
33
+ warnings: audit?.warnings ?? [], statusCode, durationMs,
34
+ upstreamInputTokens: usage?.inputTokens,
35
+ upstreamOutputTokens: usage?.outputTokens
36
+ };
37
+ }
38
+
39
+ function auditSafeManifest(manifest: AuditManifest): AuditManifest {
40
+ return { ...manifest, client: manifest.client ? auditSafeClient(manifest.client) : undefined };
41
+ }
42
+
43
+ function auditSafeClient(client: NonNullable<AuditManifest["client"]>): AuditManifest["client"] {
44
+ return {
45
+ ...client,
46
+ id: safeClientId(client.id),
47
+ label: safeClientLabel(client),
48
+ userId: client.userId ? safeSubjectId(client.userId) : undefined,
49
+ agentId: client.agentId ? safeSubjectId(client.agentId) : undefined
50
+ };
51
+ }
52
+
53
+ function safeClientId(id: string): string {
54
+ const [prefix, ...rest] = id.split(":");
55
+ const value = rest.join(":");
56
+ return value ? `${prefix}:${safeSubjectId(value)}` : safeSubjectId(id);
57
+ }
58
+
59
+ function safeClientLabel(client: NonNullable<AuditManifest["client"]>): string {
60
+ if (client.source === "api_key" && client.keyId) return `key:${client.keyId}`;
61
+ return client.label.includes(":") ? safeClientId(client.label) : safeSubjectId(client.label);
62
+ }
@@ -0,0 +1,8 @@
1
+ export function auditPath(value: string | undefined): string {
2
+ try {
3
+ const url = new URL(value || "/", "http://molenkopf.local");
4
+ return url.pathname || "/";
5
+ } catch {
6
+ return "/";
7
+ }
8
+ }
@@ -0,0 +1,46 @@
1
+ import { agentIdFromHeaders, type ClientIdentity } from "./client-identity.ts";
2
+ import type { RuntimeState } from "./runtime-state.ts";
3
+
4
+ export type EffectiveRequestPolicy = {
5
+ agentId?: string;
6
+ allowedModels?: string[];
7
+ defaultModel?: string;
8
+ enabledPluginIds?: string[];
9
+ };
10
+
11
+ export type ModelPolicyResult = { ok: true } | { ok: false; status: number; error: string };
12
+
13
+ export function effectiveRequestPolicy(state: RuntimeState, headers: Headers, client: ClientIdentity): EffectiveRequestPolicy {
14
+ const agentId = agentIdFromHeaders(headers);
15
+ if (!agentId || client.source !== "api_key" || client.keyAgentLabel !== agentId) return {};
16
+ const configAgent = state.configAgents.find((item) => item.id === agentId);
17
+ const draft = state.agentDrafts.find((item) => item.id === agentId);
18
+ return {
19
+ agentId,
20
+ allowedModels: configAgent?.allowedModels,
21
+ defaultModel: configAgent?.defaultModel,
22
+ enabledPluginIds: configAgent?.enabledPluginIds ?? draft?.enabledPluginIds
23
+ };
24
+ }
25
+
26
+ export function pluginAllowedByPolicy(policy: EffectiveRequestPolicy, pluginId: string): boolean {
27
+ return policy.enabledPluginIds === undefined || policy.enabledPluginIds.includes(pluginId);
28
+ }
29
+
30
+ export function enforceModelPolicy(policy: EffectiveRequestPolicy, body: string): ModelPolicyResult {
31
+ if (!policy.allowedModels?.length) return { ok: true };
32
+ const model = modelFromBody(body);
33
+ if (model === false) return { ok: false, status: 400, error: "invalid_json" };
34
+ if (model === undefined) return { ok: true };
35
+ if (!policy.allowedModels.includes(model)) return { ok: false, status: 403, error: "model_forbidden" };
36
+ return { ok: true };
37
+ }
38
+
39
+ function modelFromBody(body: string): string | undefined | false {
40
+ try {
41
+ const parsed = JSON.parse(body) as { model?: unknown };
42
+ return typeof parsed.model === "string" && parsed.model.trim() ? parsed.model.trim() : undefined;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,55 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { RuntimeState } from "./runtime-state.ts";
3
+
4
+ type Proof = { digest: string; expiresAt: number };
5
+
6
+ const PROOF_TTL_MS = 5 * 60 * 1000;
7
+ const PROOF_FIELDS = ["id", "name", "runtime", "authJson", "profileText", "settingsJson", "configToml", "profile", "activate"] as const;
8
+
9
+ export function issueRuntimeAuthProof(state: RuntimeState, body: Record<string, unknown>, now = Date.now()): string {
10
+ clearExpiredProofs(state, now);
11
+ const token = randomBytes(24).toString("base64url");
12
+ state.runtimeAuthProofs[token] = { digest: runtimeAuthDigest(body), expiresAt: now + PROOF_TTL_MS };
13
+ return token;
14
+ }
15
+
16
+ export function consumeRuntimeAuthProof(state: RuntimeState, body: Record<string, unknown>, now = Date.now()): boolean {
17
+ const token = typeof body.importProof === "string" ? body.importProof : "";
18
+ const proof = token ? state.runtimeAuthProofs[token] : undefined;
19
+ if (token) delete state.runtimeAuthProofs[token];
20
+ clearExpiredProofs(state, now);
21
+ return Boolean(proof && proof.expiresAt > now && proof.digest === runtimeAuthDigest(body));
22
+ }
23
+
24
+ export function runtimeAuthDigest(body: Record<string, unknown>): string {
25
+ const payload: Record<string, unknown> = {};
26
+ for (const field of PROOF_FIELDS) if (body[field] !== undefined) payload[field] = normalize(body[field]);
27
+ return createHash("sha256").update(stableJson(payload)).digest("hex");
28
+ }
29
+
30
+ function clearExpiredProofs(state: RuntimeState, now: number): void {
31
+ for (const [token, proof] of Object.entries(state.runtimeAuthProofs)) {
32
+ if (proof.expiresAt <= now) delete state.runtimeAuthProofs[token];
33
+ }
34
+ }
35
+
36
+ function normalize(value: unknown): unknown {
37
+ if (typeof value === "string") return value.trim();
38
+ if (Array.isArray(value)) return value.map(normalize);
39
+ if (value && typeof value === "object") {
40
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalize(item)]));
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function stableJson(value: unknown): string {
46
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
47
+ if (value && typeof value === "object") {
48
+ return `{${Object.entries(value as Record<string, unknown>)
49
+ .sort(([a], [b]) => a.localeCompare(b))
50
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
51
+ }
52
+ return JSON.stringify(value);
53
+ }
54
+
55
+ export type RuntimeAuthProofStore = Record<string, Proof>;
@@ -0,0 +1,105 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
5
+ import { defaultDataDir } from "../../../core/src/storage/local-paths.ts";
6
+ import { ensurePrivateDir, writePrivateFile } from "../../../core/src/storage/private-state.ts";
7
+ import type { RoutingMode } from "./runtime-state.ts";
8
+ import { runtimeCliArgs } from "../runtime/runtime-profile.ts";
9
+
10
+ type RuntimeAuthMeta = Pick<ProviderConfig, "id" | "name" | "runtime" | "runtimeProfile" | "allowDistribution"> & { authRef: string };
11
+ type RuntimeAuthState = { activeProviderId?: string; routingMode?: RoutingMode };
12
+
13
+ const ID_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/i;
14
+ const META_FILE = "provider.json";
15
+ const STATE_FILE = "state.json";
16
+
17
+ export function restoreRuntimeAuthProviders(dataDir: string | undefined): { providers: ProviderConfig[]; activeProviderId?: string; routingMode?: RoutingMode } {
18
+ const root = runtimeAuthRoot(dataDir);
19
+ if (!root || !existsSync(root)) return { providers: [] };
20
+ const providers = readdirSync(root, { withFileTypes: true })
21
+ .filter((entry) => entry.isDirectory())
22
+ .map((entry) => providerFromDir(root, entry.name))
23
+ .filter((provider): provider is ProviderConfig => Boolean(provider));
24
+ const persisted = readJson<RuntimeAuthState>(join(root, STATE_FILE));
25
+ const activeProviderId = providers.some((item) => item.id === persisted?.activeProviderId) ? persisted?.activeProviderId : undefined;
26
+ return { providers, activeProviderId, routingMode: persisted?.routingMode };
27
+ }
28
+
29
+ export async function writeRuntimeAuthFiles(authDir: string, runtime: "claude" | "codex", content: string): Promise<void> {
30
+ await ensurePrivateDir(authDir);
31
+ await writePrivateFile(join(authDir, "auth.json"), content);
32
+ if (runtime === "claude") await writePrivateFile(join(authDir, ".credentials.json"), content);
33
+ }
34
+
35
+ export async function persistRuntimeAuthProvider(dataDir: string | undefined, provider: ProviderConfig, active: boolean, routingMode: RoutingMode): Promise<void> {
36
+ if (!provider.runtimeAuthDir || !provider.runtime) return;
37
+ await ensurePrivateDir(provider.runtimeAuthDir);
38
+ const meta: RuntimeAuthMeta = {
39
+ id: provider.id,
40
+ name: provider.name,
41
+ runtime: provider.runtime,
42
+ authRef: provider.authRef ?? `runtime-auth:${provider.id}`,
43
+ runtimeProfile: provider.runtimeProfile,
44
+ allowDistribution: provider.allowDistribution
45
+ };
46
+ await writePrivateFile(join(provider.runtimeAuthDir, META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
47
+ if (active) await persistRuntimeAuthSelection(dataDir, provider.id, routingMode);
48
+ }
49
+
50
+ export async function persistRuntimeAuthSelection(dataDir: string | undefined, activeProviderId: string, routingMode: RoutingMode): Promise<void> {
51
+ const root = runtimeAuthRoot(dataDir);
52
+ if (!root) return;
53
+ await ensurePrivateDir(root);
54
+ await writePrivateFile(join(root, STATE_FILE), `${JSON.stringify({ activeProviderId, routingMode }, null, 2)}\n`);
55
+ }
56
+
57
+ export async function removeRuntimeAuthProvider(provider: ProviderConfig): Promise<void> {
58
+ if (!provider.runtimeAuthDir) return;
59
+ await rm(provider.runtimeAuthDir, { recursive: true, force: true });
60
+ }
61
+
62
+ export function runtimeAuthProvider(id: string, name: string, runtime: "claude" | "codex", authDir: string, authRef: string, profile?: ProviderConfig["runtimeProfile"]): ProviderConfig {
63
+ return {
64
+ id,
65
+ name,
66
+ kind: "cli",
67
+ target: `cli://${id}`,
68
+ runtime,
69
+ cliCommand: runtime,
70
+ cliArgs: runtimeCliArgs(runtime, authDir, profile),
71
+ cliInputMode: "stdin",
72
+ cliTimeoutMs: 120000,
73
+ authScheme: "none",
74
+ credentialRef: "none",
75
+ runtimeAuthDir: authDir,
76
+ authRef,
77
+ runtimeProfile: profile,
78
+ allowDistribution: true,
79
+ enabled: true
80
+ };
81
+ }
82
+
83
+ function providerFromDir(root: string, id: string): ProviderConfig | undefined {
84
+ if (!ID_RE.test(id)) return undefined;
85
+ const authDir = join(root, id);
86
+ const meta = readJson<RuntimeAuthMeta>(join(authDir, META_FILE));
87
+ if (meta && meta.id === id && (meta.runtime === "claude" || meta.runtime === "codex") && typeof meta.authRef === "string" && meta.authRef.trim()) {
88
+ const provider = runtimeAuthProvider(id, meta.name || id, meta.runtime, authDir, meta.authRef.trim(), meta.runtimeProfile);
89
+ if (typeof meta.allowDistribution === "boolean") provider.allowDistribution = meta.allowDistribution;
90
+ return provider;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function runtimeAuthRoot(dataDir: string | undefined): string | undefined {
96
+ return join(dataDir ?? defaultDataDir(), "runtime-auth");
97
+ }
98
+
99
+ function readJson<T>(path: string): T | undefined {
100
+ try {
101
+ return JSON.parse(readFileSync(path, "utf8")) as T;
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync, readFileSync, renameSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
5
+ import { validateProviderTarget } from "../../../core/src/security/target-policy.ts";
6
+ import { defaultDataDir } from "../../../core/src/storage/local-paths.ts";
7
+ import { ensurePrivateDir, writePrivateFile } from "../../../core/src/storage/private-state.ts";
8
+ import { CONTROL_PLANE_LIMITS, type AgentDraftMetadata, type RoutingMode, type RuntimeState } from "./runtime-state.ts";
9
+
10
+ export type RuntimeSettings = {
11
+ activeProviderId?: string;
12
+ routingMode?: RoutingMode;
13
+ pluginEnabled?: Record<string, boolean>;
14
+ pluginOrder?: string[];
15
+ providerWeights?: Record<string, number>;
16
+ providers?: PersistedProvider[];
17
+ consumerBudgets?: Record<string, number>;
18
+ agentDrafts?: AgentDraftMetadata[];
19
+ };
20
+ export type RuntimeSettingsLoad = { settings: RuntimeSettings; warning?: string };
21
+ type PersistedProvider = Pick<ProviderConfig, "id" | "name" | "kind" | "target" | "credentialEnv" | "credentialRef" | "authScheme" | "protocol" | "enabled" | "allowDistribution" | "runtime" | "cliCommand" | "cliArgs" | "cliInputMode" | "cliTimeoutMs">;
22
+ const BUILT_IN_IDS = new Set(["default", "openai-env", "anthropic-env", "ollama-local", "lmstudio-local"]);
23
+
24
+ const FILE = "runtime-settings.json";
25
+
26
+ export function loadRuntimeSettings(dataDir: string | undefined): RuntimeSettingsLoad {
27
+ const file = settingsFile(dataDir);
28
+ if (!existsSync(file)) return { settings: {} };
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
31
+ const settings = cleanSettings(parsed);
32
+ return { settings, warning: cleanWarning(parsed, settings) };
33
+ } catch {
34
+ const corrupt = `${file}.corrupt.${Date.now()}`;
35
+ try { renameSync(file, corrupt); } catch { /* best effort */ }
36
+ return { settings: {}, warning: `runtime settings were corrupt and quarantined as ${corrupt}` };
37
+ }
38
+ }
39
+
40
+ export async function persistRuntimeSettings(state: RuntimeState): Promise<void> {
41
+ const file = settingsFile(state.dataDir);
42
+ await ensurePrivateDir(dirname(file));
43
+ const data: RuntimeSettings = {
44
+ activeProviderId: state.activeProviderId,
45
+ routingMode: state.routingMode,
46
+ pluginEnabled: state.pluginEnabled,
47
+ pluginOrder: state.pluginOrder,
48
+ providerWeights: state.providerWeights,
49
+ providers: state.providers.filter(persistableProvider).map(persistedProvider),
50
+ consumerBudgets: state.consumerBudgets,
51
+ agentDrafts: state.agentDrafts.map(persistedDraft)
52
+ };
53
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
54
+ await writePrivateFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
55
+ await rename(tmp, file);
56
+ }
57
+
58
+ function settingsFile(dataDir: string | undefined): string {
59
+ return join(dataDir ?? defaultDataDir(), FILE);
60
+ }
61
+
62
+ function persistableProvider(provider: ProviderConfig): boolean {
63
+ return !BUILT_IN_IDS.has(provider.id) && !provider.runtimeAuthDir && !provider.credentialValue;
64
+ }
65
+
66
+ function persistedProvider(provider: ProviderConfig): PersistedProvider {
67
+ const { id, name, kind, target, credentialEnv, credentialRef, authScheme, protocol, enabled, allowDistribution, runtime, cliCommand, cliArgs, cliInputMode, cliTimeoutMs } = provider;
68
+ return { id, name, kind, target, credentialEnv, credentialRef, authScheme, protocol, enabled, allowDistribution, runtime, cliCommand, cliArgs, cliInputMode, cliTimeoutMs };
69
+ }
70
+
71
+ function cleanSettings(value: unknown): RuntimeSettings {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
73
+ const input = value as RuntimeSettings;
74
+ return {
75
+ activeProviderId: idOk(input.activeProviderId) ? input.activeProviderId : undefined,
76
+ routingMode: input.routingMode === "manual" || input.routingMode === "distribute" ? input.routingMode : undefined,
77
+ pluginEnabled: cleanBooleanMap(input.pluginEnabled),
78
+ pluginOrder: cleanIdArray(input.pluginOrder),
79
+ providerWeights: cleanWeights(input.providerWeights),
80
+ providers: cleanProviders(input.providers),
81
+ consumerBudgets: cleanBudgets(input.consumerBudgets),
82
+ agentDrafts: cleanDrafts(input.agentDrafts)
83
+ };
84
+ }
85
+
86
+ function cleanWarning(value: unknown, settings: RuntimeSettings): string | undefined {
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
88
+ const providers = (value as RuntimeSettings).providers;
89
+ return Array.isArray(providers) && providers.length !== (settings.providers?.length ?? 0) ? "runtime settings contained invalid provider records that were ignored" : undefined;
90
+ }
91
+
92
+ function cleanBudgets(value: unknown): Record<string, number> | undefined {
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
94
+ const out: Record<string, number> = {};
95
+ for (const [id, limit] of Object.entries(value)) {
96
+ if (/^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(id) && typeof limit === "number" && Number.isInteger(limit) && limit > 0) out[id] = limit;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function cleanDrafts(value: unknown): AgentDraftMetadata[] | undefined {
102
+ if (!Array.isArray(value)) return undefined;
103
+ return value.slice(0, CONTROL_PLANE_LIMITS.agentDrafts).filter(isDraft).map(persistedDraft);
104
+ }
105
+
106
+ function cleanProviders(value: unknown): PersistedProvider[] | undefined {
107
+ if (!Array.isArray(value)) return undefined;
108
+ const seen = new Set<string>(), out: PersistedProvider[] = [];
109
+ for (const item of value) {
110
+ const provider = cleanProvider(item);
111
+ if (provider && !seen.has(provider.id)) { seen.add(provider.id); out.push(provider); }
112
+ }
113
+ return out.length ? out : undefined;
114
+ }
115
+
116
+ function cleanProvider(value: unknown): PersistedProvider | undefined {
117
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
118
+ const item = value as Record<string, unknown>;
119
+ const id = typeof item.id === "string" && idOk(item.id) && !BUILT_IN_IDS.has(item.id) ? item.id : "";
120
+ const kind = item.kind === "api" || item.kind === "local" || item.kind === "cli" ? item.kind : undefined;
121
+ const target = typeof item.target === "string" ? item.target : "";
122
+ if (!id || !kind || item.credentialValue !== undefined || item.credential !== undefined) return undefined;
123
+ if (kind === "cli") return cleanCliProvider(id, item);
124
+ try { validateProviderTarget(target, { path: "runtime provider target", allowPrivate: kind === "local" }); } catch { return undefined; }
125
+ const credentialEnv = cleanEnv(item.credentialEnv);
126
+ const credentialRef = credentialEnv ? `env:${credentialEnv}` : "none";
127
+ return cleanBaseProvider(id, item, kind, target, { credentialEnv, credentialRef, authScheme: cleanAuth(item.authScheme, target, credentialEnv), protocol: cleanProtocol(item.protocol) });
128
+ }
129
+
130
+ function cleanCliProvider(id: string, item: Record<string, unknown>): PersistedProvider | undefined {
131
+ const runtime = item.runtime === "claude" || item.runtime === "codex" ? item.runtime : undefined;
132
+ const target = typeof item.target === "string" && item.target === `cli://${id}` ? item.target : "";
133
+ if (!runtime || !target) return undefined;
134
+ const cliArgs = Array.isArray(item.cliArgs) && item.cliArgs.every((arg) => typeof arg === "string") ? item.cliArgs.slice(0, 20) as string[] : undefined;
135
+ const cliTimeoutMs = typeof item.cliTimeoutMs === "number" && Number.isInteger(item.cliTimeoutMs) && item.cliTimeoutMs > 0 && item.cliTimeoutMs <= 600000 ? item.cliTimeoutMs : undefined;
136
+ return cleanBaseProvider(id, item, "cli", target, {
137
+ runtime,
138
+ cliCommand: typeof item.cliCommand === "string" && item.cliCommand.trim() ? item.cliCommand.trim().slice(0, 80) : runtime,
139
+ cliArgs,
140
+ cliInputMode: item.cliInputMode === "argument" ? "argument" : "stdin",
141
+ cliTimeoutMs,
142
+ authScheme: "none",
143
+ credentialRef: "none"
144
+ });
145
+ }
146
+
147
+ function cleanBaseProvider(id: string, item: Record<string, unknown>, kind: ProviderConfig["kind"], target: string, extra: Partial<PersistedProvider>): PersistedProvider {
148
+ return { id, name: typeof item.name === "string" && item.name.trim() ? item.name.trim().slice(0, 80) : id, kind, target, enabled: item.enabled !== false, allowDistribution: typeof item.allowDistribution === "boolean" ? item.allowDistribution : undefined, ...extra };
149
+ }
150
+
151
+ function cleanBooleanMap(value: unknown): Record<string, boolean> | undefined {
152
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
153
+ const out: Record<string, boolean> = {};
154
+ for (const [id, enabled] of Object.entries(value)) if (idOk(id) && typeof enabled === "boolean") out[id] = enabled;
155
+ return Object.keys(out).length ? out : undefined;
156
+ }
157
+
158
+ function cleanWeights(value: unknown): Record<string, number> | undefined {
159
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
160
+ const out: Record<string, number> = {};
161
+ for (const [id, weight] of Object.entries(value)) if (idOk(id) && typeof weight === "number" && Number.isFinite(weight) && weight >= 0 && weight <= 1000) out[id] = weight;
162
+ return Object.keys(out).length ? out : undefined;
163
+ }
164
+
165
+ function cleanIdArray(value: unknown): string[] | undefined {
166
+ if (!Array.isArray(value)) return undefined;
167
+ const out = value.filter((id): id is string => idOk(id)).slice(0, 100);
168
+ return out.length ? [...new Set(out)] : undefined;
169
+ }
170
+
171
+ function cleanEnv(value: unknown): string | undefined {
172
+ return typeof value === "string" && /^[A-Z_][A-Z0-9_]*$/i.test(value) ? value : undefined;
173
+ }
174
+
175
+ function cleanAuth(value: unknown, target: string, credentialEnv?: string): ProviderConfig["authScheme"] {
176
+ if (value === "bearer" || value === "x-api-key" || value === "none") return value;
177
+ if (!credentialEnv) return "none";
178
+ return target.includes("anthropic") ? "x-api-key" : "bearer";
179
+ }
180
+
181
+ function cleanProtocol(value: unknown): ProviderConfig["protocol"] | undefined {
182
+ return value === "openai-responses" || value === "anthropic-messages" || value === "openai-chat" || value === "ollama-tags" ? value : undefined;
183
+ }
184
+
185
+ function isDraft(value: unknown): value is AgentDraftMetadata {
186
+ if (!value || typeof value !== "object") return false;
187
+ const item = value as AgentDraftMetadata;
188
+ return idOk(item.id) && idOk(item.providerId) && typeof item.label === "string" && Array.isArray(item.enabledPluginIds) && item.status === "draft";
189
+ }
190
+
191
+ function persistedDraft(draft: AgentDraftMetadata): AgentDraftMetadata {
192
+ const copy: AgentDraftMetadata = { ...draft, enabledPluginIds: [...draft.enabledPluginIds] };
193
+ if (!copy.tokenHash) delete copy.tokenHashAlgorithm;
194
+ return copy;
195
+ }
196
+
197
+ function idOk(value: unknown): value is string {
198
+ return typeof value === "string" && /^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(value);
199
+ }