@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,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential policy — which references a host lets the credential layer touch.
|
|
3
|
+
*
|
|
4
|
+
* A reference names where a secret lives (`env:NAME`, `keyring:s/a`, `cmd:…`,
|
|
5
|
+
* `platform:…`). A host that stores provider entries an administrator typed,
|
|
6
|
+
* and runs next to its own secrets (a Directus token, an encryption key), must
|
|
7
|
+
* decide up front which references may be resolved at all. The decision is
|
|
8
|
+
* made *before* a reference is read: a refused reference is never resolved,
|
|
9
|
+
* never looked up in an environment and never passed on to a child process.
|
|
10
|
+
*
|
|
11
|
+
* The policy covers every place an entry names an environment variable:
|
|
12
|
+
*
|
|
13
|
+
* - `credential.ref` — by form, and for `env:` by variable name;
|
|
14
|
+
* - `${NAME}` interpolation tokens in `base_url`, `headers` and `query_params`,
|
|
15
|
+
* which a spawned runtime expands from its environment. Only bare variable
|
|
16
|
+
* names the `envNames` allow-list accepts pass; any other token is refused.
|
|
17
|
+
*
|
|
18
|
+
* No policy means no restriction: existing hosts keep their current reference
|
|
19
|
+
* handling. A policy is opt-in per host and states the host's rule explicitly.
|
|
20
|
+
*/
|
|
21
|
+
import type { AIProviderEntry, AIProviderRegistryView } from "../shared/provider-registry.js";
|
|
22
|
+
/** The four credential reference forms. */
|
|
23
|
+
export type CredentialRefForm = "env" | "cmd" | "platform" | "keyring";
|
|
24
|
+
/** A host's rule for which credential references may be resolved. */
|
|
25
|
+
export interface CredentialPolicy {
|
|
26
|
+
/** Reference forms that may be resolved. Absent: every form. */
|
|
27
|
+
forms?: readonly CredentialRefForm[];
|
|
28
|
+
/**
|
|
29
|
+
* Environment variable names a reference or interpolation token may name:
|
|
30
|
+
* exact strings, or expressions that must match the whole name. Absent:
|
|
31
|
+
* any name.
|
|
32
|
+
*/
|
|
33
|
+
envNames?: ReadonlyArray<string | RegExp>;
|
|
34
|
+
}
|
|
35
|
+
/** An entry the policy refused, and why. The reason is secret-free. */
|
|
36
|
+
export interface PolicyRefusal {
|
|
37
|
+
providerId: string;
|
|
38
|
+
error: string;
|
|
39
|
+
}
|
|
40
|
+
/** Whether `name` passes the policy's `envNames` allow-list. */
|
|
41
|
+
export declare function isEnvNameAllowed(name: string, policy: CredentialPolicy): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The reason a credential reference is refused, or `undefined` when the policy
|
|
44
|
+
* lets it through. A string that is not a reference is not this function's
|
|
45
|
+
* concern: it resolves to nothing anyway.
|
|
46
|
+
*/
|
|
47
|
+
export declare function credentialRefRefusal(ref: string, policy: CredentialPolicy): string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* The reason an entry is refused, or `undefined`: its credential reference and
|
|
50
|
+
* every environment name it interpolates are checked, nothing is resolved.
|
|
51
|
+
*/
|
|
52
|
+
export declare function entryPolicyRefusal(entry: AIProviderEntry, policy: CredentialPolicy): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* The registry without the entries the policy refuses, as a plain entry map in
|
|
55
|
+
* registry order, plus what was refused. Nothing is resolved.
|
|
56
|
+
*/
|
|
57
|
+
export declare function applyCredentialPolicy(registry: AIProviderRegistryView, policy: CredentialPolicy): {
|
|
58
|
+
registry: Record<string, AIProviderEntry>;
|
|
59
|
+
refused: PolicyRefusal[];
|
|
60
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseCredentialRef, registryEntries } from "../shared/provider-registry.js";
|
|
2
|
+
const BARE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
+
const INTERPOLATION_TOKEN = /\$\{([^}]*)\}/g;
|
|
4
|
+
export function isEnvNameAllowed(name, policy) {
|
|
5
|
+
if (!policy.envNames) return true;
|
|
6
|
+
return policy.envNames.some((rule) => {
|
|
7
|
+
if (typeof rule === "string") return rule === name;
|
|
8
|
+
const match = new RegExp(rule.source, rule.flags.replace(/[gy]/g, "")).exec(name);
|
|
9
|
+
return match !== null && match.index === 0 && match[0] === name;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function credentialRefRefusal(ref, policy) {
|
|
13
|
+
const parsed = parseCredentialRef(ref);
|
|
14
|
+
if (!parsed) return void 0;
|
|
15
|
+
if (policy.forms && !policy.forms.includes(parsed.form)) {
|
|
16
|
+
return `credential references of the form '${parsed.form}:' are not allowed here`;
|
|
17
|
+
}
|
|
18
|
+
if (parsed.form === "env" && !isEnvNameAllowed(parsed.name, policy)) {
|
|
19
|
+
return `environment variable ${parsed.name} is not allowed here`;
|
|
20
|
+
}
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
function interpolationRefusal(field, value, policy) {
|
|
24
|
+
for (const match of value.matchAll(INTERPOLATION_TOKEN)) {
|
|
25
|
+
const name = match[1].trim();
|
|
26
|
+
if (!BARE_ENV_NAME.test(name) || !isEnvNameAllowed(name, policy)) {
|
|
27
|
+
return `${field} interpolates '\${${name}}', which is not allowed here`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return void 0;
|
|
31
|
+
}
|
|
32
|
+
export function entryPolicyRefusal(entry, policy) {
|
|
33
|
+
const ref = entry.credential?.ref;
|
|
34
|
+
if (ref !== void 0) {
|
|
35
|
+
const refusal = credentialRefRefusal(ref, policy);
|
|
36
|
+
if (refusal) return refusal;
|
|
37
|
+
}
|
|
38
|
+
const fields = [
|
|
39
|
+
["base_url", entry.base_url],
|
|
40
|
+
...Object.entries(entry.headers ?? {}).map(([name, value]) => [
|
|
41
|
+
`headers.${name}`,
|
|
42
|
+
value
|
|
43
|
+
]),
|
|
44
|
+
...Object.entries(entry.query_params ?? {}).map(
|
|
45
|
+
([name, value]) => [`query_params.${name}`, value]
|
|
46
|
+
)
|
|
47
|
+
];
|
|
48
|
+
for (const [field, value] of fields) {
|
|
49
|
+
if (typeof value !== "string") continue;
|
|
50
|
+
const refusal = interpolationRefusal(field, value, policy);
|
|
51
|
+
if (refusal) return refusal;
|
|
52
|
+
}
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
export function applyCredentialPolicy(registry, policy) {
|
|
56
|
+
const allowed = {};
|
|
57
|
+
const refused = [];
|
|
58
|
+
for (const [providerId, entry] of registryEntries(registry)) {
|
|
59
|
+
const error = entryPolicyRefusal(entry, policy);
|
|
60
|
+
if (error) refused.push({ providerId, error });
|
|
61
|
+
else allowed[providerId] = entry;
|
|
62
|
+
}
|
|
63
|
+
return { registry: allowed, refused };
|
|
64
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
import type { AIProviderEntry, AIProviderRegistryView } from "../shared/provider-registry.js";
|
|
30
|
+
import { type CredentialPolicy } from "./credential-policy.js";
|
|
31
|
+
export declare const CREDENTIAL_PREFIX: "enc:";
|
|
32
|
+
/**
|
|
33
|
+
* The credential key is missing or unusable. Only operations that need the
|
|
34
|
+
* encrypted store raise it; `env:` and `cmd:` references never touch the key.
|
|
35
|
+
* `statusCode` is 503 so a host can map it straight to an HTTP answer.
|
|
36
|
+
*/
|
|
37
|
+
export declare class CredentialKeyError extends Error {
|
|
38
|
+
readonly code: "missing" | "invalid";
|
|
39
|
+
readonly statusCode: 503;
|
|
40
|
+
constructor(code: "missing" | "invalid", message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A stored blob did not authenticate: the key is wrong, the data was altered,
|
|
44
|
+
* or the blob was encrypted for a different reference (the reference is the
|
|
45
|
+
* GCM additional data). The message names no secret and no plaintext.
|
|
46
|
+
*/
|
|
47
|
+
export declare class CredentialDecryptError extends Error {
|
|
48
|
+
constructor();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Encrypt a credential for storage under one reference.
|
|
52
|
+
*
|
|
53
|
+
* @param plaintext - The secret. Must not be empty.
|
|
54
|
+
* @param hexKey - 64-char hex key (32 bytes) from `FORGE_CREDENTIAL_KEY`.
|
|
55
|
+
* @param ref - The credential reference this blob belongs to; bound into
|
|
56
|
+
* the ciphertext as GCM additional data.
|
|
57
|
+
* @returns `enc:<base64(iv + tag + ciphertext)>`
|
|
58
|
+
*/
|
|
59
|
+
export declare function encryptCredential(plaintext: string, hexKey: string, ref: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt a credential stored under `ref`.
|
|
62
|
+
*
|
|
63
|
+
* @param ciphertext - `enc:<base64(iv + tag + ciphertext)>` string.
|
|
64
|
+
* @param hexKey - 64-char hex key (32 bytes) from `FORGE_CREDENTIAL_KEY`.
|
|
65
|
+
* @param ref - The reference the blob was encrypted for.
|
|
66
|
+
* @returns The original plaintext.
|
|
67
|
+
* @throws If the blob is tampered with, the key is wrong, the value is
|
|
68
|
+
* not encrypted, or the blob belongs to a different reference.
|
|
69
|
+
*/
|
|
70
|
+
export declare function decryptCredential(ciphertext: string, hexKey: string, ref: string): string;
|
|
71
|
+
/** Where and with what a credential-store call reads or writes. */
|
|
72
|
+
export interface CredentialStoreOptions {
|
|
73
|
+
/** 64-char hex key; defaults to `FORGE_CREDENTIAL_KEY` of the environment. */
|
|
74
|
+
key?: string;
|
|
75
|
+
/** Store file; defaults to `FORGE_CREDENTIAL_STORE` or `<skaile home>/forge-credentials.json`. */
|
|
76
|
+
path?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Environment `env:` references (and the key and store path variables) read
|
|
79
|
+
* from. Without a {@link policy} it defaults to `process.env`; with one it
|
|
80
|
+
* defaults to an empty environment, so a policy-bound host never reads the
|
|
81
|
+
* ambient process by accident.
|
|
82
|
+
*/
|
|
83
|
+
env?: Record<string, string | undefined>;
|
|
84
|
+
/**
|
|
85
|
+
* Which references may be resolved. A refused reference is reported as an
|
|
86
|
+
* error and never read. Absent: no restriction.
|
|
87
|
+
*/
|
|
88
|
+
policy?: CredentialPolicy;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Path of the local keyring file.
|
|
92
|
+
*
|
|
93
|
+
* Lives beside the user's `settings.json` so it follows `$SKAILE_HOME`, and is
|
|
94
|
+
* overridable per app with `FORGE_CREDENTIAL_STORE`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function credentialStorePath(options?: CredentialStoreOptions): string;
|
|
97
|
+
/**
|
|
98
|
+
* Store `value` under `ref` in the local keyring, encrypted against `ref`.
|
|
99
|
+
*
|
|
100
|
+
* @param ref - A `keyring:<service>/<account>` reference.
|
|
101
|
+
* @param value - The secret.
|
|
102
|
+
* @returns The store path that was written.
|
|
103
|
+
*/
|
|
104
|
+
export declare function storeCredential(ref: string, value: string, options?: CredentialStoreOptions): string;
|
|
105
|
+
/** Read and decrypt the credential stored under `ref`, if any. */
|
|
106
|
+
export declare function readStoredCredential(ref: string, options?: CredentialStoreOptions): string | undefined;
|
|
107
|
+
/** Remove the credential stored under `ref`. Returns whether anything was removed. */
|
|
108
|
+
export declare function deleteStoredCredential(ref: string, options?: CredentialStoreOptions): boolean;
|
|
109
|
+
/** Every reference the local keyring holds a credential for. */
|
|
110
|
+
export declare function listStoredCredentialRefs(options?: CredentialStoreOptions): string[];
|
|
111
|
+
/** Where a resolved credential came from, or why it is missing. */
|
|
112
|
+
export type CredentialSource = "env" | "keyring" | "cmd" | "platform" | "ambient" | "none";
|
|
113
|
+
/** Outcome of a resolution attempt. Never carries the secret in `error`. */
|
|
114
|
+
export interface ResolvedCredential {
|
|
115
|
+
/** The secret, when this layer could produce one. */
|
|
116
|
+
value?: string;
|
|
117
|
+
/** Which reference form answered — `ambient` means the cloud SDK chain signs. */
|
|
118
|
+
source: CredentialSource;
|
|
119
|
+
/** Why there is no value. Secret-free, safe to log and to show in the UI. */
|
|
120
|
+
error?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve one `credential.ref` to a value.
|
|
124
|
+
*
|
|
125
|
+
* `env:` and `keyring:` resolve here. `cmd:` and `platform:` are named as
|
|
126
|
+
* belonging to another layer rather than being silently treated as missing:
|
|
127
|
+
* the spawned runtime runs the command against its own `providers.yml`, and the
|
|
128
|
+
* control plane injects platform secrets into the session.
|
|
129
|
+
*/
|
|
130
|
+
export declare function resolveCredentialRef(ref: string, options?: CredentialStoreOptions): ResolvedCredential;
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a registry entry's credential.
|
|
133
|
+
*
|
|
134
|
+
* `kind: none` needs nothing; `sigv4`, `google_adc` and `azure_entra` without a
|
|
135
|
+
* reference sign from the ambient AWS/Google/Azure chain, so there is no value
|
|
136
|
+
* for this layer to hand over and that is not an error.
|
|
137
|
+
*/
|
|
138
|
+
export declare function resolveEntryCredential(entry: AIProviderEntry, options?: CredentialStoreOptions): ResolvedCredential;
|
|
139
|
+
/** Prefix of the per-entry credential variable the harness adapters read. */
|
|
140
|
+
export declare const PROVIDER_CREDENTIAL_ENV_PREFIX: "SKAILE_AIP_";
|
|
141
|
+
/** Suffix of the per-entry credential variable. */
|
|
142
|
+
export declare const PROVIDER_CREDENTIAL_ENV_SUFFIX: "_KEY";
|
|
143
|
+
/**
|
|
144
|
+
* Canonical environment variable name carrying one entry's credential.
|
|
145
|
+
*
|
|
146
|
+
* `SKAILE_AIP_<ID>_KEY`, with every non-alphanumeric character in the id mapped
|
|
147
|
+
* to `_` (`my-gw.eu` → `SKAILE_AIP_MY_GW_EU_KEY`). The runtime's harness
|
|
148
|
+
* adapters write this *name* into the config they render (omp's `models.yml`
|
|
149
|
+
* `apiKey:`, Codex's `env_key`) and read the *value* from the child
|
|
150
|
+
* environment, which is what this function's callers fill in. The platform's
|
|
151
|
+
* secret-env allowlist is the `SKAILE_AIP_` prefix rule over the same names, so
|
|
152
|
+
* the transform must not drift.
|
|
153
|
+
*/
|
|
154
|
+
export declare function providerCredentialEnvName(providerId: string): string;
|
|
155
|
+
/** One entry's credential delivery outcome. */
|
|
156
|
+
export interface CredentialDelivery {
|
|
157
|
+
providerId: string;
|
|
158
|
+
/** Variable names this entry contributed to the child environment. */
|
|
159
|
+
names: string[];
|
|
160
|
+
source: CredentialSource;
|
|
161
|
+
/** Why nothing was delivered. Secret-free. */
|
|
162
|
+
error?: string;
|
|
163
|
+
}
|
|
164
|
+
/** Result of {@link registryCredentialEnv}. */
|
|
165
|
+
export interface RegistryCredentialEnv {
|
|
166
|
+
/** Variable name → secret. Pass into the child environment, never into a file. */
|
|
167
|
+
env: Record<string, string>;
|
|
168
|
+
/** Per-entry outcome, in registry order — for logging and settings UIs. */
|
|
169
|
+
deliveries: CredentialDelivery[];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolve every enabled entry's credential into a child-process environment.
|
|
173
|
+
*
|
|
174
|
+
* Each resolved entry contributes `SKAILE_AIP_<ID>_KEY`; an entry whose
|
|
175
|
+
* reference is `env:NAME` also re-exports `NAME`, so a harness that still reads
|
|
176
|
+
* its vendor's conventional variable keeps working without forge owning a
|
|
177
|
+
* vendor table. The first entry in registry order wins a name collision.
|
|
178
|
+
*
|
|
179
|
+
* Entries that resolve elsewhere (`cmd:` in the runtime, `platform:` in the
|
|
180
|
+
* control plane) or sign from an ambient chain contribute nothing and say so in
|
|
181
|
+
* {@link RegistryCredentialEnv.deliveries} — the spawned session resolves those
|
|
182
|
+
* itself from the same registry.
|
|
183
|
+
*/
|
|
184
|
+
export declare function registryCredentialEnv(registry: AIProviderRegistryView, options?: CredentialStoreOptions): RegistryCredentialEnv;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { globalSettingsPath } from "@skaile/workspaces/sdk/core";
|
|
5
|
+
import { isEntryEnabled, parseCredentialRef, registryEntries } from "../shared/provider-registry.js";
|
|
6
|
+
import {
|
|
7
|
+
credentialRefRefusal,
|
|
8
|
+
entryPolicyRefusal
|
|
9
|
+
} from "./credential-policy.js";
|
|
10
|
+
export const CREDENTIAL_PREFIX = "enc:";
|
|
11
|
+
const STORE_FILENAME = "forge-credentials.json";
|
|
12
|
+
const ALGORITHM = "aes-256-gcm";
|
|
13
|
+
const IV_LENGTH = 16;
|
|
14
|
+
const TAG_LENGTH = 16;
|
|
15
|
+
export class CredentialKeyError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
statusCode = 503;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "CredentialKeyError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class CredentialDecryptError extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super(
|
|
27
|
+
"Encrypted credential could not be authenticated: wrong key, altered data, or it belongs to a different reference."
|
|
28
|
+
);
|
|
29
|
+
this.name = "CredentialDecryptError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function parseKey(key) {
|
|
33
|
+
if (key.length !== 64) {
|
|
34
|
+
throw new CredentialKeyError(
|
|
35
|
+
"invalid",
|
|
36
|
+
`FORGE_CREDENTIAL_KEY must be a 64-character hex string (32 bytes). Got ${key.length} chars.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
40
|
+
throw new CredentialKeyError(
|
|
41
|
+
"invalid",
|
|
42
|
+
"FORGE_CREDENTIAL_KEY contains non-hex characters. Provide a 64-char hex string."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return Buffer.from(key, "hex");
|
|
46
|
+
}
|
|
47
|
+
export function encryptCredential(plaintext, hexKey, ref) {
|
|
48
|
+
if (!plaintext) throw new Error("plaintext must not be empty");
|
|
49
|
+
if (!ref) throw new Error("ref must not be empty \u2014 credentials are encrypted per reference");
|
|
50
|
+
const key = parseKey(hexKey);
|
|
51
|
+
const iv = randomBytes(IV_LENGTH);
|
|
52
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
53
|
+
cipher.setAAD(Buffer.from(ref, "utf8"));
|
|
54
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
55
|
+
const tag = cipher.getAuthTag();
|
|
56
|
+
return `${CREDENTIAL_PREFIX}${Buffer.concat([iv, tag, encrypted]).toString("base64")}`;
|
|
57
|
+
}
|
|
58
|
+
function splitBlob(ciphertext) {
|
|
59
|
+
if (!ciphertext.startsWith(CREDENTIAL_PREFIX)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Value is not encrypted (missing '${CREDENTIAL_PREFIX}' prefix). Pass plaintext through encryptCredential() first.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const blob = Buffer.from(ciphertext.slice(CREDENTIAL_PREFIX.length), "base64");
|
|
65
|
+
if (blob.length < IV_LENGTH + TAG_LENGTH + 1) {
|
|
66
|
+
throw new Error("Encrypted blob is too short \u2014 data is corrupted.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
iv: blob.subarray(0, IV_LENGTH),
|
|
70
|
+
tag: blob.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH),
|
|
71
|
+
body: blob.subarray(IV_LENGTH + TAG_LENGTH)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function decryptCredential(ciphertext, hexKey, ref) {
|
|
75
|
+
const { iv, tag, body } = splitBlob(ciphertext);
|
|
76
|
+
const decipher = createDecipheriv(ALGORITHM, parseKey(hexKey), iv);
|
|
77
|
+
decipher.setAAD(Buffer.from(ref, "utf8"));
|
|
78
|
+
decipher.setAuthTag(tag);
|
|
79
|
+
try {
|
|
80
|
+
return decipher.update(body) + decipher.final("utf8");
|
|
81
|
+
} catch {
|
|
82
|
+
throw new CredentialDecryptError();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function envOf(options) {
|
|
86
|
+
return options.env ?? (options.policy ? {} : process.env);
|
|
87
|
+
}
|
|
88
|
+
export function credentialStorePath(options = {}) {
|
|
89
|
+
if (options.path) return options.path;
|
|
90
|
+
const fromEnv = envOf(options).FORGE_CREDENTIAL_STORE;
|
|
91
|
+
if (fromEnv) return fromEnv;
|
|
92
|
+
return join(dirname(globalSettingsPath()), STORE_FILENAME);
|
|
93
|
+
}
|
|
94
|
+
function requireKey(options) {
|
|
95
|
+
const key = options.key ?? envOf(options).FORGE_CREDENTIAL_KEY;
|
|
96
|
+
if (!key) {
|
|
97
|
+
throw new CredentialKeyError(
|
|
98
|
+
"missing",
|
|
99
|
+
"FORGE_CREDENTIAL_KEY is not set \u2014 the forge credential store cannot encrypt or decrypt stored credentials. Set it to a 64-char hex string (32 bytes)."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return key;
|
|
103
|
+
}
|
|
104
|
+
function readStore(path) {
|
|
105
|
+
if (!existsSync(path)) return { version: 1, credentials: {} };
|
|
106
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
107
|
+
const credentials = typeof parsed === "object" && parsed !== null ? parsed.credentials : void 0;
|
|
108
|
+
if (typeof credentials !== "object" || credentials === null || Array.isArray(credentials)) {
|
|
109
|
+
return { version: 1, credentials: {} };
|
|
110
|
+
}
|
|
111
|
+
return { version: 1, credentials };
|
|
112
|
+
}
|
|
113
|
+
function writeStore(path, store) {
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
writeFileSync(path, `${JSON.stringify(store, null, 2)}
|
|
116
|
+
`, { encoding: "utf8", mode: 384 });
|
|
117
|
+
}
|
|
118
|
+
export function storeCredential(ref, value, options = {}) {
|
|
119
|
+
const parsed = parseCredentialRef(ref);
|
|
120
|
+
if (parsed?.form !== "keyring") {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`'${ref}' is not a keyring reference \u2014 the local store only backs keyring:<service>/<account>. env:/cmd:/platform: references live outside forge.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const path = credentialStorePath(options);
|
|
126
|
+
const store = readStore(path);
|
|
127
|
+
store.credentials[ref] = encryptCredential(value, requireKey(options), ref);
|
|
128
|
+
writeStore(path, store);
|
|
129
|
+
return path;
|
|
130
|
+
}
|
|
131
|
+
export function readStoredCredential(ref, options = {}) {
|
|
132
|
+
const blob = readStore(credentialStorePath(options)).credentials[ref];
|
|
133
|
+
if (!blob) return void 0;
|
|
134
|
+
return decryptCredential(blob, requireKey(options), ref);
|
|
135
|
+
}
|
|
136
|
+
export function deleteStoredCredential(ref, options = {}) {
|
|
137
|
+
const path = credentialStorePath(options);
|
|
138
|
+
const store = readStore(path);
|
|
139
|
+
if (store.credentials[ref] === void 0) return false;
|
|
140
|
+
delete store.credentials[ref];
|
|
141
|
+
writeStore(path, store);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
export function listStoredCredentialRefs(options = {}) {
|
|
145
|
+
return Object.keys(readStore(credentialStorePath(options)).credentials);
|
|
146
|
+
}
|
|
147
|
+
export function resolveCredentialRef(ref, options = {}) {
|
|
148
|
+
const parsed = parseCredentialRef(ref);
|
|
149
|
+
if (!parsed) {
|
|
150
|
+
return {
|
|
151
|
+
source: "none",
|
|
152
|
+
error: "credential.ref is not a reference (expected env:NAME, cmd:<shell>, platform:<configId> or keyring:<service>/<account>)"
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const refusal = options.policy ? credentialRefRefusal(ref, options.policy) : void 0;
|
|
156
|
+
if (refusal) return { source: parsed.form, error: refusal };
|
|
157
|
+
switch (parsed.form) {
|
|
158
|
+
case "env": {
|
|
159
|
+
const value = envOf(options)[parsed.name];
|
|
160
|
+
if (!value) {
|
|
161
|
+
return { source: "env", error: `environment variable ${parsed.name} is not set` };
|
|
162
|
+
}
|
|
163
|
+
return { source: "env", value };
|
|
164
|
+
}
|
|
165
|
+
case "keyring": {
|
|
166
|
+
try {
|
|
167
|
+
const value = readStoredCredential(ref, options);
|
|
168
|
+
if (!value) {
|
|
169
|
+
return { source: "keyring", error: `no credential is stored for '${ref}'` };
|
|
170
|
+
}
|
|
171
|
+
return { source: "keyring", value };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return { source: "keyring", error: err.message };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
case "cmd":
|
|
177
|
+
return {
|
|
178
|
+
source: "cmd",
|
|
179
|
+
error: "cmd: references are run by the runtime that owns the shell \u2014 the spawned session resolves this reference itself"
|
|
180
|
+
};
|
|
181
|
+
case "platform":
|
|
182
|
+
return {
|
|
183
|
+
source: "platform",
|
|
184
|
+
error: "platform: references are injected by the control plane into the session"
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function resolveEntryCredential(entry, options = {}) {
|
|
189
|
+
const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : void 0;
|
|
190
|
+
if (refusal) return { source: "none", error: refusal };
|
|
191
|
+
const credential = entry.credential;
|
|
192
|
+
if (!credential || credential.kind === "none") return { source: "none" };
|
|
193
|
+
if (!credential.ref) {
|
|
194
|
+
if (credential.kind === "sigv4" || credential.kind === "google_adc" || credential.kind === "azure_entra") {
|
|
195
|
+
return { source: "ambient" };
|
|
196
|
+
}
|
|
197
|
+
return { source: "none", error: `credential.ref is missing for kind '${credential.kind}'` };
|
|
198
|
+
}
|
|
199
|
+
return resolveCredentialRef(credential.ref, options);
|
|
200
|
+
}
|
|
201
|
+
export const PROVIDER_CREDENTIAL_ENV_PREFIX = "SKAILE_AIP_";
|
|
202
|
+
export const PROVIDER_CREDENTIAL_ENV_SUFFIX = "_KEY";
|
|
203
|
+
export function providerCredentialEnvName(providerId) {
|
|
204
|
+
const slug = providerId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
205
|
+
return `${PROVIDER_CREDENTIAL_ENV_PREFIX}${slug}${PROVIDER_CREDENTIAL_ENV_SUFFIX}`;
|
|
206
|
+
}
|
|
207
|
+
export function registryCredentialEnv(registry, options = {}) {
|
|
208
|
+
const env = {};
|
|
209
|
+
const deliveries = [];
|
|
210
|
+
for (const [providerId, entry] of registryEntries(registry)) {
|
|
211
|
+
if (!isEntryEnabled(entry)) continue;
|
|
212
|
+
const resolved = resolveEntryCredential(entry, options);
|
|
213
|
+
const names = [];
|
|
214
|
+
if (resolved.value) {
|
|
215
|
+
const canonical = providerCredentialEnvName(providerId);
|
|
216
|
+
if (env[canonical] === void 0) {
|
|
217
|
+
env[canonical] = resolved.value;
|
|
218
|
+
names.push(canonical);
|
|
219
|
+
}
|
|
220
|
+
const parsed = entry.credential?.ref ? parseCredentialRef(entry.credential.ref) : void 0;
|
|
221
|
+
if (parsed?.form === "env" && env[parsed.name] === void 0) {
|
|
222
|
+
env[parsed.name] = resolved.value;
|
|
223
|
+
names.push(parsed.name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
deliveries.push({
|
|
227
|
+
providerId,
|
|
228
|
+
names,
|
|
229
|
+
source: resolved.source,
|
|
230
|
+
...resolved.error ? { error: resolved.error } : {}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { env, deliveries };
|
|
234
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered `.env` files — the server half of the settings modal's Providers
|
|
3
|
+
* tab (credential picker, Enter a key, environment disclosure).
|
|
4
|
+
*
|
|
5
|
+
* A registry entry references its secret as `env:NAME`; the host resolves that
|
|
6
|
+
* name against a layered environment where the nearest layer wins:
|
|
7
|
+
* `process` (boot env) < `instance` (`$FORGE_HOME/.env`) < `workspace`
|
|
8
|
+
* (`<workspace root>/.env`). This module reads and edits those files and builds
|
|
9
|
+
* the document the browser sees — names, masked hints and lengths, never
|
|
10
|
+
* values.
|
|
11
|
+
*
|
|
12
|
+
* The vocabulary (layer ids, name rules, document shapes) lives in the
|
|
13
|
+
* browser-safe `utils/env-vars.ts` and is re-exported here, so client and
|
|
14
|
+
* server refuse the same names from one source.
|
|
15
|
+
*/
|
|
16
|
+
import type { AIProviderRegistryView } from "../shared/provider-registry.js";
|
|
17
|
+
import { type EnvLayerId, type EnvSettingsDocument, type EnvSettingsUpdate } from "../shared/env-vars.js";
|
|
18
|
+
export * from "../shared/env-vars.js";
|
|
19
|
+
/**
|
|
20
|
+
* Parse `.env` text: blank and `#` lines skipped, optional `export ` prefix,
|
|
21
|
+
* single-quoted values literal, double-quoted values unescape `\n`, `\"`, `\\`,
|
|
22
|
+
* unquoted values lose an inline ` #` comment. A later duplicate wins.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseDotenv(text: string): Record<string, string>;
|
|
25
|
+
/** Parsed `.env` file; `{}` when it is missing or unreadable. */
|
|
26
|
+
export declare function readDotenvFile(path: string): Record<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* Apply `patch` to the `.env` file at `path`, preserving every line it does not
|
|
29
|
+
* touch. A patched key's first `KEY=` / `export KEY=` line is rewritten in
|
|
30
|
+
* place and its later duplicates dropped; `null` deletes the key; keys the file
|
|
31
|
+
* lacks are appended. The file is created (parents included) when missing and
|
|
32
|
+
* always left at mode 0600.
|
|
33
|
+
*/
|
|
34
|
+
export declare function updateDotenvFile(path: string, patch: Record<string, string | null>): void;
|
|
35
|
+
/**
|
|
36
|
+
* Make sure `<dir>/.gitignore` ignores `.env`: appends the line unless an
|
|
37
|
+
* equivalent pattern is already present, creating the file when missing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ensureDotenvIgnored(dir: string): void;
|
|
40
|
+
/** Input of {@link buildEnvSettingsDocument}. */
|
|
41
|
+
export interface EnvSettingsDocumentInput {
|
|
42
|
+
scope: "instance" | "workspace";
|
|
43
|
+
editable: boolean;
|
|
44
|
+
/** The layers visible from this scope; absent layers are ignored. */
|
|
45
|
+
layers: Partial<Record<EnvLayerId, Record<string, string | undefined>>>;
|
|
46
|
+
/** Registry whose `env:` references are reported (`referencedBy`, `missing`). */
|
|
47
|
+
registry: AIProviderRegistryView;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The browser's view of a scope's environment. Lists every name an editable
|
|
51
|
+
* layer (instance, workspace) holds, every name a registry `env:` reference
|
|
52
|
+
* uses, and every catalog preset's conventional key name that some layer sets
|
|
53
|
+
* — so the credential picker can tell a set `OPENROUTER_API_KEY` from an unset
|
|
54
|
+
* one. Other process variables stay unlisted. Values never leave: each row
|
|
55
|
+
* carries only a hint and a length.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildEnvSettingsDocument(input: EnvSettingsDocumentInput): EnvSettingsDocument;
|
|
58
|
+
/**
|
|
59
|
+
* Validate a `PUT <environmentUrl>` body. Returns the normalized update, or a
|
|
60
|
+
* refusal string suitable as an HTTP 400 status message.
|
|
61
|
+
*/
|
|
62
|
+
export declare function validateEnvSettingsUpdate(body: unknown): EnvSettingsUpdate | string;
|