@skaile/forge-settings 0.0.0-stage → 1.0.1
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 +28 -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 +46 -0
- package/dist/runtime/ai-sdk/index.js +31 -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 +72 -0
- package/dist/runtime/model/errors.d.ts +32 -0
- package/dist/runtime/model/errors.js +19 -0
- package/dist/runtime/server/credential-policy.d.ts +61 -0
- package/dist/runtime/server/credential-policy.js +65 -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 +183 -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 +35 -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 +221 -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/guards.d.ts +2 -0
- package/dist/runtime/shared/guards.js +3 -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/providers-panel-model.d.ts +147 -0
- package/dist/runtime/vue/providers-panel-model.js +37 -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 +115 -0
- package/dist/runtime/vue/useProvidersPanel.js +633 -0
- package/dist/types.d.mts +9 -0
- package/package.json +117 -4
- package/src/runtime/ai-sdk/index.ts +88 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
- package/src/runtime/editor/index.ts +146 -0
- package/src/runtime/model/errors.ts +52 -0
- package/src/runtime/server/credential-policy.ts +139 -0
- package/src/runtime/server/credential-store.ts +473 -0
- package/src/runtime/server/env-layers.ts +287 -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 +67 -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 +433 -0
- package/src/runtime/server/settings-defaults.ts +87 -0
- package/src/runtime/shared/env-vars.ts +82 -0
- package/src/runtime/shared/guards.ts +4 -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/providers-panel-model.ts +203 -0
- package/src/runtime/vue/settings-modal-scope.ts +66 -0
- package/src/runtime/vue/useProvidersPanel.ts +930 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSafeProviderId,
|
|
3
|
+
loadProviderRegistryFile,
|
|
4
|
+
mergeRegistries,
|
|
5
|
+
parseCredentialRef,
|
|
6
|
+
validateMergedRegistry,
|
|
7
|
+
validateRegistry
|
|
8
|
+
} from "@skaile/workspaces/core";
|
|
9
|
+
import {
|
|
10
|
+
AI_PROVIDER_PRESETS,
|
|
11
|
+
expandPreset
|
|
12
|
+
} from "@skaile/workspaces/sdk/bridge";
|
|
13
|
+
import { isPlainObject } from "../shared/guards.js";
|
|
14
|
+
import { entryPolicyRefusal } from "./credential-policy.js";
|
|
15
|
+
import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers.js";
|
|
16
|
+
export class ProviderWriteError extends Error {
|
|
17
|
+
statusCode;
|
|
18
|
+
statusMessage;
|
|
19
|
+
data;
|
|
20
|
+
constructor(input) {
|
|
21
|
+
super(input.statusMessage);
|
|
22
|
+
this.name = "ProviderWriteError";
|
|
23
|
+
this.statusCode = input.statusCode;
|
|
24
|
+
this.statusMessage = input.statusMessage;
|
|
25
|
+
if (input.data !== void 0) this.data = input.data;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function createError(input) {
|
|
29
|
+
return new ProviderWriteError(input);
|
|
30
|
+
}
|
|
31
|
+
export const PROVIDER_SECRET_REFUSAL = "Secrets go into the scope's .env; reference them as env:NAME";
|
|
32
|
+
const CREDENTIAL_REF_REFUSAL = "credential.ref must be a reference, not a secret: env:NAME, cmd:<shell>, platform:<configId> or keyring:<service>/<account>";
|
|
33
|
+
const PATCH_REFUSAL = "A workspace may only patch credential and enabled of an inherited entry; add it as a workspace entry to change anything else.";
|
|
34
|
+
const BODY_KEYS = {
|
|
35
|
+
preset: true,
|
|
36
|
+
label: true,
|
|
37
|
+
surface: true,
|
|
38
|
+
base_url: true,
|
|
39
|
+
headers: true,
|
|
40
|
+
credential: true,
|
|
41
|
+
model_map: true,
|
|
42
|
+
enabled: true
|
|
43
|
+
};
|
|
44
|
+
export const PATCH_KEYS = ["credential", "enabled"];
|
|
45
|
+
const SECRET_KEYS = { apiKey: true, api_key: true, token: true };
|
|
46
|
+
export function providerScopeWritable(scope, caller) {
|
|
47
|
+
if (caller.isAdmin) return true;
|
|
48
|
+
return scope.name !== "instance" && !scope.lock;
|
|
49
|
+
}
|
|
50
|
+
function mergeLayerOf(scope) {
|
|
51
|
+
return scope.name === "instance" ? "user" : "workspace";
|
|
52
|
+
}
|
|
53
|
+
function readLayerLeniently(file) {
|
|
54
|
+
try {
|
|
55
|
+
return readProviderLayer(file);
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function editableLayer(scope) {
|
|
61
|
+
try {
|
|
62
|
+
return readProviderLayer(scope.file);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw createError({
|
|
65
|
+
statusCode: 409,
|
|
66
|
+
statusMessage: `${err.message} - fix it by hand first`
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function readEntryBody(body) {
|
|
71
|
+
if (!isPlainObject(body)) return "Body must be a provider entry object";
|
|
72
|
+
const credential = body.credential;
|
|
73
|
+
const credentialKeys = isPlainObject(credential) ? Object.keys(credential) : [];
|
|
74
|
+
if (Object.keys(body).some((key) => Object.hasOwn(SECRET_KEYS, key)) || credentialKeys.some((key) => key === "value" || Object.hasOwn(SECRET_KEYS, key))) {
|
|
75
|
+
return PROVIDER_SECRET_REFUSAL;
|
|
76
|
+
}
|
|
77
|
+
const unknown = Object.keys(body).find((key) => !Object.hasOwn(BODY_KEYS, key) && key !== "kind");
|
|
78
|
+
if (unknown) return `'${unknown}' is not a provider entry field`;
|
|
79
|
+
if (body.preset !== void 0 && !catalogPreset(body.preset)) {
|
|
80
|
+
return `Unknown preset '${String(body.preset)}'`;
|
|
81
|
+
}
|
|
82
|
+
if (credential !== void 0) {
|
|
83
|
+
if (!isPlainObject(credential)) return "credential must be { kind, ref }";
|
|
84
|
+
const ref = credential.ref;
|
|
85
|
+
if (ref !== void 0 && (typeof ref !== "string" || !parseCredentialRef(ref)))
|
|
86
|
+
return CREDENTIAL_REF_REFUSAL;
|
|
87
|
+
}
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
function entryErrors(scope, providers, id) {
|
|
91
|
+
const layer = mergeLayerOf(scope);
|
|
92
|
+
const lower = scope.below.map((file) => loadProviderRegistryFile(file, "user"));
|
|
93
|
+
const own = validateRegistry({ version: 1, providers }, { source: scope.file, layer });
|
|
94
|
+
const fileLayers = [
|
|
95
|
+
...lower.map((file) => ({ layer: file.layer, providers: file.document.providers })),
|
|
96
|
+
{ layer, providers: own.document.providers }
|
|
97
|
+
];
|
|
98
|
+
const catalog = {};
|
|
99
|
+
for (const [entryId, patch] of Object.entries(mergeRegistries(fileLayers).providers)) {
|
|
100
|
+
const preset = catalogPreset(patch.preset);
|
|
101
|
+
if (preset) catalog[entryId] = expandPreset(preset);
|
|
102
|
+
}
|
|
103
|
+
const merged = validateMergedRegistry(
|
|
104
|
+
mergeRegistries([...fileLayers, { layer: "catalog", providers: catalog }]),
|
|
105
|
+
{
|
|
106
|
+
sources: [
|
|
107
|
+
...lower.map((file) => ({ label: file.path, providers: file.document.providers })),
|
|
108
|
+
{ label: scope.file, providers: own.document.providers }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
const path = `providers.${id}`;
|
|
113
|
+
return [...own.diagnostics, ...merged.diagnostics].filter(
|
|
114
|
+
(d) => d.severity === "error" && (d.path === path || d.path.startsWith(`${path}.`))
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
function isPatchOrAbsent(entry) {
|
|
118
|
+
if (entry === void 0) return true;
|
|
119
|
+
return isPlainObject(entry) && Object.keys(entry).every((key) => PATCH_KEYS.includes(key));
|
|
120
|
+
}
|
|
121
|
+
export function upsertProviderEntry(scope, id, body) {
|
|
122
|
+
if (!isSafeProviderId(id)) {
|
|
123
|
+
throw createError({
|
|
124
|
+
statusCode: 400,
|
|
125
|
+
statusMessage: `'${id}' is not a valid provider id - use [a-z0-9][a-z0-9_-]*`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const fields = readEntryBody(body);
|
|
129
|
+
if (typeof fields === "string") throw createError({ statusCode: 400, statusMessage: fields });
|
|
130
|
+
const providers = editableLayer(scope);
|
|
131
|
+
const existing = providers[id];
|
|
132
|
+
const lowerEntries = scope.below.map((file) => readLayerLeniently(file)[id]);
|
|
133
|
+
const inherited = lowerEntries.some((entry2) => entry2 !== void 0);
|
|
134
|
+
if ([...lowerEntries, existing].some((entry2) => isPlainObject(entry2) && entry2.locked === true)) {
|
|
135
|
+
throw createError({
|
|
136
|
+
statusCode: 403,
|
|
137
|
+
statusMessage: `Provider '${id}' is locked by the control plane`
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const fieldKeys = Object.keys(fields);
|
|
141
|
+
const isPatch = inherited && fieldKeys.length > 0 && fieldKeys.every((key) => PATCH_KEYS.includes(key));
|
|
142
|
+
if (inherited && !isPatch && isPatchOrAbsent(existing)) {
|
|
143
|
+
throw createError({ statusCode: 400, statusMessage: PATCH_REFUSAL });
|
|
144
|
+
}
|
|
145
|
+
let entry;
|
|
146
|
+
if (isPatch) {
|
|
147
|
+
entry = { ...isPlainObject(existing) ? existing : {}, ...fields };
|
|
148
|
+
} else {
|
|
149
|
+
const kept = isPlainObject(existing) ? Object.fromEntries(
|
|
150
|
+
Object.entries(existing).filter(([key]) => !Object.hasOwn(BODY_KEYS, key))
|
|
151
|
+
) : {};
|
|
152
|
+
entry = { ...kept, ...fields };
|
|
153
|
+
}
|
|
154
|
+
const next = { ...providers, [id]: entry };
|
|
155
|
+
const refusal = entryPolicyRefusal(entry, scope.policy);
|
|
156
|
+
if (refusal) throw createError({ statusCode: 400, statusMessage: refusal });
|
|
157
|
+
const diagnostics = entryErrors(scope, next, id);
|
|
158
|
+
if (diagnostics.length > 0) {
|
|
159
|
+
throw createError({
|
|
160
|
+
statusCode: 400,
|
|
161
|
+
statusMessage: `Invalid provider entry '${id}': ${diagnostics.map((d) => d.path).join(", ")}`,
|
|
162
|
+
data: { diagnostics }
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
writeProviderLayer(scope.file, next);
|
|
166
|
+
return { id, entry };
|
|
167
|
+
}
|
|
168
|
+
export function removeProviderEntry(scope, id) {
|
|
169
|
+
const providers = editableLayer(scope);
|
|
170
|
+
if (!Object.hasOwn(providers, id)) {
|
|
171
|
+
throw createError({
|
|
172
|
+
statusCode: 404,
|
|
173
|
+
statusMessage: `No provider '${id}' in the ${scope.name} registry`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const { [id]: _removed, ...rest } = providers;
|
|
177
|
+
writeProviderLayer(scope.file, rest);
|
|
178
|
+
return { id };
|
|
179
|
+
}
|
|
180
|
+
export function providerPresetSummaries(presets = Object.values(AI_PROVIDER_PRESETS)) {
|
|
181
|
+
return presets.map((preset) => ({
|
|
182
|
+
id: preset.id,
|
|
183
|
+
label: preset.label,
|
|
184
|
+
kind: preset.kind,
|
|
185
|
+
surface: preset.surface,
|
|
186
|
+
base_url: preset.base_url,
|
|
187
|
+
base_url_env: preset.base_url_env,
|
|
188
|
+
credential: { kind: preset.credential.kind, env: preset.credential.env }
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
export function detectedProviderPresets(env, registry) {
|
|
192
|
+
const used = new Set(Object.keys(registry));
|
|
193
|
+
for (const entry of Object.values(registry)) {
|
|
194
|
+
if (entry.preset !== void 0) used.add(entry.preset);
|
|
195
|
+
}
|
|
196
|
+
return providerPresetSummaries(
|
|
197
|
+
Object.values(AI_PROVIDER_PRESETS).filter((preset) => {
|
|
198
|
+
const name = preset.credential.env;
|
|
199
|
+
return name !== void 0 && Boolean(env[name]?.trim()) && !used.has(preset.id);
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled"];
|
|
204
|
+
export function withProviderLayers(views, scope) {
|
|
205
|
+
const own = readLayerLeniently(scope.file);
|
|
206
|
+
const lower = scope.below.map((file) => readLayerLeniently(file));
|
|
207
|
+
return views.map((view) => {
|
|
208
|
+
const layers = lower.filter((doc) => Object.hasOwn(doc, view.id)).map(() => "instance");
|
|
209
|
+
if (Object.hasOwn(own, view.id)) layers.push(scope.name);
|
|
210
|
+
const deduped = [...new Set(layers)];
|
|
211
|
+
if (deduped.length === 0) deduped.push(scope.below.length > 0 ? "instance" : scope.name);
|
|
212
|
+
const entry = Object.hasOwn(own, view.id) ? own[view.id] : lower.find((doc) => Object.hasOwn(doc, view.id))?.[view.id];
|
|
213
|
+
const layerFields = {};
|
|
214
|
+
if (isPlainObject(entry)) {
|
|
215
|
+
for (const key of LAYER_FIELD_KEYS) {
|
|
216
|
+
if (entry[key] !== void 0) layerFields[key] = entry[key];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { ...view, layers: deduped, layerFields };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings defaults and the legacy harness-key migration.
|
|
3
|
+
*
|
|
4
|
+
* Storage stays with the host: this module reads and writes nothing except the
|
|
5
|
+
* one `settings.json` {@link migrateLegacyHarnessSettings} is pointed at.
|
|
6
|
+
*/
|
|
7
|
+
import type { SkaileSettings } from "../shared/settings-types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Merged default settings for forge apps.
|
|
10
|
+
*
|
|
11
|
+
* Combines core defaults with the app-specific domain defaults. Used as the
|
|
12
|
+
* lowest-priority layer during settings resolution.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SETTINGS_DEFAULTS: SkaileSettings;
|
|
15
|
+
/**
|
|
16
|
+
* Rewrite one `settings.json` from the legacy harness keys to the current
|
|
17
|
+
* ones. Each legacy key is moved to its new name and removed; when the new key
|
|
18
|
+
* already exists it wins and the legacy value is dropped. A file without legacy
|
|
19
|
+
* keys (or one that is missing / not a JSON object) is left byte-identical.
|
|
20
|
+
*
|
|
21
|
+
* @param settingsPath - Absolute path to a `settings.json`.
|
|
22
|
+
* @returns The legacy keys that were rewritten (empty when nothing changed).
|
|
23
|
+
*/
|
|
24
|
+
export declare function migrateLegacyHarnessSettings(settingsPath: string): {
|
|
25
|
+
mapped: string[];
|
|
26
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { SETTINGS_DEFAULTS as CORE_DEFAULTS } from "@skaile/workspaces/sdk/core";
|
|
3
|
+
const APP_DEFAULTS = {
|
|
4
|
+
domains: {
|
|
5
|
+
enabled: [
|
|
6
|
+
"skaileup-conceptualization",
|
|
7
|
+
"skaileup-implementation",
|
|
8
|
+
"skaileup-evaluate",
|
|
9
|
+
"dev-security"
|
|
10
|
+
],
|
|
11
|
+
default: "skaileup-conceptualization"
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
export const SETTINGS_DEFAULTS = { ...CORE_DEFAULTS, ...APP_DEFAULTS };
|
|
15
|
+
const LEGACY_HARNESS_KEYS = {
|
|
16
|
+
driver: "harness",
|
|
17
|
+
driverModels: "harnessModels",
|
|
18
|
+
driverProviders: "harnessProviders",
|
|
19
|
+
enabledBackends: "enabledHarnesses",
|
|
20
|
+
lockBackend: "lockHarness"
|
|
21
|
+
};
|
|
22
|
+
export function migrateLegacyHarnessSettings(settingsPath) {
|
|
23
|
+
const mapped = [];
|
|
24
|
+
if (!existsSync(settingsPath)) return { mapped };
|
|
25
|
+
let settings;
|
|
26
|
+
try {
|
|
27
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return { mapped };
|
|
30
|
+
}
|
|
31
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
32
|
+
return { mapped };
|
|
33
|
+
}
|
|
34
|
+
const doc = settings;
|
|
35
|
+
for (const [legacy, current] of Object.entries(LEGACY_HARNESS_KEYS)) {
|
|
36
|
+
if (!(legacy in doc)) continue;
|
|
37
|
+
if (!(current in doc)) doc[current] = doc[legacy];
|
|
38
|
+
delete doc[legacy];
|
|
39
|
+
mapped.push(legacy);
|
|
40
|
+
}
|
|
41
|
+
if (mapped.length > 0) {
|
|
42
|
+
writeFileSync(settingsPath, `${JSON.stringify(doc, null, 2)}
|
|
43
|
+
`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
return { mapped };
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-layer vocabulary shared by the server helpers
|
|
3
|
+
* (`server/utils/env-layers.ts`) and the settings modal's Providers tab.
|
|
4
|
+
*
|
|
5
|
+
* Browser-safe on purpose — no `node:` imports — so the client refuses the
|
|
6
|
+
* same names the server refuses, from one source.
|
|
7
|
+
*
|
|
8
|
+
* Layers, lowest to highest precedence: `process` (boot env), `instance`
|
|
9
|
+
* (`$FORGE_HOME/.env`), `workspace` (`<workspace root>/.env`). The nearest
|
|
10
|
+
* layer defining a non-empty value wins.
|
|
11
|
+
*/
|
|
12
|
+
/** One source of environment variables. */
|
|
13
|
+
export type EnvLayerId = "process" | "instance" | "workspace";
|
|
14
|
+
/** Layer precedence, lowest first. */
|
|
15
|
+
export declare const ENV_LAYER_ORDER: readonly EnvLayerId[];
|
|
16
|
+
/** Names an editable `.env` layer accepts. */
|
|
17
|
+
export declare const ENV_VAR_NAME_PATTERN: RegExp;
|
|
18
|
+
/** Names no settings UI may write: they steer the process or forge itself. */
|
|
19
|
+
export declare const ENV_VAR_DENYLIST: readonly string[];
|
|
20
|
+
/** Refusal text for a name an editable layer must not hold, or `null` when it is fine. */
|
|
21
|
+
export declare function validateEnvVarName(name: string): string | null;
|
|
22
|
+
/** One variable as the browser sees it — never the value. */
|
|
23
|
+
export interface EnvVarView {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Last 4 characters when the value has at least 8, else `""`. */
|
|
26
|
+
hint: string;
|
|
27
|
+
length: number;
|
|
28
|
+
/** Layer whose value wins. */
|
|
29
|
+
source: EnvLayerId;
|
|
30
|
+
/** Lower layers that also define the name (their value is shadowed). */
|
|
31
|
+
shadows: EnvLayerId[];
|
|
32
|
+
/** Registry entry ids whose credential references `env:<name>`. */
|
|
33
|
+
referencedBy: string[];
|
|
34
|
+
}
|
|
35
|
+
/** A registry `env:` reference no layer defines. */
|
|
36
|
+
export interface EnvSettingsMissing {
|
|
37
|
+
name: string;
|
|
38
|
+
referencedBy: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Response of `GET`/`PUT <environmentUrl>`. */
|
|
41
|
+
export interface EnvSettingsDocument {
|
|
42
|
+
scope: "instance" | "workspace";
|
|
43
|
+
/** Whether the caller may `PUT` to this document's layer. */
|
|
44
|
+
editable: boolean;
|
|
45
|
+
/** The layer this document edits. */
|
|
46
|
+
layer: "instance" | "workspace";
|
|
47
|
+
/** Sorted by name. */
|
|
48
|
+
vars: EnvVarView[];
|
|
49
|
+
missing: EnvSettingsMissing[];
|
|
50
|
+
}
|
|
51
|
+
/** Request body of `PUT <environmentUrl>`. */
|
|
52
|
+
export interface EnvSettingsUpdate {
|
|
53
|
+
set: Record<string, string>;
|
|
54
|
+
unset: string[];
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const ENV_LAYER_ORDER = ["process", "instance", "workspace"];
|
|
2
|
+
export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
3
|
+
export const ENV_VAR_DENYLIST = [
|
|
4
|
+
"PATH",
|
|
5
|
+
"HOME",
|
|
6
|
+
"NODE_OPTIONS",
|
|
7
|
+
"LD_PRELOAD",
|
|
8
|
+
"DYLD_INSERT_LIBRARIES",
|
|
9
|
+
"FORGE_HOME",
|
|
10
|
+
"FORGE_CREDENTIAL_KEY",
|
|
11
|
+
"FORGE_CREDENTIAL_STORE",
|
|
12
|
+
"SKAILE_HOME"
|
|
13
|
+
];
|
|
14
|
+
export function validateEnvVarName(name) {
|
|
15
|
+
if (!ENV_VAR_NAME_PATTERN.test(name)) {
|
|
16
|
+
return `Invalid variable name "${name}": use upper-case letters, digits and underscores, not starting with a digit`;
|
|
17
|
+
}
|
|
18
|
+
if (ENV_VAR_DENYLIST.includes(name)) {
|
|
19
|
+
return `"${name}" cannot be set from settings`;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which registry providers a harness can run against, and which of
|
|
3
|
+
* them a session on it will use.
|
|
4
|
+
*
|
|
5
|
+
* One rule, applied identically by the settings dialog and the spawner, so the
|
|
6
|
+
* dialog never promises a provider the spawner will not deliver.
|
|
7
|
+
*
|
|
8
|
+
* Eligibility is by wire surface, never by vendor name: an entry is eligible
|
|
9
|
+
* for a harness when it is an enabled `llm` entry whose `surface` the harness
|
|
10
|
+
* declares. A harness that declares no surfaces accepts every `llm` entry.
|
|
11
|
+
*
|
|
12
|
+
* Browser-safe on purpose — no `node:` imports.
|
|
13
|
+
*/
|
|
14
|
+
/** How many registry entries a harness can run at once (`@skaile/workspaces` `HarnessInfo.providerPlurality`). */
|
|
15
|
+
export type HarnessProviderPlurality = "registry" | "credentials-n" | "single";
|
|
16
|
+
/** The slice of a harness the rule reads. `GET /api/agents` rows satisfy it. */
|
|
17
|
+
export interface HarnessProviderInfo {
|
|
18
|
+
id: string;
|
|
19
|
+
/** Wire surfaces the harness speaks. Absent: every `llm` entry is eligible. */
|
|
20
|
+
surfaces?: readonly string[];
|
|
21
|
+
/** Absent means `"single"`. */
|
|
22
|
+
providerPlurality?: HarnessProviderPlurality;
|
|
23
|
+
}
|
|
24
|
+
/** The slice of a registry entry the rule reads. `GET /api/providers` rows satisfy it. */
|
|
25
|
+
export interface HarnessProviderCandidate {
|
|
26
|
+
id: string;
|
|
27
|
+
/** Absent means `llm`. */
|
|
28
|
+
kind?: string;
|
|
29
|
+
/** Wire protocol of an `llm` entry. */
|
|
30
|
+
surface?: string;
|
|
31
|
+
/** Absent means enabled. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* - `chosen`: the stored pick, narrowed to what is still eligible, is used.
|
|
36
|
+
* - `auto`: nothing usable picked; the only eligible entry — or, for a multi
|
|
37
|
+
* harness, every eligible entry — is used.
|
|
38
|
+
* - `none-eligible`: no entry fits; the harness runs on its native login.
|
|
39
|
+
* - `needs-choice`: a single-provider harness with several eligible entries and
|
|
40
|
+
* no usable pick — the harness is not usable until one is chosen.
|
|
41
|
+
*/
|
|
42
|
+
export type HarnessProviderState = "chosen" | "auto" | "none-eligible" | "needs-choice";
|
|
43
|
+
export interface HarnessProviderResolution<C extends HarnessProviderCandidate = HarnessProviderCandidate> {
|
|
44
|
+
/** Eligible candidates, in candidate (registry) order. */
|
|
45
|
+
eligible: C[];
|
|
46
|
+
/** Ids the session uses, in registry order; empty for `none-eligible` and `needs-choice`. */
|
|
47
|
+
selected: string[];
|
|
48
|
+
state: HarnessProviderState;
|
|
49
|
+
/** The harness can run several providers at once. */
|
|
50
|
+
multiple: boolean;
|
|
51
|
+
/** Stored pick ids that are no longer eligible, deduplicated, in stored order. */
|
|
52
|
+
stale: string[];
|
|
53
|
+
}
|
|
54
|
+
/** Whether `candidate` is an enabled `llm` entry on a surface `harness` speaks. */
|
|
55
|
+
export declare function isProviderEligibleForHarness(harness: HarnessProviderInfo, candidate: HarnessProviderCandidate): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the providers a harness runs against.
|
|
58
|
+
*
|
|
59
|
+
* @param candidates Registry entries in registry order.
|
|
60
|
+
* @param selection The stored per-harness pick (`SkaileSettings.harnessProviders[harness.id]`).
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveHarnessProviders<C extends HarnessProviderCandidate>(harness: HarnessProviderInfo, candidates: readonly C[], selection: readonly string[] | undefined): HarnessProviderResolution<C>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function isProviderEligibleForHarness(harness, candidate) {
|
|
2
|
+
if ((candidate.kind ?? "llm") !== "llm") return false;
|
|
3
|
+
if (candidate.enabled === false) return false;
|
|
4
|
+
if (!harness.surfaces) return true;
|
|
5
|
+
return candidate.surface !== void 0 && harness.surfaces.includes(candidate.surface);
|
|
6
|
+
}
|
|
7
|
+
export function resolveHarnessProviders(harness, candidates, selection) {
|
|
8
|
+
const multiple = harness.providerPlurality === "registry" || harness.providerPlurality === "credentials-n";
|
|
9
|
+
const eligible = candidates.filter(
|
|
10
|
+
(candidate) => isProviderEligibleForHarness(harness, candidate)
|
|
11
|
+
);
|
|
12
|
+
const eligibleIds = new Set(eligible.map((candidate) => candidate.id));
|
|
13
|
+
const picked = new Set(selection ?? []);
|
|
14
|
+
const stale = [...picked].filter((id) => !eligibleIds.has(id));
|
|
15
|
+
const usable = eligible.filter((candidate) => picked.has(candidate.id)).map((candidate) => candidate.id);
|
|
16
|
+
if (usable.length > 0) {
|
|
17
|
+
return {
|
|
18
|
+
eligible,
|
|
19
|
+
selected: multiple ? usable : usable.slice(0, 1),
|
|
20
|
+
state: "chosen",
|
|
21
|
+
multiple,
|
|
22
|
+
stale
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (eligible.length === 0) {
|
|
26
|
+
return { eligible, selected: [], state: "none-eligible", multiple, stale };
|
|
27
|
+
}
|
|
28
|
+
if (eligible.length === 1 || multiple) {
|
|
29
|
+
return {
|
|
30
|
+
eligible,
|
|
31
|
+
selected: eligible.map((candidate) => candidate.id),
|
|
32
|
+
state: "auto",
|
|
33
|
+
multiple,
|
|
34
|
+
stale
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { eligible, selected: [], state: "needs-choice", multiple, stale };
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness catalog vocabulary shared by the server and the settings UI.
|
|
3
|
+
*
|
|
4
|
+
* A harness is a coding-agent product; `@skaile/workspaces` 6 names the adapter
|
|
5
|
+
* that runs it the same way, so these ids are the ids `listHarnesses()` reports
|
|
6
|
+
* (`--harness <id>`).
|
|
7
|
+
*
|
|
8
|
+
* {@link DEFAULT_ENABLED_HARNESSES} are the harnesses an instance offers when
|
|
9
|
+
* its settings name no `enabledHarnesses`; every other harness is experimental
|
|
10
|
+
* and must be enabled explicitly.
|
|
11
|
+
*
|
|
12
|
+
* Browser-safe on purpose — no `node:` imports.
|
|
13
|
+
*/
|
|
14
|
+
/** Display labels keyed by harness id. Unknown ids fall back to the harness's own name. */
|
|
15
|
+
export declare const HARNESS_LABELS: Record<string, string>;
|
|
16
|
+
/** Harness ids an instance enables when its settings carry no `enabledHarnesses`. */
|
|
17
|
+
export declare const DEFAULT_ENABLED_HARNESSES: readonly ["omp", "claude-sdk", "codex"];
|
|
18
|
+
/** Whether `id` is outside {@link DEFAULT_ENABLED_HARNESSES}, i.e. opt-in only. */
|
|
19
|
+
export declare function isExperimentalHarness(id: string): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const HARNESS_LABELS = {
|
|
2
|
+
omp: "oh-my-pi",
|
|
3
|
+
"claude-sdk": "Claude Code",
|
|
4
|
+
codex: "Codex",
|
|
5
|
+
gemini: "Gemini CLI",
|
|
6
|
+
qwen: "Qwen Code",
|
|
7
|
+
opencode: "OpenCode",
|
|
8
|
+
goose: "Goose",
|
|
9
|
+
continue: "Continue",
|
|
10
|
+
echo: "Echo (E2E mock)"
|
|
11
|
+
};
|
|
12
|
+
export const DEFAULT_ENABLED_HARNESSES = ["omp", "claude-sdk", "codex"];
|
|
13
|
+
export function isExperimentalHarness(id) {
|
|
14
|
+
return !DEFAULT_ENABLED_HARNESSES.includes(id);
|
|
15
|
+
}
|