@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +45 -0
  8. package/dist/runtime/ai-sdk/index.js +39 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +83 -0
  14. package/dist/runtime/model/errors.d.ts +24 -0
  15. package/dist/runtime/model/errors.js +10 -0
  16. package/dist/runtime/server/credential-policy.d.ts +60 -0
  17. package/dist/runtime/server/credential-policy.js +64 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +185 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +37 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +223 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  43. package/dist/runtime/shared/harness-providers.js +38 -0
  44. package/dist/runtime/shared/harnesses.d.ts +19 -0
  45. package/dist/runtime/shared/harnesses.js +15 -0
  46. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  47. package/dist/runtime/shared/provider-registry.js +112 -0
  48. package/dist/runtime/shared/settings-types.d.ts +84 -0
  49. package/dist/runtime/shared/settings-types.js +0 -0
  50. package/dist/runtime/shims-vue.d.ts +8 -0
  51. package/dist/runtime/vue/index.d.ts +14 -0
  52. package/dist/runtime/vue/index.js +6 -0
  53. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  54. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  55. package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
  56. package/dist/runtime/vue/useProvidersPanel.js +655 -0
  57. package/dist/types.d.mts +9 -0
  58. package/package.json +117 -4
  59. package/src/runtime/ai-sdk/index.ts +91 -0
  60. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  61. package/src/runtime/editor/index.ts +157 -0
  62. package/src/runtime/model/errors.ts +31 -0
  63. package/src/runtime/server/credential-policy.ts +137 -0
  64. package/src/runtime/server/credential-store.ts +473 -0
  65. package/src/runtime/server/env-layers.ts +290 -0
  66. package/src/runtime/server/env.ts +46 -0
  67. package/src/runtime/server/harness-settings.ts +373 -0
  68. package/src/runtime/server/index.ts +24 -0
  69. package/src/runtime/server/provider-discovery.ts +164 -0
  70. package/src/runtime/server/provider-layers.ts +70 -0
  71. package/src/runtime/server/provider-service.ts +244 -0
  72. package/src/runtime/server/provider-settings.ts +192 -0
  73. package/src/runtime/server/provider-write.ts +438 -0
  74. package/src/runtime/server/settings-defaults.ts +87 -0
  75. package/src/runtime/shared/env-vars.ts +82 -0
  76. package/src/runtime/shared/harness-providers.ts +118 -0
  77. package/src/runtime/shared/harnesses.ts +34 -0
  78. package/src/runtime/shared/provider-registry.ts +391 -0
  79. package/src/runtime/shared/settings-types.ts +87 -0
  80. package/src/runtime/shims-vue.d.ts +8 -0
  81. package/src/runtime/vue/index.ts +15 -0
  82. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  83. package/src/runtime/vue/useProvidersPanel.ts +1087 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * The forge credential store — secrets at rest, keyed by credential-reference
3
+ * identity, plus resolution of the reference forms forge itself can serve.
4
+ *
5
+ * A registry entry never holds a secret: it holds a `credential.ref`
6
+ * (`env:NAME`, `cmd:<shell>`, `platform:<configId>`, `keyring:<service>/<account>`).
7
+ * This module is the backend for the two forms a forge server owns:
8
+ *
9
+ * - `env:NAME` — read from the process environment.
10
+ * - `keyring:s/a` — read from this store: an AES-256-GCM encrypted JSON file
11
+ * whose keys are the reference strings themselves. A forge
12
+ * app runs headless on a server with no OS keychain, so the
13
+ * encrypted file *is* the keyring the grammar names.
14
+ *
15
+ * `cmd:` is resolved by the runtime that owns the shell (the spawned
16
+ * `skaile serve` reads its own `providers.yml`), and `platform:` by the control
17
+ * plane that injects the session secret; both are reported, never guessed.
18
+ *
19
+ * Encryption is bound to reference identity: the reference string is the GCM
20
+ * additional-authenticated-data, so a blob stored for one reference cannot be
21
+ * replayed under another — moving `keyring:forge/anthropic` onto
22
+ * `keyring:forge/openai` fails authentication instead of silently handing the
23
+ * wrong endpoint a working key.
24
+ *
25
+ * Blob format: `enc:<base64(iv[16] + tag[16] + ciphertext)>`.
26
+ *
27
+ * Key format: `FORGE_CREDENTIAL_KEY` must be a 64-char hex string (32 bytes).
28
+ */
29
+
30
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
+ import { dirname, join } from "node:path";
33
+ import { globalSettingsPath } from "@skaile/workspaces/sdk/core";
34
+ import type { AIProviderEntry, AIProviderRegistryView } from "../shared/provider-registry";
35
+ import { isEntryEnabled, parseCredentialRef, registryEntries } from "../shared/provider-registry";
36
+ import {
37
+ type CredentialPolicy,
38
+ credentialRefRefusal,
39
+ entryPolicyRefusal,
40
+ } from "./credential-policy";
41
+
42
+ export const CREDENTIAL_PREFIX = "enc:" as const;
43
+
44
+ /** Filename of the local keyring, next to the user's `settings.json`. */
45
+ const STORE_FILENAME = "forge-credentials.json";
46
+
47
+ const ALGORITHM = "aes-256-gcm" as const;
48
+ const IV_LENGTH = 16; // bytes
49
+ const TAG_LENGTH = 16; // bytes
50
+
51
+ /**
52
+ * The credential key is missing or unusable. Only operations that need the
53
+ * encrypted store raise it; `env:` and `cmd:` references never touch the key.
54
+ * `statusCode` is 503 so a host can map it straight to an HTTP answer.
55
+ */
56
+ export class CredentialKeyError extends Error {
57
+ readonly code: "missing" | "invalid";
58
+ readonly statusCode = 503 as const;
59
+ constructor(code: "missing" | "invalid", message: string) {
60
+ super(message);
61
+ this.name = "CredentialKeyError";
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * A stored blob did not authenticate: the key is wrong, the data was altered,
68
+ * or the blob was encrypted for a different reference (the reference is the
69
+ * GCM additional data). The message names no secret and no plaintext.
70
+ */
71
+ export class CredentialDecryptError extends Error {
72
+ constructor() {
73
+ super(
74
+ "Encrypted credential could not be authenticated: wrong key, altered data, " +
75
+ "or it belongs to a different reference.",
76
+ );
77
+ this.name = "CredentialDecryptError";
78
+ }
79
+ }
80
+
81
+ /** Validate that `key` is a 64-character hex string (32 bytes). */
82
+ function parseKey(key: string): Buffer {
83
+ if (key.length !== 64) {
84
+ throw new CredentialKeyError(
85
+ "invalid",
86
+ `FORGE_CREDENTIAL_KEY must be a 64-character hex string (32 bytes). Got ${key.length} chars.`,
87
+ );
88
+ }
89
+ if (!/^[0-9a-fA-F]{64}$/.test(key)) {
90
+ throw new CredentialKeyError(
91
+ "invalid",
92
+ "FORGE_CREDENTIAL_KEY contains non-hex characters. Provide a 64-char hex string.",
93
+ );
94
+ }
95
+ return Buffer.from(key, "hex");
96
+ }
97
+
98
+ /**
99
+ * Encrypt a credential for storage under one reference.
100
+ *
101
+ * @param plaintext - The secret. Must not be empty.
102
+ * @param hexKey - 64-char hex key (32 bytes) from `FORGE_CREDENTIAL_KEY`.
103
+ * @param ref - The credential reference this blob belongs to; bound into
104
+ * the ciphertext as GCM additional data.
105
+ * @returns `enc:<base64(iv + tag + ciphertext)>`
106
+ */
107
+ export function encryptCredential(plaintext: string, hexKey: string, ref: string): string {
108
+ if (!plaintext) throw new Error("plaintext must not be empty");
109
+ if (!ref) throw new Error("ref must not be empty — credentials are encrypted per reference");
110
+ const key = parseKey(hexKey);
111
+ const iv = randomBytes(IV_LENGTH);
112
+ const cipher = createCipheriv(ALGORITHM, key, iv);
113
+ cipher.setAAD(Buffer.from(ref, "utf8"));
114
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
115
+ const tag = cipher.getAuthTag(); // 16 bytes for GCM
116
+ return `${CREDENTIAL_PREFIX}${Buffer.concat([iv, tag, encrypted]).toString("base64")}`;
117
+ }
118
+
119
+ function splitBlob(ciphertext: string): { iv: Buffer; tag: Buffer; body: Buffer } {
120
+ if (!ciphertext.startsWith(CREDENTIAL_PREFIX)) {
121
+ throw new Error(
122
+ `Value is not encrypted (missing '${CREDENTIAL_PREFIX}' prefix). ` +
123
+ "Pass plaintext through encryptCredential() first.",
124
+ );
125
+ }
126
+ const blob = Buffer.from(ciphertext.slice(CREDENTIAL_PREFIX.length), "base64");
127
+ if (blob.length < IV_LENGTH + TAG_LENGTH + 1) {
128
+ throw new Error("Encrypted blob is too short — data is corrupted.");
129
+ }
130
+ return {
131
+ iv: blob.subarray(0, IV_LENGTH),
132
+ tag: blob.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH),
133
+ body: blob.subarray(IV_LENGTH + TAG_LENGTH),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Decrypt a credential stored under `ref`.
139
+ *
140
+ * @param ciphertext - `enc:<base64(iv + tag + ciphertext)>` string.
141
+ * @param hexKey - 64-char hex key (32 bytes) from `FORGE_CREDENTIAL_KEY`.
142
+ * @param ref - The reference the blob was encrypted for.
143
+ * @returns The original plaintext.
144
+ * @throws If the blob is tampered with, the key is wrong, the value is
145
+ * not encrypted, or the blob belongs to a different reference.
146
+ */
147
+ export function decryptCredential(ciphertext: string, hexKey: string, ref: string): string {
148
+ const { iv, tag, body } = splitBlob(ciphertext);
149
+ const decipher = createDecipheriv(ALGORITHM, parseKey(hexKey), iv);
150
+ decipher.setAAD(Buffer.from(ref, "utf8"));
151
+ decipher.setAuthTag(tag);
152
+ try {
153
+ return decipher.update(body) + decipher.final("utf8");
154
+ } catch {
155
+ throw new CredentialDecryptError();
156
+ }
157
+ }
158
+
159
+ // ── The local keyring ──────────────────────────────────────────────────────
160
+
161
+ /** Where and with what a credential-store call reads or writes. */
162
+ export interface CredentialStoreOptions {
163
+ /** 64-char hex key; defaults to `FORGE_CREDENTIAL_KEY` of the environment. */
164
+ key?: string;
165
+ /** Store file; defaults to `FORGE_CREDENTIAL_STORE` or `<skaile home>/forge-credentials.json`. */
166
+ path?: string;
167
+ /**
168
+ * Environment `env:` references (and the key and store path variables) read
169
+ * from. Without a {@link policy} it defaults to `process.env`; with one it
170
+ * defaults to an empty environment, so a policy-bound host never reads the
171
+ * ambient process by accident.
172
+ */
173
+ env?: Record<string, string | undefined>;
174
+ /**
175
+ * Which references may be resolved. A refused reference is reported as an
176
+ * error and never read. Absent: no restriction.
177
+ */
178
+ policy?: CredentialPolicy;
179
+ }
180
+
181
+ /** The environment a call reads: the explicit one, else the process (unless a policy binds it). */
182
+ function envOf(options: CredentialStoreOptions): Record<string, string | undefined> {
183
+ return options.env ?? (options.policy ? {} : process.env);
184
+ }
185
+
186
+ interface StoreFile {
187
+ version: 1;
188
+ /** Credential reference → `enc:` blob. */
189
+ credentials: Record<string, string>;
190
+ }
191
+
192
+ /**
193
+ * Path of the local keyring file.
194
+ *
195
+ * Lives beside the user's `settings.json` so it follows `$SKAILE_HOME`, and is
196
+ * overridable per app with `FORGE_CREDENTIAL_STORE`.
197
+ */
198
+ export function credentialStorePath(options: CredentialStoreOptions = {}): string {
199
+ if (options.path) return options.path;
200
+ const fromEnv = envOf(options).FORGE_CREDENTIAL_STORE;
201
+ if (fromEnv) return fromEnv;
202
+ return join(dirname(globalSettingsPath()), STORE_FILENAME);
203
+ }
204
+
205
+ function requireKey(options: CredentialStoreOptions): string {
206
+ const key = options.key ?? envOf(options).FORGE_CREDENTIAL_KEY;
207
+ if (!key) {
208
+ throw new CredentialKeyError(
209
+ "missing",
210
+ "FORGE_CREDENTIAL_KEY is not set — the forge credential store cannot encrypt or " +
211
+ "decrypt stored credentials. Set it to a 64-char hex string (32 bytes).",
212
+ );
213
+ }
214
+ return key;
215
+ }
216
+
217
+ function readStore(path: string): StoreFile {
218
+ if (!existsSync(path)) return { version: 1, credentials: {} };
219
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
220
+ const credentials =
221
+ typeof parsed === "object" && parsed !== null
222
+ ? (parsed as { credentials?: unknown }).credentials
223
+ : undefined;
224
+ if (typeof credentials !== "object" || credentials === null || Array.isArray(credentials)) {
225
+ return { version: 1, credentials: {} };
226
+ }
227
+ return { version: 1, credentials: credentials as Record<string, string> };
228
+ }
229
+
230
+ function writeStore(path: string, store: StoreFile): void {
231
+ mkdirSync(dirname(path), { recursive: true });
232
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
233
+ }
234
+
235
+ /**
236
+ * Store `value` under `ref` in the local keyring, encrypted against `ref`.
237
+ *
238
+ * @param ref - A `keyring:<service>/<account>` reference.
239
+ * @param value - The secret.
240
+ * @returns The store path that was written.
241
+ */
242
+ export function storeCredential(
243
+ ref: string,
244
+ value: string,
245
+ options: CredentialStoreOptions = {},
246
+ ): string {
247
+ const parsed = parseCredentialRef(ref);
248
+ if (parsed?.form !== "keyring") {
249
+ throw new Error(
250
+ `'${ref}' is not a keyring reference — the local store only backs ` +
251
+ "keyring:<service>/<account>. env:/cmd:/platform: references live outside forge.",
252
+ );
253
+ }
254
+ const path = credentialStorePath(options);
255
+ const store = readStore(path);
256
+ store.credentials[ref] = encryptCredential(value, requireKey(options), ref);
257
+ writeStore(path, store);
258
+ return path;
259
+ }
260
+
261
+ /** Read and decrypt the credential stored under `ref`, if any. */
262
+ export function readStoredCredential(
263
+ ref: string,
264
+ options: CredentialStoreOptions = {},
265
+ ): string | undefined {
266
+ const blob = readStore(credentialStorePath(options)).credentials[ref];
267
+ if (!blob) return undefined;
268
+ return decryptCredential(blob, requireKey(options), ref);
269
+ }
270
+
271
+ /** Remove the credential stored under `ref`. Returns whether anything was removed. */
272
+ export function deleteStoredCredential(ref: string, options: CredentialStoreOptions = {}): boolean {
273
+ const path = credentialStorePath(options);
274
+ const store = readStore(path);
275
+ if (store.credentials[ref] === undefined) return false;
276
+ delete store.credentials[ref];
277
+ writeStore(path, store);
278
+ return true;
279
+ }
280
+
281
+ /** Every reference the local keyring holds a credential for. */
282
+ export function listStoredCredentialRefs(options: CredentialStoreOptions = {}): string[] {
283
+ return Object.keys(readStore(credentialStorePath(options)).credentials);
284
+ }
285
+
286
+ // ── Resolving references ───────────────────────────────────────────────────
287
+
288
+ /** Where a resolved credential came from, or why it is missing. */
289
+ export type CredentialSource = "env" | "keyring" | "cmd" | "platform" | "ambient" | "none";
290
+
291
+ /** Outcome of a resolution attempt. Never carries the secret in `error`. */
292
+ export interface ResolvedCredential {
293
+ /** The secret, when this layer could produce one. */
294
+ value?: string;
295
+ /** Which reference form answered — `ambient` means the cloud SDK chain signs. */
296
+ source: CredentialSource;
297
+ /** Why there is no value. Secret-free, safe to log and to show in the UI. */
298
+ error?: string;
299
+ }
300
+
301
+ /**
302
+ * Resolve one `credential.ref` to a value.
303
+ *
304
+ * `env:` and `keyring:` resolve here. `cmd:` and `platform:` are named as
305
+ * belonging to another layer rather than being silently treated as missing:
306
+ * the spawned runtime runs the command against its own `providers.yml`, and the
307
+ * control plane injects platform secrets into the session.
308
+ */
309
+ export function resolveCredentialRef(
310
+ ref: string,
311
+ options: CredentialStoreOptions = {},
312
+ ): ResolvedCredential {
313
+ const parsed = parseCredentialRef(ref);
314
+ if (!parsed) {
315
+ return {
316
+ source: "none",
317
+ error:
318
+ "credential.ref is not a reference (expected env:NAME, cmd:<shell>, " +
319
+ "platform:<configId> or keyring:<service>/<account>)",
320
+ };
321
+ }
322
+ const refusal = options.policy ? credentialRefRefusal(ref, options.policy) : undefined;
323
+ if (refusal) return { source: parsed.form, error: refusal };
324
+ switch (parsed.form) {
325
+ case "env": {
326
+ const value = envOf(options)[parsed.name];
327
+ if (!value) {
328
+ return { source: "env", error: `environment variable ${parsed.name} is not set` };
329
+ }
330
+ return { source: "env", value };
331
+ }
332
+ case "keyring": {
333
+ try {
334
+ const value = readStoredCredential(ref, options);
335
+ if (!value) {
336
+ return { source: "keyring", error: `no credential is stored for '${ref}'` };
337
+ }
338
+ return { source: "keyring", value };
339
+ } catch (err) {
340
+ return { source: "keyring", error: (err as Error).message };
341
+ }
342
+ }
343
+ case "cmd":
344
+ return {
345
+ source: "cmd",
346
+ error:
347
+ "cmd: references are run by the runtime that owns the shell — the spawned " +
348
+ "session resolves this reference itself",
349
+ };
350
+ case "platform":
351
+ return {
352
+ source: "platform",
353
+ error: "platform: references are injected by the control plane into the session",
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Resolve a registry entry's credential.
360
+ *
361
+ * `kind: none` needs nothing; `sigv4`, `google_adc` and `azure_entra` without a
362
+ * reference sign from the ambient AWS/Google/Azure chain, so there is no value
363
+ * for this layer to hand over and that is not an error.
364
+ */
365
+ export function resolveEntryCredential(
366
+ entry: AIProviderEntry,
367
+ options: CredentialStoreOptions = {},
368
+ ): ResolvedCredential {
369
+ const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : undefined;
370
+ if (refusal) return { source: "none", error: refusal };
371
+ const credential = entry.credential;
372
+ if (!credential || credential.kind === "none") return { source: "none" };
373
+ if (!credential.ref) {
374
+ if (
375
+ credential.kind === "sigv4" ||
376
+ credential.kind === "google_adc" ||
377
+ credential.kind === "azure_entra"
378
+ ) {
379
+ return { source: "ambient" };
380
+ }
381
+ return { source: "none", error: `credential.ref is missing for kind '${credential.kind}'` };
382
+ }
383
+ return resolveCredentialRef(credential.ref, options);
384
+ }
385
+
386
+ // ── Delivering credentials to a child process ──────────────────────────────
387
+
388
+ /** Prefix of the per-entry credential variable the harness adapters read. */
389
+ export const PROVIDER_CREDENTIAL_ENV_PREFIX = "SKAILE_AIP_" as const;
390
+
391
+ /** Suffix of the per-entry credential variable. */
392
+ export const PROVIDER_CREDENTIAL_ENV_SUFFIX = "_KEY" as const;
393
+
394
+ /**
395
+ * Canonical environment variable name carrying one entry's credential.
396
+ *
397
+ * `SKAILE_AIP_<ID>_KEY`, with every non-alphanumeric character in the id mapped
398
+ * to `_` (`my-gw.eu` → `SKAILE_AIP_MY_GW_EU_KEY`). The runtime's harness
399
+ * adapters write this *name* into the config they render (omp's `models.yml`
400
+ * `apiKey:`, Codex's `env_key`) and read the *value* from the child
401
+ * environment, which is what this function's callers fill in. The platform's
402
+ * secret-env allowlist is the `SKAILE_AIP_` prefix rule over the same names, so
403
+ * the transform must not drift.
404
+ */
405
+ export function providerCredentialEnvName(providerId: string): string {
406
+ const slug = providerId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
407
+ return `${PROVIDER_CREDENTIAL_ENV_PREFIX}${slug}${PROVIDER_CREDENTIAL_ENV_SUFFIX}`;
408
+ }
409
+
410
+ /** One entry's credential delivery outcome. */
411
+ export interface CredentialDelivery {
412
+ providerId: string;
413
+ /** Variable names this entry contributed to the child environment. */
414
+ names: string[];
415
+ source: CredentialSource;
416
+ /** Why nothing was delivered. Secret-free. */
417
+ error?: string;
418
+ }
419
+
420
+ /** Result of {@link registryCredentialEnv}. */
421
+ export interface RegistryCredentialEnv {
422
+ /** Variable name → secret. Pass into the child environment, never into a file. */
423
+ env: Record<string, string>;
424
+ /** Per-entry outcome, in registry order — for logging and settings UIs. */
425
+ deliveries: CredentialDelivery[];
426
+ }
427
+
428
+ /**
429
+ * Resolve every enabled entry's credential into a child-process environment.
430
+ *
431
+ * Each resolved entry contributes `SKAILE_AIP_<ID>_KEY`; an entry whose
432
+ * reference is `env:NAME` also re-exports `NAME`, so a harness that still reads
433
+ * its vendor's conventional variable keeps working without forge owning a
434
+ * vendor table. The first entry in registry order wins a name collision.
435
+ *
436
+ * Entries that resolve elsewhere (`cmd:` in the runtime, `platform:` in the
437
+ * control plane) or sign from an ambient chain contribute nothing and say so in
438
+ * {@link RegistryCredentialEnv.deliveries} — the spawned session resolves those
439
+ * itself from the same registry.
440
+ */
441
+ export function registryCredentialEnv(
442
+ registry: AIProviderRegistryView,
443
+ options: CredentialStoreOptions = {},
444
+ ): RegistryCredentialEnv {
445
+ const env: Record<string, string> = {};
446
+ const deliveries: CredentialDelivery[] = [];
447
+
448
+ for (const [providerId, entry] of registryEntries(registry)) {
449
+ if (!isEntryEnabled(entry)) continue;
450
+ const resolved = resolveEntryCredential(entry, options);
451
+ const names: string[] = [];
452
+ if (resolved.value) {
453
+ const canonical = providerCredentialEnvName(providerId);
454
+ if (env[canonical] === undefined) {
455
+ env[canonical] = resolved.value;
456
+ names.push(canonical);
457
+ }
458
+ const parsed = entry.credential?.ref ? parseCredentialRef(entry.credential.ref) : undefined;
459
+ if (parsed?.form === "env" && env[parsed.name] === undefined) {
460
+ env[parsed.name] = resolved.value;
461
+ names.push(parsed.name);
462
+ }
463
+ }
464
+ deliveries.push({
465
+ providerId,
466
+ names,
467
+ source: resolved.source,
468
+ ...(resolved.error ? { error: resolved.error } : {}),
469
+ });
470
+ }
471
+
472
+ return { env, deliveries };
473
+ }