@skaile/forge-settings 0.0.0-stage → 1.0.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/module.d.mts +17 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +23 -0
- package/dist/runtime/ai-sdk/index.d.ts +45 -0
- package/dist/runtime/ai-sdk/index.js +39 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
- package/dist/runtime/editor/index.d.ts +58 -0
- package/dist/runtime/editor/index.js +83 -0
- package/dist/runtime/model/errors.d.ts +24 -0
- package/dist/runtime/model/errors.js +10 -0
- package/dist/runtime/server/credential-policy.d.ts +60 -0
- package/dist/runtime/server/credential-policy.js +64 -0
- package/dist/runtime/server/credential-store.d.ts +184 -0
- package/dist/runtime/server/credential-store.js +234 -0
- package/dist/runtime/server/env-layers.d.ts +62 -0
- package/dist/runtime/server/env-layers.js +185 -0
- package/dist/runtime/server/env.d.ts +30 -0
- package/dist/runtime/server/env.js +15 -0
- package/dist/runtime/server/harness-settings.d.ts +150 -0
- package/dist/runtime/server/harness-settings.js +166 -0
- package/dist/runtime/server/index.d.ts +23 -0
- package/dist/runtime/server/index.js +14 -0
- package/dist/runtime/server/provider-discovery.d.ts +73 -0
- package/dist/runtime/server/provider-discovery.js +77 -0
- package/dist/runtime/server/provider-layers.d.ts +27 -0
- package/dist/runtime/server/provider-layers.js +37 -0
- package/dist/runtime/server/provider-service.d.ts +126 -0
- package/dist/runtime/server/provider-service.js +87 -0
- package/dist/runtime/server/provider-settings.d.ts +78 -0
- package/dist/runtime/server/provider-settings.js +81 -0
- package/dist/runtime/server/provider-write.d.ts +154 -0
- package/dist/runtime/server/provider-write.js +223 -0
- package/dist/runtime/server/settings-defaults.d.ts +26 -0
- package/dist/runtime/server/settings-defaults.js +46 -0
- package/dist/runtime/shared/env-vars.d.ts +55 -0
- package/dist/runtime/shared/env-vars.js +22 -0
- package/dist/runtime/shared/harness-providers.d.ts +62 -0
- package/dist/runtime/shared/harness-providers.js +38 -0
- package/dist/runtime/shared/harnesses.d.ts +19 -0
- package/dist/runtime/shared/harnesses.js +15 -0
- package/dist/runtime/shared/provider-registry.d.ts +255 -0
- package/dist/runtime/shared/provider-registry.js +112 -0
- package/dist/runtime/shared/settings-types.d.ts +84 -0
- package/dist/runtime/shared/settings-types.js +0 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/vue/index.d.ts +14 -0
- package/dist/runtime/vue/index.js +6 -0
- package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
- package/dist/runtime/vue/settings-modal-scope.js +2 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
- package/dist/runtime/vue/useProvidersPanel.js +655 -0
- package/dist/types.d.mts +9 -0
- package/package.json +117 -4
- package/src/runtime/ai-sdk/index.ts +91 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
- package/src/runtime/editor/index.ts +157 -0
- package/src/runtime/model/errors.ts +31 -0
- package/src/runtime/server/credential-policy.ts +137 -0
- package/src/runtime/server/credential-store.ts +473 -0
- package/src/runtime/server/env-layers.ts +290 -0
- package/src/runtime/server/env.ts +46 -0
- package/src/runtime/server/harness-settings.ts +373 -0
- package/src/runtime/server/index.ts +24 -0
- package/src/runtime/server/provider-discovery.ts +164 -0
- package/src/runtime/server/provider-layers.ts +70 -0
- package/src/runtime/server/provider-service.ts +244 -0
- package/src/runtime/server/provider-settings.ts +192 -0
- package/src/runtime/server/provider-write.ts +438 -0
- package/src/runtime/server/settings-defaults.ts +87 -0
- package/src/runtime/shared/env-vars.ts +82 -0
- package/src/runtime/shared/harness-providers.ts +118 -0
- package/src/runtime/shared/harnesses.ts +34 -0
- package/src/runtime/shared/provider-registry.ts +391 -0
- package/src/runtime/shared/settings-types.ts +87 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/vue/index.ts +15 -0
- package/src/runtime/vue/settings-modal-scope.ts +66 -0
- package/src/runtime/vue/useProvidersPanel.ts +1087 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
|
|
4
|
+
import { parseCredentialRef, registryEntries } from "../shared/provider-registry.js";
|
|
5
|
+
import {
|
|
6
|
+
ENV_LAYER_ORDER,
|
|
7
|
+
validateEnvVarName
|
|
8
|
+
} from "../shared/env-vars.js";
|
|
9
|
+
export * from "../shared/env-vars.js";
|
|
10
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/;
|
|
11
|
+
const NEEDS_QUOTES = /[\s#"'=\\]/;
|
|
12
|
+
function parseValue(raw) {
|
|
13
|
+
const value = raw.trim();
|
|
14
|
+
if (value.startsWith('"')) {
|
|
15
|
+
for (let i = 1; i < value.length; i++) {
|
|
16
|
+
if (value[i] === "\\") {
|
|
17
|
+
i++;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (value[i] === '"') {
|
|
21
|
+
return value.slice(1, i).replace(/\\([nr"\\])/g, (_, ch) => ch === "n" ? "\n" : ch === "r" ? "\r" : ch);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
if (value.startsWith("'")) {
|
|
27
|
+
const end = value.indexOf("'", 1);
|
|
28
|
+
return end === -1 ? value : value.slice(1, end);
|
|
29
|
+
}
|
|
30
|
+
const comment = value.search(/\s#/);
|
|
31
|
+
return (comment === -1 ? value : value.slice(0, comment)).trim();
|
|
32
|
+
}
|
|
33
|
+
export function parseDotenv(text) {
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const line of text.split(/\r?\n/)) {
|
|
36
|
+
const match = ASSIGNMENT.exec(line);
|
|
37
|
+
if (match) env[match[1]] = parseValue(match[2]);
|
|
38
|
+
}
|
|
39
|
+
return env;
|
|
40
|
+
}
|
|
41
|
+
export function readDotenvFile(path) {
|
|
42
|
+
try {
|
|
43
|
+
return parseDotenv(readFileSync(path, "utf-8"));
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function serializeValue(value) {
|
|
49
|
+
if (!NEEDS_QUOTES.test(value)) return value;
|
|
50
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
|
51
|
+
return `"${escaped}"`;
|
|
52
|
+
}
|
|
53
|
+
function readTextIfExists(path) {
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(path, "utf-8");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.code === "ENOENT") return "";
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeFileAtomic(path, content) {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
63
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
66
|
+
renameSync(tmp, path);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
rmSync(tmp, { force: true });
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
chmodSync(path, 384);
|
|
72
|
+
}
|
|
73
|
+
export function updateDotenvFile(path, patch) {
|
|
74
|
+
const text = readTextIfExists(path);
|
|
75
|
+
const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split("\n");
|
|
76
|
+
const written = /* @__PURE__ */ new Set();
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const key = ASSIGNMENT.exec(line)?.[1];
|
|
80
|
+
if (key === void 0 || !Object.hasOwn(patch, key)) {
|
|
81
|
+
out.push(line);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (written.has(key)) continue;
|
|
85
|
+
written.add(key);
|
|
86
|
+
const value = patch[key];
|
|
87
|
+
if (value === null) continue;
|
|
88
|
+
const prefix = /^\s*export\s/.test(line) ? "export " : "";
|
|
89
|
+
out.push(`${prefix}${key}=${serializeValue(value)}`);
|
|
90
|
+
}
|
|
91
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
92
|
+
if (written.has(key) || value === null) continue;
|
|
93
|
+
out.push(`${key}=${serializeValue(value)}`);
|
|
94
|
+
}
|
|
95
|
+
writeFileAtomic(path, out.length > 0 ? `${out.join("\n")}
|
|
96
|
+
` : "");
|
|
97
|
+
}
|
|
98
|
+
const DOTENV_IGNORE_LINES = {
|
|
99
|
+
".env": true,
|
|
100
|
+
"/.env": true,
|
|
101
|
+
"*.env": true,
|
|
102
|
+
".env*": true
|
|
103
|
+
};
|
|
104
|
+
export function ensureDotenvIgnored(dir) {
|
|
105
|
+
const path = join(dir, ".gitignore");
|
|
106
|
+
const text = readTextIfExists(path);
|
|
107
|
+
if (text.split(/\r?\n/).some((line) => DOTENV_IGNORE_LINES[line.trim()])) return;
|
|
108
|
+
const separator = text !== "" && !text.endsWith("\n") ? "\n" : "";
|
|
109
|
+
writeFileSync(path, `${text}${separator}.env
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
export function buildEnvSettingsDocument(input) {
|
|
113
|
+
const { layers } = input;
|
|
114
|
+
const referencedBy = /* @__PURE__ */ new Map();
|
|
115
|
+
for (const [id, entry] of registryEntries(input.registry)) {
|
|
116
|
+
const ref = entry.credential?.ref;
|
|
117
|
+
const parsed = ref ? parseCredentialRef(ref) : void 0;
|
|
118
|
+
if (parsed?.form !== "env") continue;
|
|
119
|
+
const ids = referencedBy.get(parsed.name) ?? [];
|
|
120
|
+
ids.push(id);
|
|
121
|
+
referencedBy.set(parsed.name, ids);
|
|
122
|
+
}
|
|
123
|
+
const names = new Set(referencedBy.keys());
|
|
124
|
+
for (const layer of ["instance", "workspace"]) {
|
|
125
|
+
for (const name of Object.keys(layers[layer] ?? {})) names.add(name);
|
|
126
|
+
}
|
|
127
|
+
for (const preset of Object.values(AI_PROVIDER_PRESETS)) {
|
|
128
|
+
const name = preset.credential.env;
|
|
129
|
+
if (name && ENV_LAYER_ORDER.some((layer) => layers[layer]?.[name])) names.add(name);
|
|
130
|
+
}
|
|
131
|
+
const vars = [];
|
|
132
|
+
const missing = [];
|
|
133
|
+
for (const name of [...names].sort()) {
|
|
134
|
+
const refs = [...referencedBy.get(name) ?? []].sort();
|
|
135
|
+
const defining = ENV_LAYER_ORDER.filter((layer) => {
|
|
136
|
+
const value2 = layers[layer]?.[name];
|
|
137
|
+
return value2 !== void 0 && value2 !== "";
|
|
138
|
+
});
|
|
139
|
+
const source = defining.at(-1);
|
|
140
|
+
if (source === void 0) {
|
|
141
|
+
if (refs.length > 0) missing.push({ name, referencedBy: refs });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const value = layers[source]?.[name] ?? "";
|
|
145
|
+
vars.push({
|
|
146
|
+
name,
|
|
147
|
+
hint: value.length >= 8 ? value.slice(-4) : "",
|
|
148
|
+
length: value.length,
|
|
149
|
+
source,
|
|
150
|
+
shadows: defining.slice(0, -1),
|
|
151
|
+
referencedBy: refs
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return { scope: input.scope, editable: input.editable, layer: input.scope, vars, missing };
|
|
155
|
+
}
|
|
156
|
+
function isPlainObject(value) {
|
|
157
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
+
}
|
|
159
|
+
export function validateEnvSettingsUpdate(body) {
|
|
160
|
+
if (!isPlainObject(body)) return "Body must be an object with `set` and/or `unset`";
|
|
161
|
+
const set = {};
|
|
162
|
+
if (body.set !== void 0) {
|
|
163
|
+
if (!isPlainObject(body.set)) return "`set` must be an object of variable names to values";
|
|
164
|
+
for (const [name, value] of Object.entries(body.set)) {
|
|
165
|
+
const refusal = validateEnvVarName(name);
|
|
166
|
+
if (refusal) return refusal;
|
|
167
|
+
if (typeof value !== "string") return `Value of "${name}" must be a string`;
|
|
168
|
+
if (/[\r\n]/.test(value)) return `Value of "${name}" must be a single line`;
|
|
169
|
+
set[name] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const unset = [];
|
|
173
|
+
if (body.unset !== void 0) {
|
|
174
|
+
if (!Array.isArray(body.unset)) return "`unset` must be an array of variable names";
|
|
175
|
+
for (const name of body.unset) {
|
|
176
|
+
if (typeof name !== "string") return "`unset` must be an array of variable names";
|
|
177
|
+
const refusal = validateEnvVarName(name);
|
|
178
|
+
if (refusal) return refusal;
|
|
179
|
+
if (Object.hasOwn(set, name)) return `"${name}" cannot be both set and unset`;
|
|
180
|
+
if (!unset.includes(name)) unset.push(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (Object.keys(set).length === 0 && unset.length === 0) return "Nothing to change";
|
|
184
|
+
return { set, unset };
|
|
185
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env file loading utilities for forge apps.
|
|
3
|
+
*
|
|
4
|
+
* Nuxt's built-in dotenv only injects NUXT_* variables into runtime config.
|
|
5
|
+
* API keys and provider preferences (ANTHROPIC_API_KEY, PREFERRED_PROVIDER,
|
|
6
|
+
* PREFERRED_MODEL, etc.) are plain env vars read directly via process.env, so
|
|
7
|
+
* forge apps need to load them explicitly at server startup.
|
|
8
|
+
*
|
|
9
|
+
* Usage pattern (in a Nitro plugin named 00.env-load.ts):
|
|
10
|
+
*
|
|
11
|
+
* import { injectEnvFiles } from '@skaile/forge-settings/server'
|
|
12
|
+
* injectEnvFiles([appRoot])
|
|
13
|
+
*
|
|
14
|
+
* ~/.env is always included as the lowest-priority source automatically.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Parse the `.env` file in the given directory (see `parseDotenv` for the
|
|
18
|
+
* syntax). Returns an empty object if the file does not exist or cannot be read.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadEnvFile(dir: string): Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Load `.env` files and inject missing keys into process.env.
|
|
23
|
+
*
|
|
24
|
+
* Directories are merged in order — later directories take precedence.
|
|
25
|
+
* `~/.env` is always prepended as the lowest-priority source.
|
|
26
|
+
* Real environment variables (already in process.env) are never overwritten.
|
|
27
|
+
*
|
|
28
|
+
* @param appDirs - App-specific directories to scan (e.g. app root, project root)
|
|
29
|
+
*/
|
|
30
|
+
export declare function injectEnvFiles(appDirs: string[]): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readDotenvFile } from "./env-layers.js";
|
|
4
|
+
export function loadEnvFile(dir) {
|
|
5
|
+
return readDotenvFile(path.join(dir, ".env"));
|
|
6
|
+
}
|
|
7
|
+
export function injectEnvFiles(appDirs) {
|
|
8
|
+
const merged = {};
|
|
9
|
+
for (const dir of [homedir(), ...appDirs]) {
|
|
10
|
+
Object.assign(merged, loadEnvFile(dir));
|
|
11
|
+
}
|
|
12
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
13
|
+
if (!process.env[k] && v) process.env[k] = v;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness settings — the one implementation both forge hosts share.
|
|
3
|
+
*
|
|
4
|
+
* A harness is a coding-agent product; `@skaile/workspaces` 6 names the adapter
|
|
5
|
+
* that runs it the same way (`listHarnesses()`), and the selecting settings key
|
|
6
|
+
* is `harness`.
|
|
7
|
+
*
|
|
8
|
+
* What lives here, so hosts only read and write their own storage:
|
|
9
|
+
*
|
|
10
|
+
* - {@link validateHarnessSettingsPatch} — the rule set for a harness settings
|
|
11
|
+
* write (`harness`, `harnessModels`, `harnessProviders`, `enabledHarnesses`,
|
|
12
|
+
* `lock*`), as a pure function over the request body and the stored state.
|
|
13
|
+
* - {@link resolveLockFlags} — the admin lock flags in force. Locks are
|
|
14
|
+
* `settings.json` fields only; there is no environment override.
|
|
15
|
+
* - {@link applyHarnessLayers} — which harness, model and provider pick a
|
|
16
|
+
* session starts with, given the settings layers the host read.
|
|
17
|
+
* - {@link enabledHarnessIds} / {@link buildHarnessRows} — the harness catalog
|
|
18
|
+
* `GET /api/agents` serves.
|
|
19
|
+
*
|
|
20
|
+
* Pure functions over plain values: no Node built-ins, no environment, no file access.
|
|
21
|
+
*/
|
|
22
|
+
import type { HarnessInfo } from "@skaile/workspaces/types";
|
|
23
|
+
import type { SkaileSettings } from "../shared/settings-types.js";
|
|
24
|
+
/** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
|
|
25
|
+
export type HarnessSettings = Pick<SkaileSettings, "harness" | "harnessModels" | "harnessProviders" | "enabledHarnesses" | "lockHarness" | "lockProvider" | "lockModel" | "lockAudio">;
|
|
26
|
+
/** One lock flag and where its value comes from. */
|
|
27
|
+
export type LockFlagStatus = {
|
|
28
|
+
value: boolean;
|
|
29
|
+
source: "settings" | "default";
|
|
30
|
+
};
|
|
31
|
+
/** The admin lock flags in force on an instance. */
|
|
32
|
+
export type LockFlags = {
|
|
33
|
+
harness: LockFlagStatus;
|
|
34
|
+
provider: LockFlagStatus;
|
|
35
|
+
model: LockFlagStatus;
|
|
36
|
+
audio: LockFlagStatus;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The lock flags a stored settings document carries: a boolean `lock*` field is
|
|
40
|
+
* `{ value, source: "settings" }`, anything else `{ value: false, source:
|
|
41
|
+
* "default" }`. Pass the instance settings (locks describe the instance, not a
|
|
42
|
+
* workspace or a user).
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveLockFlags(stored: Partial<HarnessSettings> | null | undefined): LockFlags;
|
|
45
|
+
/**
|
|
46
|
+
* The harness ids this instance offers: its `enabledHarnesses`, else
|
|
47
|
+
* {@link DEFAULT_ENABLED_HARNESSES}. A missing or empty list means the default
|
|
48
|
+
* set.
|
|
49
|
+
*/
|
|
50
|
+
export declare function enabledHarnessIds(s: Pick<HarnessSettings, "enabledHarnesses">): string[];
|
|
51
|
+
/** A `GET /api/agents` row: the harness's own info plus how this instance presents it. */
|
|
52
|
+
export type HarnessRow = HarnessInfo & {
|
|
53
|
+
/** Display label ({@link HARNESS_LABELS}, else the harness's own `name`). */
|
|
54
|
+
label: string;
|
|
55
|
+
/** Outside {@link DEFAULT_ENABLED_HARNESSES}: opt-in only. */
|
|
56
|
+
experimental: boolean;
|
|
57
|
+
/** Whether the instance's `enabledHarnesses` offers it. */
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
|
|
62
|
+
* filter to `enabled` unless the request asked for all (`?all=1`).
|
|
63
|
+
* `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
|
|
64
|
+
* Harness tab can offer each harness only the providers it can use.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildHarnessRows(harnesses: HarnessInfo[], s: Pick<HarnessSettings, "enabledHarnesses">): HarnessRow[];
|
|
67
|
+
/** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
|
|
68
|
+
export type HarnessSettingsPatch = {
|
|
69
|
+
/**
|
|
70
|
+
* The fields to write; a key whose value is `undefined` is removed from the
|
|
71
|
+
* stored document. Only fields that carry something to write appear.
|
|
72
|
+
*/
|
|
73
|
+
patch: Partial<HarnessSettings>;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the effective `harness`, `harnessModels`, `harnessProviders` or
|
|
76
|
+
* `enabledHarnesses` changed, i.e. whether running sessions now run on stale
|
|
77
|
+
* defaults and the host should stop them.
|
|
78
|
+
*/
|
|
79
|
+
sessionAffecting: boolean;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Validate the harness part of a settings write and compute what to store.
|
|
83
|
+
* Keys outside the harness set are ignored — the host validates its own.
|
|
84
|
+
*
|
|
85
|
+
* - `model`: refused — the model is set per harness (`harnessModels`).
|
|
86
|
+
* - `harness`: a catalog harness id that is enabled; empty/null removes it
|
|
87
|
+
* (→ `"omp"`). The body key `driver` (the pre-6.0 spelling) is an alias for
|
|
88
|
+
* one major: it is validated the same way and stored as `harness`. When both
|
|
89
|
+
* keys are present `harness` wins.
|
|
90
|
+
* - `harnessModels`: patched per harness id (catalog ids only); a value is a
|
|
91
|
+
* `providerId/modelId` selector, an empty string removes that harness's entry.
|
|
92
|
+
* - `harnessProviders`: the registry entry ids picked per harness id. The modal
|
|
93
|
+
* sends the whole map, so it replaces the stored one: each value must be an
|
|
94
|
+
* array of strings (empty strings are dropped, duplicates collapse, order is
|
|
95
|
+
* kept), a harness whose array ends up empty is removed from the map, and the
|
|
96
|
+
* key appears in `patch` only when the map changed. Ids are not checked
|
|
97
|
+
* against the registry — a pick that is no longer eligible is shown as stale
|
|
98
|
+
* in the modal.
|
|
99
|
+
* - `enabledHarnesses`: catalog ids, always including the resulting `harness`.
|
|
100
|
+
* - `lockHarness` / `lockProvider` / `lockModel` / `lockAudio`: booleans,
|
|
101
|
+
* written only when they differ from `ctx.locks`.
|
|
102
|
+
*
|
|
103
|
+
* `ctx.locks` is used only to skip no-op lock writes; it is **not** enforced
|
|
104
|
+
* here. Whether the caller may write at all (admin, instance scope) and
|
|
105
|
+
* whether a locked field may be overridden is the host's decision, made before
|
|
106
|
+
* this runs.
|
|
107
|
+
*
|
|
108
|
+
* @param body the request body, untrusted.
|
|
109
|
+
* @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
|
|
110
|
+
* @param ctx.current the stored settings the patch applies to (the instance file).
|
|
111
|
+
* @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
|
|
112
|
+
* @returns the patch, or the error message of the first violated rule (a
|
|
113
|
+
* string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
|
|
114
|
+
*/
|
|
115
|
+
export declare function validateHarnessSettingsPatch(body: unknown, ctx: {
|
|
116
|
+
catalog: Iterable<string>;
|
|
117
|
+
current: HarnessSettings;
|
|
118
|
+
locks: LockFlags;
|
|
119
|
+
}): HarnessSettingsPatch | string;
|
|
120
|
+
/** One settings layer's say in which harness, model and provider pick a session gets. */
|
|
121
|
+
export type HarnessLayer = {
|
|
122
|
+
/** The harness id (the workspaces settings key `harness`). */
|
|
123
|
+
harness?: string;
|
|
124
|
+
/** An explicit model selector; wins over this layer's `harnessModels` entry. */
|
|
125
|
+
model?: string;
|
|
126
|
+
harnessModels?: Record<string, string>;
|
|
127
|
+
harnessProviders?: Record<string, string[]>;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the harness, model and provider pick a session starts with.
|
|
131
|
+
*
|
|
132
|
+
* `layers[0]` is the instance layer; later layers (workspace file, workspace
|
|
133
|
+
* overrides, chat, …) are more specific and override it per field — the last
|
|
134
|
+
* layer that sets a field wins. A locked field (`locks.harness` → `harness`,
|
|
135
|
+
* `locks.model` → `model`, `locks.provider` → `providers`) ignores every later
|
|
136
|
+
* layer: layer 0's value is the answer, even when it is unset.
|
|
137
|
+
*
|
|
138
|
+
* "Sets" means a non-empty `harness`/`model` string or a non-empty provider
|
|
139
|
+
* array; `""` and `[]` inherit. A layer's model is `layer.model`, else
|
|
140
|
+
* `layer.harnessModels[harness]`; its providers are
|
|
141
|
+
* `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
|
|
142
|
+
* a pick stored for another harness never applies.
|
|
143
|
+
*
|
|
144
|
+
* Fields no layer sets come back `undefined`; the host supplies its defaults.
|
|
145
|
+
*/
|
|
146
|
+
export declare function applyHarnessLayers(layers: HarnessLayer[], locks: LockFlags): {
|
|
147
|
+
harness: string | undefined;
|
|
148
|
+
model: string | undefined;
|
|
149
|
+
providers: string[] | undefined;
|
|
150
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ENABLED_HARNESSES,
|
|
3
|
+
HARNESS_LABELS,
|
|
4
|
+
isExperimentalHarness
|
|
5
|
+
} from "../shared/harnesses.js";
|
|
6
|
+
const LOCK_FIELDS = [
|
|
7
|
+
["lockHarness", "harness"],
|
|
8
|
+
["lockProvider", "provider"],
|
|
9
|
+
["lockModel", "model"],
|
|
10
|
+
["lockAudio", "audio"]
|
|
11
|
+
];
|
|
12
|
+
export function resolveLockFlags(stored) {
|
|
13
|
+
const flag = (value) => typeof value === "boolean" ? { value, source: "settings" } : { value: false, source: "default" };
|
|
14
|
+
return {
|
|
15
|
+
harness: flag(stored?.lockHarness),
|
|
16
|
+
provider: flag(stored?.lockProvider),
|
|
17
|
+
model: flag(stored?.lockModel),
|
|
18
|
+
audio: flag(stored?.lockAudio)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function enabledHarnessIds(s) {
|
|
22
|
+
return s.enabledHarnesses?.length ? s.enabledHarnesses : [...DEFAULT_ENABLED_HARNESSES];
|
|
23
|
+
}
|
|
24
|
+
export function buildHarnessRows(harnesses, s) {
|
|
25
|
+
const enabled = new Set(enabledHarnessIds(s));
|
|
26
|
+
return harnesses.map((info) => ({
|
|
27
|
+
...info,
|
|
28
|
+
label: HARNESS_LABELS[info.id] ?? info.name,
|
|
29
|
+
experimental: isExperimentalHarness(info.id),
|
|
30
|
+
enabled: enabled.has(info.id)
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
const nonEmpty = (value) => typeof value === "string" && value ? value : void 0;
|
|
35
|
+
function isModelSelector(value) {
|
|
36
|
+
const slash = value.indexOf("/");
|
|
37
|
+
return slash > 0 && slash < value.length - 1;
|
|
38
|
+
}
|
|
39
|
+
function stringEntries(value) {
|
|
40
|
+
const entries = {};
|
|
41
|
+
if (!isObject(value)) return entries;
|
|
42
|
+
for (const [id, entry] of Object.entries(value))
|
|
43
|
+
if (typeof entry === "string" && entry) entries[id] = entry;
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
function sameEntries(a, b) {
|
|
47
|
+
const keys = Object.keys(a);
|
|
48
|
+
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
|
49
|
+
}
|
|
50
|
+
function idLists(value) {
|
|
51
|
+
const lists = {};
|
|
52
|
+
if (!isObject(value)) return lists;
|
|
53
|
+
for (const [harnessId, ids] of Object.entries(value)) {
|
|
54
|
+
if (!Array.isArray(ids)) continue;
|
|
55
|
+
const kept = [
|
|
56
|
+
...new Set(ids.filter((id) => typeof id === "string" && id !== ""))
|
|
57
|
+
];
|
|
58
|
+
if (kept.length) lists[harnessId] = kept;
|
|
59
|
+
}
|
|
60
|
+
return lists;
|
|
61
|
+
}
|
|
62
|
+
function sameIdLists(a, b) {
|
|
63
|
+
const keys = Object.keys(a);
|
|
64
|
+
return keys.length === Object.keys(b).length && keys.every(
|
|
65
|
+
(key) => b[key] !== void 0 && a[key].length === b[key].length && a[key].every((id, i) => id === b[key][i])
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
export function validateHarnessSettingsPatch(body, ctx) {
|
|
69
|
+
if (!isObject(body)) return "body must be an object";
|
|
70
|
+
if ("model" in body) return "model is set per harness - use harnessModels";
|
|
71
|
+
const { current, locks } = ctx;
|
|
72
|
+
const catalog = new Set(ctx.catalog);
|
|
73
|
+
const patch = {};
|
|
74
|
+
let sessionAffecting = false;
|
|
75
|
+
const currentHarness = nonEmpty(current.harness) ?? "omp";
|
|
76
|
+
let nextHarness = currentHarness;
|
|
77
|
+
const harnessKey = "harness" in body ? "harness" : "driver" in body ? "driver" : void 0;
|
|
78
|
+
if (harnessKey) {
|
|
79
|
+
const raw = body[harnessKey];
|
|
80
|
+
if (raw !== null && raw !== void 0 && typeof raw !== "string")
|
|
81
|
+
return `${harnessKey} must be a string`;
|
|
82
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
83
|
+
if (value && !catalog.has(value)) return `Unknown harness "${value}"`;
|
|
84
|
+
patch.harness = value || void 0;
|
|
85
|
+
nextHarness = value || "omp";
|
|
86
|
+
if (nextHarness !== currentHarness) sessionAffecting = true;
|
|
87
|
+
}
|
|
88
|
+
if ("harnessModels" in body) {
|
|
89
|
+
const raw = body.harnessModels;
|
|
90
|
+
if (!isObject(raw)) return "harnessModels must be an object";
|
|
91
|
+
const currentModels = stringEntries(current.harnessModels);
|
|
92
|
+
const nextModels = { ...currentModels };
|
|
93
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
94
|
+
if (!catalog.has(id)) return `harnessModels: unknown harness "${id}"`;
|
|
95
|
+
if (typeof entry !== "string") return `harnessModels.${id} must be a string`;
|
|
96
|
+
const value = entry.trim();
|
|
97
|
+
if (!value) {
|
|
98
|
+
delete nextModels[id];
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!isModelSelector(value))
|
|
102
|
+
return `harnessModels.${id} must be a "providerId/modelId" selector`;
|
|
103
|
+
nextModels[id] = value;
|
|
104
|
+
}
|
|
105
|
+
patch.harnessModels = Object.keys(nextModels).length ? nextModels : void 0;
|
|
106
|
+
if (!sameEntries(currentModels, nextModels)) sessionAffecting = true;
|
|
107
|
+
}
|
|
108
|
+
if ("harnessProviders" in body) {
|
|
109
|
+
const raw = body.harnessProviders;
|
|
110
|
+
if (!isObject(raw)) return "harnessProviders must be an object";
|
|
111
|
+
for (const [id, ids] of Object.entries(raw)) {
|
|
112
|
+
if (!Array.isArray(ids) || !ids.every((entry) => typeof entry === "string")) {
|
|
113
|
+
return `harnessProviders.${id} must be an array of provider ids`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const next = idLists(raw);
|
|
117
|
+
if (!sameIdLists(idLists(current.harnessProviders), next)) {
|
|
118
|
+
patch.harnessProviders = Object.keys(next).length ? next : void 0;
|
|
119
|
+
sessionAffecting = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if ("enabledHarnesses" in body) {
|
|
123
|
+
const raw = body.enabledHarnesses;
|
|
124
|
+
if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
|
|
125
|
+
return "enabledHarnesses must be an array of harness ids";
|
|
126
|
+
}
|
|
127
|
+
const unknownId = raw.find((id) => !catalog.has(id));
|
|
128
|
+
if (unknownId !== void 0) return `enabledHarnesses: unknown harness "${unknownId}"`;
|
|
129
|
+
const enabled = [...new Set(raw)];
|
|
130
|
+
if (!enabled.includes(nextHarness))
|
|
131
|
+
return `enabledHarnesses must include the active harness "${nextHarness}"`;
|
|
132
|
+
patch.enabledHarnesses = enabled;
|
|
133
|
+
const before = enabledHarnessIds(current);
|
|
134
|
+
if (enabled.length !== before.length || enabled.some((id) => !before.includes(id)))
|
|
135
|
+
sessionAffecting = true;
|
|
136
|
+
} else if (harnessKey && !enabledHarnessIds(current).includes(nextHarness)) {
|
|
137
|
+
return `Harness "${nextHarness}" is not enabled`;
|
|
138
|
+
}
|
|
139
|
+
for (const [field, lock] of LOCK_FIELDS) {
|
|
140
|
+
const wanted = body[field];
|
|
141
|
+
if (wanted === void 0) continue;
|
|
142
|
+
if (typeof wanted !== "boolean") return `${field} must be a boolean`;
|
|
143
|
+
if (wanted !== locks[lock].value) patch[field] = wanted;
|
|
144
|
+
}
|
|
145
|
+
return { patch, sessionAffecting };
|
|
146
|
+
}
|
|
147
|
+
export function applyHarnessLayers(layers, locks) {
|
|
148
|
+
const pick = (locked, read) => {
|
|
149
|
+
if (locked) return layers[0] ? read(layers[0]) : void 0;
|
|
150
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
151
|
+
const value = read(layers[i]);
|
|
152
|
+
if (value !== void 0) return value;
|
|
153
|
+
}
|
|
154
|
+
return void 0;
|
|
155
|
+
};
|
|
156
|
+
const harness = pick(locks.harness.value, (layer) => nonEmpty(layer.harness));
|
|
157
|
+
const model = pick(
|
|
158
|
+
locks.model.value,
|
|
159
|
+
(layer) => nonEmpty(layer.model) ?? (harness === void 0 ? void 0 : nonEmpty(layer.harnessModels?.[harness]))
|
|
160
|
+
);
|
|
161
|
+
const providers = pick(locks.provider.value, (layer) => {
|
|
162
|
+
const ids = harness === void 0 ? void 0 : layer.harnessProviders?.[harness];
|
|
163
|
+
return ids?.length ? ids : void 0;
|
|
164
|
+
});
|
|
165
|
+
return { harness, model, providers };
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@skaile/forge-settings/server` — provider and settings operations for a
|
|
3
|
+
* host server: the registry vocabulary and layer editing, the credential
|
|
4
|
+
* store, env layers, harness settings, model discovery.
|
|
5
|
+
*
|
|
6
|
+
* Imports no Vue, no editor, no AI SDK and no agent session code. Storage,
|
|
7
|
+
* authentication and workspace permissions stay with the host.
|
|
8
|
+
*/
|
|
9
|
+
export * from "../shared/harness-providers.js";
|
|
10
|
+
export * from "../shared/harnesses.js";
|
|
11
|
+
export * from "../shared/provider-registry.js";
|
|
12
|
+
export type { DomainConfig, SkaileSettings } from "../shared/settings-types.js";
|
|
13
|
+
export * from "./credential-policy.js";
|
|
14
|
+
export * from "./credential-store.js";
|
|
15
|
+
export * from "./env.js";
|
|
16
|
+
export * from "./env-layers.js";
|
|
17
|
+
export * from "./harness-settings.js";
|
|
18
|
+
export * from "./provider-discovery.js";
|
|
19
|
+
export * from "./provider-layers.js";
|
|
20
|
+
export * from "./provider-service.js";
|
|
21
|
+
export * from "./provider-settings.js";
|
|
22
|
+
export * from "./provider-write.js";
|
|
23
|
+
export * from "./settings-defaults.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "../shared/harness-providers.js";
|
|
2
|
+
export * from "../shared/harnesses.js";
|
|
3
|
+
export * from "../shared/provider-registry.js";
|
|
4
|
+
export * from "./credential-policy.js";
|
|
5
|
+
export * from "./credential-store.js";
|
|
6
|
+
export * from "./env.js";
|
|
7
|
+
export * from "./env-layers.js";
|
|
8
|
+
export * from "./harness-settings.js";
|
|
9
|
+
export * from "./provider-discovery.js";
|
|
10
|
+
export * from "./provider-layers.js";
|
|
11
|
+
export * from "./provider-service.js";
|
|
12
|
+
export * from "./provider-settings.js";
|
|
13
|
+
export * from "./provider-write.js";
|
|
14
|
+
export * from "./settings-defaults.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live model discovery for one registry entry.
|
|
3
|
+
*
|
|
4
|
+
* `listAIProviderModels` (provider-service) answers from the registry's own
|
|
5
|
+
* `model_map`; this module asks the provider's REST API. The two are different
|
|
6
|
+
* facts and the result says which one it is:
|
|
7
|
+
*
|
|
8
|
+
* - `live` — the provider answered; `models` is its list.
|
|
9
|
+
* - `static` — the live request failed or the entry has no key, and `models` is
|
|
10
|
+
* the offline catalog of its preset. It is a fallback, never a live answer.
|
|
11
|
+
* - `unsupported` — the entry has no catalog preset, so there is nothing to ask;
|
|
12
|
+
* a host lets the admin type a model id.
|
|
13
|
+
* - `error` — nothing usable. The message is scrubbed.
|
|
14
|
+
*
|
|
15
|
+
* The error text is built here, never forwarded: a remote response body is
|
|
16
|
+
* never part of it, and every secret this call resolved (or the host names in
|
|
17
|
+
* `redact`) is removed from whatever text remains before it leaves the module.
|
|
18
|
+
*
|
|
19
|
+
* The credential policy (see `credential-policy.ts`) is applied to the entry
|
|
20
|
+
* before its credential is resolved, and `env:` references read from the
|
|
21
|
+
* explicit `env` only.
|
|
22
|
+
*/
|
|
23
|
+
import type { AIProviderRegistryView } from "../shared/provider-registry.js";
|
|
24
|
+
import { type CredentialPolicy } from "./credential-policy.js";
|
|
25
|
+
/** One model a provider offers. */
|
|
26
|
+
export interface DiscoveredModel {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
/** Outcome of {@link discoverProviderModels}. */
|
|
31
|
+
export type ModelDiscoveryResult = {
|
|
32
|
+
status: "live";
|
|
33
|
+
providerId: string;
|
|
34
|
+
models: DiscoveredModel[];
|
|
35
|
+
} | {
|
|
36
|
+
status: "static";
|
|
37
|
+
providerId: string;
|
|
38
|
+
models: DiscoveredModel[];
|
|
39
|
+
/** Why the live request did not produce the list. Scrubbed. */
|
|
40
|
+
reason: string;
|
|
41
|
+
} | {
|
|
42
|
+
status: "unsupported";
|
|
43
|
+
providerId: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
} | {
|
|
46
|
+
status: "error";
|
|
47
|
+
providerId: string;
|
|
48
|
+
error: string;
|
|
49
|
+
};
|
|
50
|
+
/** Options for {@link discoverProviderModels}. */
|
|
51
|
+
export interface DiscoverProviderModelsOptions {
|
|
52
|
+
/** Environment `env:` references and base-URL variables are read from. Required: no ambient process. */
|
|
53
|
+
env: Record<string, string | undefined>;
|
|
54
|
+
/** Which credential references may be resolved. Absent: no restriction. */
|
|
55
|
+
policy?: CredentialPolicy;
|
|
56
|
+
/** Extra strings to remove from error text, besides the secrets this call resolves. */
|
|
57
|
+
redact?: readonly string[];
|
|
58
|
+
/** Local encrypted store for `keyring:` references. */
|
|
59
|
+
key?: string;
|
|
60
|
+
path?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Remove every occurrence of each secret (verbatim, URL-encoded and
|
|
64
|
+
* base64-encoded) from `text`, then cap its length.
|
|
65
|
+
*/
|
|
66
|
+
export declare function scrubSecrets(text: string, secrets: readonly (string | undefined)[]): string;
|
|
67
|
+
/**
|
|
68
|
+
* Ask one registry entry's provider for its models.
|
|
69
|
+
*
|
|
70
|
+
* Never throws for a provider-side failure: that is the `error` / `static`
|
|
71
|
+
* result. A host maps `error` to its own status.
|
|
72
|
+
*/
|
|
73
|
+
export declare function discoverProviderModels(providerId: string, registry: AIProviderRegistryView, options: DiscoverProviderModelsOptions): Promise<ModelDiscoveryResult>;
|