@hasna/switcher 0.1.3 → 0.1.4
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/README.md +15 -6
- package/dist/cli/index.js +147 -62
- package/dist/credentials.d.ts +71 -8
- package/dist/domain.d.ts +1 -1
- package/dist/index.js +45 -6
- package/dist/mcp/index.js +47 -8
- package/dist/sdk.d.ts +10 -1
- package/dist/sdk.js +45 -6
- package/dist/serve/index.js +2 -2
- package/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ title: "Switcher"
|
|
|
4
4
|
type: "package-documentation"
|
|
5
5
|
owner: "codex-fixer"
|
|
6
6
|
created_at: "2026-09-05T12:50:21.698672Z"
|
|
7
|
-
updated_at: "2026-09-
|
|
7
|
+
updated_at: "2026-09-07T06:18:32.384233+00:00"
|
|
8
8
|
status: "active"
|
|
9
9
|
source_task: "01a07181-ca8d-70c1-99a2-b276dc5770f3"
|
|
10
10
|
---
|
|
@@ -69,18 +69,27 @@ An interactive terminal can choose or search the catalog when `--model` is omitt
|
|
|
69
69
|
|
|
70
70
|
When no remote API configuration is present, each CLI invocation starts an authenticated loopback API on an allocated port, stores SQLite data in `~/.hasna/switcher`, and closes its own listener on completion. Its random operator key remains in memory. Use `HASNA_SWITCHER_HOME` to choose another owner-only home, `HASNA_SWITCHER_SQLITE_PATH` for an explicit database, or `HASNA_SWITCHER_DATABASE_URL` for PostgreSQL. API and SDK data access remains HTTP.
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
Remote API configuration is resolved through Contracts, including canonical credential stores and the default gateway URL. Invalid, unavailable or unauthorized remote services fail without opening local SQLite.
|
|
73
73
|
|
|
74
74
|
The registry contains DeepSeek, OpenRouter, Anthropic, OpenAI, xAI, Ollama, LM Studio, Groq, Cerebras, Mistral, Together AI, Fireworks, Moonshot/Kimi, DashScope, Z.AI, MiniMax, SiliconFlow, and generic protocol entries. `switcher providers presets ID` exposes documented routes, aliases and limitations; this is not a claim that every combination has passed live tests. Remaining adapters and acceptance gates are tracked in [TODOS.md](TODOS.md).
|
|
75
75
|
|
|
76
|
+
## Canonical API configuration
|
|
77
|
+
|
|
78
|
+
The CLI, `clientFromEnv()` SDK helper and standalone MCP client resolve Switcher API URL/key through `@hasna/contracts/client`, following the same shared convention as Conversations. A key alone selects `https://api.hasna.com/switcher`; `HASNA_SWITCHER_API_URL` selects a self-hosted endpoint. The `SWITCHER_API_URL` and `SWITCHER_API_KEY` aliases are supported. Deliberate overrides/profile selections take precedence; otherwise the resolver reads macOS Keychain, canonical disk configuration and then environment. A live client rereads credentials before each request and refuses an authority change until rebuilt.
|
|
79
|
+
|
|
80
|
+
The canonical file is `~/.hasna/switcher/config/credentials` (owner-only mode 0600), using assignments named `HASNA_SWITCHER_API_URL` and `HASNA_SWITCHER_API_KEY`. Provision actual values through your approved secret manager. macOS Keychain uses service `hasna.credentials.switcher.api-key` and optional `api-url`, with account `HASNA_STATION` or the short hostname. Provider keys remain in the provider's vault or secure store; this file authenticates the Switcher API only.
|
|
81
|
+
|
|
82
|
+
`HASNA_HOME` replaces `~/.hasna`; `HASNA_CONFIG_HOME` places credentials at `<root>/switcher/credentials` and `<root>/secrets/credentials`. These shared overrides must be absolute and nonblank. `HASNA_PROFILE` selects `credentials-<profile>`; API authority stays in the common credentials file. `HASNA_SWITCHER_HOME` changes Switcher's local database, bindings and launch state only, and does not relocate shared credentials. `HOME` is respected by both paths.
|
|
83
|
+
|
|
84
|
+
Configured but missing, unsafe, conflicting or inaccessible remote credentials fail before local data is opened. Only complete absence of Switcher remote configuration retains the user-authorized automatic local API. The owned local API uses a random in-memory key. Server-side authentication remains configured separately through `HASNA_SWITCHER_API_KEY` on `switcher-serve`.
|
|
85
|
+
|
|
76
86
|
## Credential bindings
|
|
77
87
|
|
|
78
|
-
Bind an existing vault key once, then launch without an external wrapper.
|
|
88
|
+
Bind an existing vault key once, then launch without an external wrapper. New bindings use the installed `secrets` CLI and resolve its operator key and API URL through `@hasna/contracts/client`. A configured Keychain item or canonical `~/.hasna/secrets/config/credentials` supplies the operator without shell exports. The default Secrets API URL is `https://api.hasna.com/secrets`; `--vault-url` can select a custom vault but must agree with any configured Secrets authority.
|
|
79
89
|
|
|
80
90
|
```sh
|
|
81
91
|
switcher credentials bind deepseek \
|
|
82
|
-
--vault-key providers/deepseek/live/api_key
|
|
83
|
-
--vault-account my-station
|
|
92
|
+
--vault-key providers/deepseek/live/api_key
|
|
84
93
|
switcher credentials check deepseek
|
|
85
94
|
switcher launch claude --provider deepseek --model deepseek-v4-pro
|
|
86
95
|
```
|
|
@@ -91,7 +100,7 @@ For provider keys already stored in macOS Keychain, use `--keychain-service SERV
|
|
|
91
100
|
|
|
92
101
|
`credentials list` displays bindings; `credentials remove PRESET_OR_REFERENCE` removes only the locator. Replacement requires explicit removal. Custom credential references require `--origin URL` (repeatable); preset bindings authorize their documented origins by default. `credentials check` reports availability, length and hash, not successful provider authentication. Provider credentials needed by a remote API's catalog discovery must still be configured on that server independently.
|
|
93
102
|
|
|
94
|
-
|
|
103
|
+
Explicit `--vault-account ACCOUNT` pins one macOS Keychain account and requires `--vault-url`; it never falls back when that account is missing or locked. `--vault-operator env` preserves the per-process `HASNA_SECRETS_API_KEY` mode and also requires `--vault-url`. Existing bindings keep their original operator mode. To adopt canonical resolution for an old binding, explicitly remove and rebind its same provider key reference. A Secrets operator cannot bootstrap itself through `HASNA_SECRETS_API_KEY_REF`; use a literal operator from its canonical store or explicit override. On Linux the shared resolver reads the owner-only canonical credentials file or process environment. The binding stores only the locator and optional vault authority. Alternatively, let the authenticated `secrets` CLI inject a provider credential for one command:
|
|
95
104
|
|
|
96
105
|
```sh
|
|
97
106
|
secrets exec providers/deepseek/live/api_key --as DEEPSEEK_API_KEY -- \
|
package/dist/cli/index.js
CHANGED
|
@@ -499,7 +499,7 @@ function canonicalPolicyJSON(value) {
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
// src/domain.ts
|
|
502
|
-
var VERSION = "0.1.
|
|
502
|
+
var VERSION = "0.1.4";
|
|
503
503
|
var harnessSchema = z3.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
|
|
504
504
|
var protocolSchema = z3.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
|
|
505
505
|
var idSchema = z3.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
|
|
@@ -1033,7 +1033,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
|
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
1035
|
// src/sdk.ts
|
|
1036
|
-
import {
|
|
1036
|
+
import { createClientTransport } from "@hasna/contracts/client";
|
|
1037
1037
|
|
|
1038
1038
|
// src/presets.ts
|
|
1039
1039
|
var route = (protocol, baseUrl, options = {}) => ({
|
|
@@ -1206,6 +1206,7 @@ function apiError(status, data, apiKey) {
|
|
|
1206
1206
|
|
|
1207
1207
|
class SwitcherClient {
|
|
1208
1208
|
options;
|
|
1209
|
+
transport;
|
|
1209
1210
|
baseUrl;
|
|
1210
1211
|
constructor(options) {
|
|
1211
1212
|
this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
|
|
@@ -1213,9 +1214,50 @@ class SwitcherClient {
|
|
|
1213
1214
|
throw new Error("Switcher API key is required.");
|
|
1214
1215
|
this.options = { ...options };
|
|
1215
1216
|
}
|
|
1217
|
+
static fromEnvironment(env = process.env, options = {}) {
|
|
1218
|
+
const shared = createClientTransport("switcher", env, {
|
|
1219
|
+
credentials: options.credentials,
|
|
1220
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
1221
|
+
retry: false,
|
|
1222
|
+
fetchImpl: async (input, init) => {
|
|
1223
|
+
const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
|
|
1224
|
+
const key = new Headers(init?.headers).get("x-api-key") ?? "";
|
|
1225
|
+
let response;
|
|
1226
|
+
try {
|
|
1227
|
+
response = await (options.fetch ?? fetch)(address, init);
|
|
1228
|
+
} catch {
|
|
1229
|
+
throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
|
|
1230
|
+
}
|
|
1231
|
+
if (response.status === 401 || response.status === 403) {
|
|
1232
|
+
await response.body?.cancel().catch(() => {});
|
|
1233
|
+
throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
|
|
1234
|
+
}
|
|
1235
|
+
let data;
|
|
1236
|
+
try {
|
|
1237
|
+
data = await boundedJson(response);
|
|
1238
|
+
} catch {
|
|
1239
|
+
throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
|
|
1240
|
+
}
|
|
1241
|
+
if (!response.ok)
|
|
1242
|
+
throw apiError(response.status, data, key);
|
|
1243
|
+
return Response.json(data);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
|
|
1247
|
+
throw new Error("Shared credential transport was not invoked.");
|
|
1248
|
+
} });
|
|
1249
|
+
client.transport = shared.client;
|
|
1250
|
+
return client;
|
|
1251
|
+
}
|
|
1216
1252
|
async request(method, path, body, options = {}) {
|
|
1217
1253
|
if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
|
|
1218
1254
|
throw new Error("Invalid API path.");
|
|
1255
|
+
if (this.transport)
|
|
1256
|
+
return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
|
|
1257
|
+
idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
|
|
1258
|
+
headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
|
|
1259
|
+
retry: false
|
|
1260
|
+
});
|
|
1219
1261
|
const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
|
|
1220
1262
|
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
1221
1263
|
throw new Error("Switcher API key is required.");
|
|
@@ -1312,11 +1354,8 @@ class SwitcherClient {
|
|
|
1312
1354
|
return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
|
|
1313
1355
|
}
|
|
1314
1356
|
}
|
|
1315
|
-
function clientFromEnv(env = process.env) {
|
|
1316
|
-
|
|
1317
|
-
if (!env.HASNA_SWITCHER_API_URL || !credential())
|
|
1318
|
-
throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
|
|
1319
|
-
return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
|
|
1357
|
+
function clientFromEnv(env = process.env, options = {}) {
|
|
1358
|
+
return SwitcherClient.fromEnvironment(env, options);
|
|
1320
1359
|
}
|
|
1321
1360
|
|
|
1322
1361
|
// src/opencode-model-policy.ts
|
|
@@ -1388,7 +1427,7 @@ function openCodeInvocationModel(args, policy, defaultAgent) {
|
|
|
1388
1427
|
|
|
1389
1428
|
// src/gemini-config.ts
|
|
1390
1429
|
import { readFile, writeFile as writeFile3, lstat as lstat2, readdir as readdir2, symlink, realpath as realpath2 } from "fs/promises";
|
|
1391
|
-
import { join as join4, dirname as dirname3, resolve as resolve4, isAbsolute as
|
|
1430
|
+
import { join as join4, dirname as dirname3, resolve as resolve4, isAbsolute as isAbsolute4, relative as relative2, sep } from "path";
|
|
1392
1431
|
import { homedir as homedir3 } from "os";
|
|
1393
1432
|
import { parseTree as parseTree2, getNodeValue } from "jsonc-parser";
|
|
1394
1433
|
|
|
@@ -1396,7 +1435,7 @@ import { parseTree as parseTree2, getNodeValue } from "jsonc-parser";
|
|
|
1396
1435
|
import { randomBytes } from "crypto";
|
|
1397
1436
|
import { mkdir as mkdir4, lstat } from "fs/promises";
|
|
1398
1437
|
import { homedir as homedir2 } from "os";
|
|
1399
|
-
import { join as join3, resolve as resolve3 } from "path";
|
|
1438
|
+
import { isAbsolute as isAbsolute3, join as join3, resolve as resolve3 } from "path";
|
|
1400
1439
|
|
|
1401
1440
|
// src/store.ts
|
|
1402
1441
|
var {SQL } = globalThis.Bun;
|
|
@@ -1638,7 +1677,7 @@ async function fetchCatalogPage(url, headers, deadline) {
|
|
|
1638
1677
|
await waitForCatalogRetry(delay ?? 100 * 2 ** retry, deadline);
|
|
1639
1678
|
}
|
|
1640
1679
|
}
|
|
1641
|
-
async function discover(provider, env = process.env,
|
|
1680
|
+
async function discover(provider, env = process.env, resolveCredential) {
|
|
1642
1681
|
const refreshedAt = new Date().toISOString();
|
|
1643
1682
|
if (provider.manualModels.length)
|
|
1644
1683
|
return { models: provider.manualModels, source: "manual", refreshedAt };
|
|
@@ -1658,7 +1697,7 @@ async function discover(provider, env = process.env, resolveCredential2) {
|
|
|
1658
1697
|
if (authStyle !== "none" && credentialEnv) {
|
|
1659
1698
|
if (url.origin !== new URL(provider.baseUrl).origin && !provider.catalogCredentialEnv)
|
|
1660
1699
|
throw new Fault(422, "catalog_credential_authority", "A different catalog origin requires an explicit catalog credential reference or catalogAuthStyle: none.");
|
|
1661
|
-
const credential =
|
|
1700
|
+
const credential = resolveCredential ? await resolveCredential({ ...provider, baseUrl: provider.catalogBaseUrl ?? provider.baseUrl, credentialEnv }) : env[credentialEnv];
|
|
1662
1701
|
if (!credential)
|
|
1663
1702
|
throw new Fault(422, "credential_missing", "Provider credential environment variable is not available on the server.");
|
|
1664
1703
|
if (/[\r\n]/.test(credential))
|
|
@@ -1985,7 +2024,7 @@ var openapi_default = {
|
|
|
1985
2024
|
openapi: "3.0.3",
|
|
1986
2025
|
info: {
|
|
1987
2026
|
title: "Switcher API",
|
|
1988
|
-
version: "0.1.
|
|
2027
|
+
version: "0.1.4",
|
|
1989
2028
|
description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
|
|
1990
2029
|
},
|
|
1991
2030
|
security: [
|
|
@@ -5460,7 +5499,7 @@ var openapi_default = {
|
|
|
5460
5499
|
// src/service.ts
|
|
5461
5500
|
var snapshot = (profile, provider, catalog) => createHash3("sha256").update(JSON.stringify([profile, provider, { models: catalog.models, source: catalog.source }])).digest("hex");
|
|
5462
5501
|
var hash = (s) => createHash3("sha256").update(s).digest();
|
|
5463
|
-
function createHandler(store, apiKey, providerEnv = process.env,
|
|
5502
|
+
function createHandler(store, apiKey, providerEnv = process.env, resolveCredential) {
|
|
5464
5503
|
if (!apiKey || apiKey.length < 24)
|
|
5465
5504
|
throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
|
|
5466
5505
|
const expected = hash(`Bearer ${apiKey}`);
|
|
@@ -5542,7 +5581,7 @@ function createHandler(store, apiKey, providerEnv = process.env, resolveCredenti
|
|
|
5542
5581
|
if (resource === "providers" && id2 && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
|
|
5543
5582
|
parse2(z4.object({}).strict(), body);
|
|
5544
5583
|
const provider = await store.get("providers", id2);
|
|
5545
|
-
refreshed = { provider, catalog: await discover(provider, providerEnv,
|
|
5584
|
+
refreshed = { provider, catalog: await discover(provider, providerEnv, resolveCredential) };
|
|
5546
5585
|
}
|
|
5547
5586
|
const result = await store.mutate(key, fingerprint, async (db) => {
|
|
5548
5587
|
if ((resource === "providers" || resource === "profiles") && parts.length <= 3) {
|
|
@@ -5689,8 +5728,11 @@ async function startServer(options) {
|
|
|
5689
5728
|
}
|
|
5690
5729
|
|
|
5691
5730
|
// src/runtime.ts
|
|
5731
|
+
import { resolveCredential as resolveClientCredential, clientTransportEnvKeys, appConfigDiskValue, keychainConfigValue } from "@hasna/contracts/client";
|
|
5692
5732
|
function switcherHome(env = process.env) {
|
|
5693
|
-
|
|
5733
|
+
const override = env.HASNA_HOME?.trim();
|
|
5734
|
+
const root = override && isAbsolute3(override) ? override : join3(env.HOME?.trim() || homedir2(), ".hasna");
|
|
5735
|
+
return resolve3(env.HASNA_SWITCHER_HOME ?? join3(root, "switcher"));
|
|
5694
5736
|
}
|
|
5695
5737
|
async function privateDirectory(path) {
|
|
5696
5738
|
await mkdir4(path, { recursive: true, mode: 448 });
|
|
@@ -5700,9 +5742,12 @@ async function privateDirectory(path) {
|
|
|
5700
5742
|
if (process.platform !== "win32" && ((info.mode & 63) !== 0 || info.uid !== process.getuid?.()))
|
|
5701
5743
|
throw new Fault(500, "home_permissions", "Switcher data directory must be owned by this user and accessible only to its owner (mode 0700).");
|
|
5702
5744
|
}
|
|
5703
|
-
async function openCliRuntime(env = process.env,
|
|
5745
|
+
async function openCliRuntime(env = process.env, resolveCredential) {
|
|
5704
5746
|
const providerEnv = Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SWITCHER_PROVIDER_")));
|
|
5705
|
-
|
|
5747
|
+
const keys = clientTransportEnvKeys("switcher");
|
|
5748
|
+
const credential = resolveClientCredential("switcher", env);
|
|
5749
|
+
const configured = credential || keys.apiUrlKeys.some((name) => env[name] !== undefined) || keychainConfigValue("switcher", env) || appConfigDiskValue("switcher", env, keys.apiUrlKeys);
|
|
5750
|
+
if (configured) {
|
|
5706
5751
|
return { client: clientFromEnv(env), mode: "remote", providerEnv, close: async () => {} };
|
|
5707
5752
|
}
|
|
5708
5753
|
const home = switcherHome(env);
|
|
@@ -5714,7 +5759,7 @@ async function openCliRuntime(env = process.env, resolveCredential2) {
|
|
|
5714
5759
|
databaseUrl: env.HASNA_SWITCHER_DATABASE_URL,
|
|
5715
5760
|
sqlitePath: env.HASNA_SWITCHER_SQLITE_PATH ?? (env.HASNA_SWITCHER_DATABASE_URL ? undefined : join3(home, "switcher.db")),
|
|
5716
5761
|
providerEnv,
|
|
5717
|
-
resolveCredential
|
|
5762
|
+
resolveCredential
|
|
5718
5763
|
});
|
|
5719
5764
|
return { client: new SwitcherClient({ baseUrl: service.url, apiKey }), mode: "local", providerEnv, close: service.close };
|
|
5720
5765
|
}
|
|
@@ -5860,7 +5905,7 @@ function originalPaths(value, home) {
|
|
|
5860
5905
|
}
|
|
5861
5906
|
async function validateGeminiConfiguration(cwd, env = process.env) {
|
|
5862
5907
|
const home = env.GEMINI_CLI_HOME ?? env.HOME ?? homedir3();
|
|
5863
|
-
if (!
|
|
5908
|
+
if (!isAbsolute4(home))
|
|
5864
5909
|
throw new Error("Gemini requires an absolute native home.");
|
|
5865
5910
|
const systemPath = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? (process.platform === "darwin" ? "/Library/Application Support/GeminiCli/settings.json" : process.platform === "win32" ? "C:\\ProgramData\\gemini-cli\\settings.json" : "/etc/gemini-cli/settings.json");
|
|
5866
5911
|
const defaultsPath = env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ?? join4(dirname3(systemPath), "system-defaults.json");
|
|
@@ -5924,7 +5969,7 @@ async function snapshotContext(sourceDir, destinationDir, names) {
|
|
|
5924
5969
|
if (code.some(([a, b]) => start >= a && start < b))
|
|
5925
5970
|
continue;
|
|
5926
5971
|
const imported = resolve4(dirname3(source), match[2]), actual = await realpath2(imported).catch(() => imported), within = relative2(root, actual);
|
|
5927
|
-
if (within === ".." || within.startsWith(".." + sep) ||
|
|
5972
|
+
if (within === ".." || within.startsWith(".." + sep) || isAbsolute4(within))
|
|
5928
5973
|
throw new Error("Gemini global context imports outside the original context directory require a supported explicit context location.");
|
|
5929
5974
|
const target = files.get(imported) ?? join4(importsDir, `${files.size}.md`);
|
|
5930
5975
|
await privateDirectory(importsDir);
|
|
@@ -6464,7 +6509,7 @@ function createInferenceGateway(input) {
|
|
|
6464
6509
|
|
|
6465
6510
|
// src/codex-model-policy.ts
|
|
6466
6511
|
import { mkdir as mkdir5, readFile as readFile2, realpath as realpath3, stat as stat3, writeFile as writeFile4 } from "fs/promises";
|
|
6467
|
-
import { dirname as dirname4, isAbsolute as
|
|
6512
|
+
import { dirname as dirname4, isAbsolute as isAbsolute5, join as join5, resolve as resolve5 } from "path";
|
|
6468
6513
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
6469
6514
|
import { createHash as createHash6 } from "crypto";
|
|
6470
6515
|
var dict = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
@@ -6484,7 +6529,7 @@ function codexAgentsFromEffectiveConfig(config, trustedProject = true, baseDir)
|
|
|
6484
6529
|
const rawConfigFile = value.config_file;
|
|
6485
6530
|
if (rawConfigFile !== undefined && typeof rawConfigFile !== "string")
|
|
6486
6531
|
throw new Error(`Codex agents.${name}.config_file must be a string.`);
|
|
6487
|
-
const configFile = rawConfigFile === undefined ? undefined :
|
|
6532
|
+
const configFile = rawConfigFile === undefined ? undefined : isAbsolute5(rawConfigFile) ? rawConfigFile : baseDir ? resolve5(baseDir, rawConfigFile) : undefined;
|
|
6488
6533
|
if (rawConfigFile !== undefined && !configFile)
|
|
6489
6534
|
throw new Error(`Codex agents.${name}.config_file must be absolute after precedence resolution.`);
|
|
6490
6535
|
const definition = { trusted: trustedProject, config_file: configFile };
|
|
@@ -6646,7 +6691,7 @@ async function prepareCodexModelPolicy(input) {
|
|
|
6646
6691
|
const global = await readConfig(globalPath) ?? {};
|
|
6647
6692
|
const projects = dict(global.projects) ? global.projects : {};
|
|
6648
6693
|
const markers = global.project_root_markers ?? [".git"];
|
|
6649
|
-
if (!Array.isArray(markers) || markers.some((m) => typeof m !== "string" ||
|
|
6694
|
+
if (!Array.isArray(markers) || markers.some((m) => typeof m !== "string" || isAbsolute5(m) || m.includes("..")))
|
|
6650
6695
|
throw new Error("Unsupported Codex project root markers.");
|
|
6651
6696
|
const ancestorDirs = [];
|
|
6652
6697
|
for (let dir = cwd;; dir = dirname4(dir)) {
|
|
@@ -6715,7 +6760,7 @@ async function prepareCodexModelPolicy(input) {
|
|
|
6715
6760
|
return { ...result, agentConfigPaths: await writeCodexAgentOverrides(result, input.stateDir), configPaths: loaded };
|
|
6716
6761
|
}
|
|
6717
6762
|
async function readCodexAgentConfig(path) {
|
|
6718
|
-
if (!
|
|
6763
|
+
if (!isAbsolute5(path))
|
|
6719
6764
|
throw new Error("Codex agent config_file must be absolute before launch.");
|
|
6720
6765
|
const canonical = await realpath3(path);
|
|
6721
6766
|
const info = await stat3(canonical);
|
|
@@ -6731,7 +6776,7 @@ async function readCodexAgentConfig(path) {
|
|
|
6731
6776
|
|
|
6732
6777
|
// src/harnesses.ts
|
|
6733
6778
|
import { access as access2, mkdir as mkdir10, readFile as readFile4, readdir as readdir4, writeFile as writeFile10, rm as rm2 } from "fs/promises";
|
|
6734
|
-
import { basename as basename2, dirname as dirname7, join as join11, isAbsolute as
|
|
6779
|
+
import { basename as basename2, dirname as dirname7, join as join11, isAbsolute as isAbsolute10, resolve as resolve8 } from "path";
|
|
6735
6780
|
import { homedir as homedir6, tmpdir } from "os";
|
|
6736
6781
|
import { createHash as createHash8, randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
6737
6782
|
import { execFile } from "child_process";
|
|
@@ -6741,7 +6786,7 @@ import { createConnection } from "net";
|
|
|
6741
6786
|
// src/aider-config.ts
|
|
6742
6787
|
import { readFile as readFile3, writeFile as writeFile5, lstat as lstat3, copyFile, rename, realpath as realpath4, access } from "fs/promises";
|
|
6743
6788
|
import { constants as constants2 } from "fs";
|
|
6744
|
-
import { dirname as dirname5, basename, join as join6, resolve as resolve6, isAbsolute as
|
|
6789
|
+
import { dirname as dirname5, basename, join as join6, resolve as resolve6, isAbsolute as isAbsolute6 } from "path";
|
|
6745
6790
|
import { homedir as homedir4 } from "os";
|
|
6746
6791
|
import { parseDocument as parseDocument2 } from "yaml";
|
|
6747
6792
|
var KEY2 = "SWITCHER_HARNESS_API_KEY";
|
|
@@ -6800,7 +6845,7 @@ var startup = `yes-always gui browser upgrade install-main-branch just-check-upd
|
|
|
6800
6845
|
var safeParameters = new Set(`max_tokens temperature top_p top_k min_p stop seed reasoning_effort thinking`.split(" "));
|
|
6801
6846
|
var expandHome = (path, home) => path === "~" ? home : path.startsWith("~/") ? join6(home, path.slice(2)) : path;
|
|
6802
6847
|
async function validateAiderConfiguration(cwd, args = [], home = process.env.HOME ?? homedir4()) {
|
|
6803
|
-
if (!
|
|
6848
|
+
if (!isAbsolute6(home))
|
|
6804
6849
|
throw new Error("Aider requires an absolute HOME to preserve native instruction paths.");
|
|
6805
6850
|
const parsed = aiderArguments(args), cwdRoot = await gitRoot(cwd);
|
|
6806
6851
|
const roots = new Set([resolve6(home), ...cwdRoot ? [cwdRoot] : [], resolve6(cwd)]);
|
|
@@ -7036,7 +7081,7 @@ function validateGrokResume(args) {
|
|
|
7036
7081
|
// src/cline-backend.ts
|
|
7037
7082
|
import { mkdir as mkdir6, writeFile as writeFile6 } from "fs/promises";
|
|
7038
7083
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
7039
|
-
import { isAbsolute as
|
|
7084
|
+
import { isAbsolute as isAbsolute7, join as join7 } from "path";
|
|
7040
7085
|
var ENV_BY_PROTOCOL = {
|
|
7041
7086
|
"anthropic-messages": "ANTHROPIC_API_KEY",
|
|
7042
7087
|
"openai-responses": "OPENAI_API_KEY",
|
|
@@ -7082,7 +7127,7 @@ async function prepareClineLaunch(input) {
|
|
|
7082
7127
|
throw new Error(`Cline ${input.protocol} uses ${mapping.authStyle} authentication; use a provider with the native Cline auth contract.`);
|
|
7083
7128
|
if (input.credential && /[\r\n]/.test(input.credential))
|
|
7084
7129
|
throw new Error("Provider credential contains invalid header characters.");
|
|
7085
|
-
if (!
|
|
7130
|
+
if (!isAbsolute7(input.stateDir) || !isAbsolute7(input.cwd))
|
|
7086
7131
|
throw new Error("Launch state and working directories must be absolute.");
|
|
7087
7132
|
const dataDir = join7(input.stateDir, "cline-data");
|
|
7088
7133
|
const sessionDir = input.sessionDir ?? join7(dataDir, "sessions");
|
|
@@ -7164,7 +7209,7 @@ async function prepareClineLaunch(input) {
|
|
|
7164
7209
|
// src/opencode2-config.ts
|
|
7165
7210
|
import { mkdir as mkdir7, open as open2, realpath as realpath5, readdir as readdir3, stat as stat4, writeFile as writeFile7 } from "fs/promises";
|
|
7166
7211
|
import { constants as constants3 } from "fs";
|
|
7167
|
-
import { dirname as dirname6, isAbsolute as
|
|
7212
|
+
import { dirname as dirname6, isAbsolute as isAbsolute8, join as join8, relative as relative4, resolve as resolve7 } from "path";
|
|
7168
7213
|
import { homedir as homedir5 } from "os";
|
|
7169
7214
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
7170
7215
|
import { Database as Database2 } from "bun:sqlite";
|
|
@@ -7180,7 +7225,7 @@ var maxTotalBytes2 = 8 * 1024 * 1024;
|
|
|
7180
7225
|
var maxFiles2 = 256;
|
|
7181
7226
|
var inside2 = (root, path) => {
|
|
7182
7227
|
const rel = relative4(root, path);
|
|
7183
|
-
return rel === "" || !
|
|
7228
|
+
return rel === "" || !isAbsolute8(rel) && rel !== ".." && !rel.startsWith("../");
|
|
7184
7229
|
};
|
|
7185
7230
|
var action = (value) => ({ bash: "shell", task: "subagent", write: "edit", patch: "edit" })[value] ?? value;
|
|
7186
7231
|
function invalid2(path, field) {
|
|
@@ -7301,7 +7346,7 @@ async function checkRemoteConfiguration(dataHome) {
|
|
|
7301
7346
|
}
|
|
7302
7347
|
async function isolateOpenCode2(cwd, stateDir, providerID, env = process.env) {
|
|
7303
7348
|
for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "OPENCODE_CONFIG_DIR"])
|
|
7304
|
-
if (env[name] && !
|
|
7349
|
+
if (env[name] && !isAbsolute8(env[name]))
|
|
7305
7350
|
throw new Error(`OpenCode 2 requires an absolute ${name} to preserve native configuration authority.`);
|
|
7306
7351
|
const home = resolve7(env.HOME || homedir5());
|
|
7307
7352
|
const configRoot = resolve7(env.OPENCODE_CONFIG_DIR || join8(env.XDG_CONFIG_HOME || join8(home, ".config"), "opencode"));
|
|
@@ -7723,7 +7768,7 @@ function compileHermesModelPolicy(currentModel, roles2, baseUrl, apiMode = "chat
|
|
|
7723
7768
|
// src/hermes-backend.ts
|
|
7724
7769
|
import { createHash as createHash7, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
7725
7770
|
import { mkdir as mkdir9, symlink as symlink2, writeFile as writeFile9 } from "fs/promises";
|
|
7726
|
-
import { isAbsolute as
|
|
7771
|
+
import { isAbsolute as isAbsolute9, join as join10 } from "path";
|
|
7727
7772
|
var hermesApiMode = {
|
|
7728
7773
|
"anthropic-messages": "anthropic_messages",
|
|
7729
7774
|
"openai-responses": "codex_responses",
|
|
@@ -7899,7 +7944,7 @@ function createHermesBridge(input) {
|
|
|
7899
7944
|
};
|
|
7900
7945
|
}
|
|
7901
7946
|
async function prepareHermesLaunch(input) {
|
|
7902
|
-
if (!
|
|
7947
|
+
if (!isAbsolute9(input.stateDir) || !isAbsolute9(input.cwd))
|
|
7903
7948
|
throw new Error("Launch state and working directories must be absolute.");
|
|
7904
7949
|
if (input.protocol !== "anthropic-messages" && input.protocol !== "openai-responses" && input.protocol !== "openai-chat")
|
|
7905
7950
|
throw new Error("Hermes does not support this provider protocol.");
|
|
@@ -8849,7 +8894,7 @@ async function prepareNativeLaunch(input, providerBaseUrl = input.providerBaseUr
|
|
|
8849
8894
|
if (!compatible(input.harness, input.protocol))
|
|
8850
8895
|
throw new Error("Harness and provider protocol are incompatible.");
|
|
8851
8896
|
validateHarnessVersion(input.harness, input.version);
|
|
8852
|
-
if (!
|
|
8897
|
+
if (!isAbsolute10(input.stateDir) || !isAbsolute10(input.cwd))
|
|
8853
8898
|
throw new Error("Launch state and working directories must be absolute.");
|
|
8854
8899
|
if (!input.models.length || !input.models.some((m) => m.id === input.model))
|
|
8855
8900
|
throw new Error("Selected model is missing from the launch catalog.");
|
|
@@ -8957,7 +9002,7 @@ async function prepareNativeLaunch(input, providerBaseUrl = input.providerBaseUr
|
|
|
8957
9002
|
const api = { "anthropic-messages": "anthropic-messages", "openai-responses": "openai-responses", "openai-chat": "openai-completions" }[input.protocol];
|
|
8958
9003
|
const home = join11(input.stateDir, "dsh-home");
|
|
8959
9004
|
const sessionDir = input.sessionDir ?? join11(input.stateDir, "dsh-state");
|
|
8960
|
-
if (!
|
|
9005
|
+
if (!isAbsolute10(sessionDir))
|
|
8961
9006
|
throw new Error("DSH session directory must be absolute.");
|
|
8962
9007
|
await mkdir10(home, { recursive: true, mode: 448 });
|
|
8963
9008
|
await mkdir10(sessionDir, { recursive: true, mode: 448 });
|
|
@@ -9218,7 +9263,7 @@ async function prepareHarnessLaunch(input) {
|
|
|
9218
9263
|
validateHarnessVersion(input.harness, input.version);
|
|
9219
9264
|
if (!compatible(input.harness, input.protocol))
|
|
9220
9265
|
throw new Error("Harness and provider protocol are incompatible.");
|
|
9221
|
-
if (!
|
|
9266
|
+
if (!isAbsolute10(input.stateDir) || !isAbsolute10(input.cwd))
|
|
9222
9267
|
throw new Error("Launch state and working directories must be absolute.");
|
|
9223
9268
|
const compiledPolicy = compileModelPolicy(input.model, input.models, input.modelPolicy);
|
|
9224
9269
|
const nativePolicy = compileNativeModelPolicy({ harness: input.harness, mainModel: input.model, roles: compiledPolicy.roles, version: input.version });
|
|
@@ -9248,9 +9293,9 @@ async function prepareHarnessLaunch(input) {
|
|
|
9248
9293
|
}
|
|
9249
9294
|
|
|
9250
9295
|
// src/ori-model-policy.ts
|
|
9251
|
-
import { isAbsolute as
|
|
9296
|
+
import { isAbsolute as isAbsolute11 } from "path";
|
|
9252
9297
|
function prepareOriModelPolicy(target, prepared) {
|
|
9253
|
-
if (!
|
|
9298
|
+
if (!isAbsolute11(prepared.executable) || /[\0\r\n]/.test(prepared.executable))
|
|
9254
9299
|
throw new Error("Ori requires an absolute verified native executable.");
|
|
9255
9300
|
const entries = Object.entries(prepared.env);
|
|
9256
9301
|
if (entries.some(([key]) => !/^[A-Z_][A-Z0-9_]*$/.test(key)))
|
|
@@ -10222,19 +10267,20 @@ async function ensureLaunchProfile(client, provider, harness, model3, modelPolic
|
|
|
10222
10267
|
import { z as z6 } from "zod";
|
|
10223
10268
|
import { constants as constants5 } from "fs";
|
|
10224
10269
|
import { open as open3, readdir as readdir5, unlink, link, lstat as lstat4, access as access3, stat as stat5, realpath as realpath6, readlink } from "fs/promises";
|
|
10225
|
-
import { join as join13, isAbsolute as
|
|
10270
|
+
import { join as join13, isAbsolute as isAbsolute12, dirname as dirname8, parse as pathParts, resolve as resolvePath, sep as sep2 } from "path";
|
|
10226
10271
|
import { createHash as createHash10, randomUUID as randomUUID5 } from "crypto";
|
|
10227
10272
|
import { execFile as execFile3, spawn as spawn2 } from "child_process";
|
|
10228
10273
|
import { promisify as promisify3 } from "util";
|
|
10229
|
-
import { resolveCredential as
|
|
10274
|
+
import { resolveCredential, resolveClientTransport, toV1BaseUrl, clientTransportEnvKeys as clientTransportEnvKeys2, keychainConfigValue as keychainConfigValue2, appConfigDiskValue as appConfigDiskValue2 } from "@hasna/contracts/client";
|
|
10230
10275
|
var execute2 = promisify3(execFile3);
|
|
10231
10276
|
var reference = z6.string().regex(/^SWITCHER_PROVIDER_[A-Z0-9_]+$/).max(120);
|
|
10232
10277
|
var item = z6.string().min(1).max(500).regex(/^[^\x00-\x1f\x7f]+$/);
|
|
10233
10278
|
var vaultKey = z6.string().max(500).regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9][A-Za-z0-9_.-]*)*$/, "Use a vault key path, not an option or secret value");
|
|
10234
10279
|
var keychain = z6.object({ kind: z6.literal("keychain"), service: item, account: item }).strict();
|
|
10235
10280
|
var operator = z6.discriminatedUnion("kind", [
|
|
10281
|
+
z6.object({ kind: z6.literal("contracts") }).strict(),
|
|
10236
10282
|
z6.object({ kind: z6.literal("env") }).strict(),
|
|
10237
|
-
z6.object({ kind: z6.literal("keychain"), account: item }).strict()
|
|
10283
|
+
z6.object({ kind: z6.literal("keychain"), account: item.refine((value) => value.trim().length > 0 && value === value.trim(), "Vault account must be nonblank without surrounding whitespace") }).strict()
|
|
10238
10284
|
]);
|
|
10239
10285
|
var origin = z6.string().transform((value) => new URL(endpoint(value)).origin);
|
|
10240
10286
|
var credentialBindingSchema = z6.object({
|
|
@@ -10244,11 +10290,14 @@ var credentialBindingSchema = z6.object({
|
|
|
10244
10290
|
source: z6.discriminatedUnion("kind", [keychain, z6.object({
|
|
10245
10291
|
kind: z6.literal("vault"),
|
|
10246
10292
|
key: vaultKey,
|
|
10247
|
-
url: z6.string().max(2000).transform(endpoint),
|
|
10248
|
-
executable: z6.string().max(4096).regex(/^[^\x00-\x1f\x7f]+$/).refine(
|
|
10293
|
+
url: z6.string().max(2000).transform(endpoint).optional(),
|
|
10294
|
+
executable: z6.string().max(4096).regex(/^[^\x00-\x1f\x7f]+$/).refine(isAbsolute12, "Secrets executable must be an absolute path"),
|
|
10249
10295
|
operator
|
|
10250
10296
|
}).strict()])
|
|
10251
|
-
}).strict()
|
|
10297
|
+
}).strict().superRefine((binding, ctx) => {
|
|
10298
|
+
if (binding.source.kind === "vault" && binding.source.operator.kind !== "contracts" && !binding.source.url)
|
|
10299
|
+
ctx.addIssue({ code: "custom", path: ["source", "url"], message: "An explicit env or Keychain operator binding requires a vault URL." });
|
|
10300
|
+
});
|
|
10252
10301
|
var fingerprint = (binding) => createHash10("sha256").update(JSON.stringify(binding)).digest("hex");
|
|
10253
10302
|
|
|
10254
10303
|
class CredentialBindings {
|
|
@@ -10489,20 +10538,49 @@ class CredentialInterrupted extends CommandInterrupted {
|
|
|
10489
10538
|
super(exitCode, "Credential lookup was interrupted; no harness was started.");
|
|
10490
10539
|
}
|
|
10491
10540
|
}
|
|
10492
|
-
function vaultEnvironment(binding, env) {
|
|
10541
|
+
async function vaultEnvironment(binding, env, options = {}) {
|
|
10493
10542
|
if (binding.source.kind !== "vault")
|
|
10494
10543
|
throw new Fault(500, "credential_resolution", "Unexpected credential source.");
|
|
10495
|
-
|
|
10544
|
+
const source = binding.source;
|
|
10545
|
+
let credential, url = source.url;
|
|
10496
10546
|
try {
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10547
|
+
if (source.operator.kind === "contracts") {
|
|
10548
|
+
const pair = () => {
|
|
10549
|
+
const keys = clientTransportEnvKeys2("secrets");
|
|
10550
|
+
const configuredUrl = keys.apiUrlKeys.map((name) => env[name]).find((value) => value !== undefined) ?? keychainConfigValue2("secrets", env, options.keychain)?.value ?? appConfigDiskValue2("secrets", env, keys.apiUrlKeys)?.value;
|
|
10551
|
+
const resolution = resolveClientTransport("secrets", env, { credentials: options });
|
|
10552
|
+
const key = resolveCredential("secrets", env, options);
|
|
10553
|
+
if (key?.tier === "pointer")
|
|
10554
|
+
throw new Fault(422, "vault_operator_pointer", "A Secrets operator cannot bootstrap itself through HASNA_SECRETS_API_KEY_REF. Configure its operator in Keychain, canonical config/credentials, or an explicit key override.");
|
|
10555
|
+
if (!key?.apiKey || key.source !== resolution.apiKeySource || key.tier !== resolution.apiKeyTier)
|
|
10556
|
+
throw new Fault(422, "vault_operator_changed", "The vault operator changed during resolution; no credential was sent. Retry after configuration is stable.");
|
|
10557
|
+
const boundUrl = source.url ? toV1BaseUrl(source.url) : resolution.baseUrl;
|
|
10558
|
+
if (resolution.apiUrlSource !== "default" && boundUrl !== resolution.baseUrl)
|
|
10559
|
+
throw new Fault(422, "vault_operator_authority", "The binding vault URL conflicts with the canonical Secrets API URL; no credential was sent.");
|
|
10560
|
+
if (configuredUrl !== undefined && toV1BaseUrl(configuredUrl) !== resolution.baseUrl)
|
|
10561
|
+
throw new Fault(422, "vault_operator_changed", "The vault authority changed during resolution; no credential was sent.");
|
|
10562
|
+
return { key, url: configuredUrl?.trim() ?? source.url ?? boundUrl.replace(/\/v1$/, "") };
|
|
10563
|
+
};
|
|
10564
|
+
const first = pair(), second = pair();
|
|
10565
|
+
if (first.key.apiKey !== second.key.apiKey || first.key.source !== second.key.source || first.key.tier !== second.key.tier || first.url !== second.url)
|
|
10566
|
+
throw new Fault(422, "vault_operator_changed", "The vault operator or authority changed during resolution; no credential was sent. Retry after configuration is stable.");
|
|
10567
|
+
credential = second.key;
|
|
10568
|
+
url = second.url;
|
|
10569
|
+
} else {
|
|
10570
|
+
credential = source.operator.kind === "keychain" ? resolveCredential("secrets", { HASNA_STATION: source.operator.account }, { keychain: { ...options.keychain, enabled: true } }) : resolveCredential("secrets", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SECRETS_API_KEY")), { keychain: { enabled: false } });
|
|
10571
|
+
}
|
|
10572
|
+
} catch (error) {
|
|
10573
|
+
if (error instanceof Fault)
|
|
10574
|
+
throw error;
|
|
10575
|
+
const kind = error instanceof Error ? error.name : "";
|
|
10576
|
+
const reason = kind === "CredentialFileUnsafeError" ? "unsafe canonical config/credentials file" : kind === "ClientTransportConfigurationError" ? "invalid or missing canonical API URL/key" : source.operator.kind === "keychain" ? "unavailable pinned Keychain account" : "credential selection refused by Contracts (Keychain, canonical file, profile, or environment)";
|
|
10577
|
+
throw new Fault(422, "vault_operator_unavailable", `Cannot resolve the Secrets vault operator: ${reason}. Check the selected source in this terminal session; no alternate account was selected.`);
|
|
10500
10578
|
}
|
|
10501
10579
|
if (!credential?.apiKey)
|
|
10502
|
-
throw new Fault(422, "vault_operator_missing", "
|
|
10503
|
-
const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z_]+|TMPDIR|TEMP|TMP|HASNA_SECRETS_HOME|HASNA_STATION)$/;
|
|
10580
|
+
throw new Fault(422, "vault_operator_missing", "The selected vault operator has no credential. Use a canonical Contracts binding, restore its named Keychain account, or inject HASNA_SECRETS_API_KEY for an explicit env binding.");
|
|
10581
|
+
const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z_]+|TMPDIR|TEMP|TMP|HASNA_HOME|HASNA_CONFIG_HOME|HASNA_SECRETS_HOME|HASNA_STATION)$/;
|
|
10504
10582
|
const next = Object.fromEntries(Object.entries(env).filter(([name]) => allowed.test(name)));
|
|
10505
|
-
next.HASNA_SECRETS_API_URL =
|
|
10583
|
+
next.HASNA_SECRETS_API_URL = url;
|
|
10506
10584
|
next.HASNA_SECRETS_API_KEY = credential.apiKey;
|
|
10507
10585
|
next.HASNA_SECRETS_API_KEY_OVERRIDE = credential.apiKey;
|
|
10508
10586
|
if (binding.source.operator.kind === "keychain")
|
|
@@ -10515,7 +10593,7 @@ async function runVaultCommand(binding, args, env, delivery = {}, captureCheck =
|
|
|
10515
10593
|
if (process.platform === "win32")
|
|
10516
10594
|
throw new Fault(422, "vault_exec_unavailable", "Vault CLI bindings currently require POSIX process groups; use runtime environment injection on Windows.");
|
|
10517
10595
|
const executable = await validateVaultExecutable(binding.source.executable);
|
|
10518
|
-
const childEnv = { ...vaultEnvironment(binding, env), ...delivery };
|
|
10596
|
+
const childEnv = { ...await vaultEnvironment(binding, env), ...delivery };
|
|
10519
10597
|
return new Promise((resolveResult, reject2) => {
|
|
10520
10598
|
const child = spawn2(executable, args, { env: childEnv, stdio: ["ignore", captureCheck ? "pipe" : "ignore", "ignore"], detached: true, shell: false });
|
|
10521
10599
|
let failure;
|
|
@@ -10646,8 +10724,8 @@ var HELP = `switcher \u2014 launch a coding harness with a provider and its mode
|
|
|
10646
10724
|
[--ori-executable PATH] [--state-dir DIR]
|
|
10647
10725
|
[--timeout SECONDS] -- [native harness arguments]
|
|
10648
10726
|
switcher runs list|get [ID]
|
|
10649
|
-
switcher credentials bind PRESET --vault-key KEY --vault-url URL
|
|
10650
|
-
[--vault-cli PATH] [--vault-account ACCOUNT]
|
|
10727
|
+
switcher credentials bind PRESET --vault-key KEY [--vault-url URL]
|
|
10728
|
+
[--vault-cli PATH] [--vault-account ACCOUNT | --vault-operator env]
|
|
10651
10729
|
switcher credentials bind PRESET --keychain-service SERVICE --keychain-account ACCOUNT
|
|
10652
10730
|
switcher credentials list|check|remove [PRESET_OR_REFERENCE]
|
|
10653
10731
|
switcher doctor
|
|
@@ -10656,12 +10734,14 @@ HARNESS: claude, codex, grok, opencode, opencode2, pi, omp, dsh, cline, hermes,
|
|
|
10656
10734
|
PROTOCOL: anthropic-messages, openai-responses, openai-chat, gemini-generate-content
|
|
10657
10735
|
Without remote API configuration, the CLI owns a local authenticated API and
|
|
10658
10736
|
stores data in ~/.hasna/switcher (override HASNA_SWITCHER_HOME).
|
|
10659
|
-
|
|
10737
|
+
Remote API URL/key resolve through @hasna/contracts: overrides, Keychain,
|
|
10738
|
+
~/.hasna/switcher/config/credentials, then environment. A key alone uses the gateway.
|
|
10660
10739
|
A configured remote API never falls back to local data.
|
|
10661
10740
|
Provider credential references must start SWITCHER_PROVIDER_.
|
|
10662
10741
|
Credential bindings contain references only. Custom destinations require --origin URL.
|
|
10663
|
-
Vault bindings use the installed secrets CLI
|
|
10664
|
-
|
|
10742
|
+
Vault bindings use the installed secrets CLI and its canonical Contracts URL/key
|
|
10743
|
+
by default. --vault-account pins a Keychain account; --vault-operator env requires
|
|
10744
|
+
per-process HASNA_SECRETS_API_KEY. Explicit operators also require --vault-url.
|
|
10665
10745
|
--file accepts a JSON object including id; raw credentials are never accepted.
|
|
10666
10746
|
Fireworks discovery requires --catalog-account-id (or an explicit --catalog-url).
|
|
10667
10747
|
--json outputs machine-readable records (also the default for data commands).
|
|
@@ -10739,6 +10819,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
10739
10819
|
"vault-url": { type: "string" },
|
|
10740
10820
|
"vault-cli": { type: "string" },
|
|
10741
10821
|
"vault-account": { type: "string" },
|
|
10822
|
+
"vault-operator": { type: "string" },
|
|
10742
10823
|
"keychain-service": { type: "string" },
|
|
10743
10824
|
"keychain-account": { type: "string" },
|
|
10744
10825
|
origin: { type: "string", multiple: true }
|
|
@@ -10753,7 +10834,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
10753
10834
|
throw new Error("Unknown command. Run switcher --help.");
|
|
10754
10835
|
const providerFlags = ["url", "protocol", "preset", "credential-env", "auth-style", "catalog-url", "catalog-format", "catalog-auth-style", "catalog-credential-env", "catalog-account-id", "models-path"];
|
|
10755
10836
|
const provided = (names) => names.some((name) => values2[name] !== undefined);
|
|
10756
|
-
const credentialFlags = ["vault-key", "vault-url", "vault-cli", "vault-account", "keychain-service", "keychain-account", "origin"];
|
|
10837
|
+
const credentialFlags = ["vault-key", "vault-url", "vault-cli", "vault-account", "vault-operator", "keychain-service", "keychain-account", "origin"];
|
|
10757
10838
|
const credentials = new CredentialResolver;
|
|
10758
10839
|
if (command === "credentials") {
|
|
10759
10840
|
const bindingFlags = [...credentialFlags, "credential-env"];
|
|
@@ -10775,16 +10856,20 @@ async function main(args = process.argv.slice(2)) {
|
|
|
10775
10856
|
}
|
|
10776
10857
|
if (action2 !== "bind" || !id2)
|
|
10777
10858
|
throw new Fault(400, "invalid_request", "Use credentials bind PRESET, list, check PRESET_OR_REFERENCE, or remove PRESET_OR_REFERENCE.");
|
|
10778
|
-
const hasVault = provided(["vault-key", "vault-url", "vault-cli", "vault-account"]);
|
|
10859
|
+
const hasVault = provided(["vault-key", "vault-url", "vault-cli", "vault-account", "vault-operator"]);
|
|
10779
10860
|
const hasKeychain = provided(["keychain-service", "keychain-account"]);
|
|
10780
10861
|
if (hasVault === hasKeychain)
|
|
10781
10862
|
throw new Fault(400, "conflicting_options", "Choose one credential source: vault or Keychain.");
|
|
10863
|
+
if (values2["vault-operator"] !== undefined && !["contracts", "env"].includes(values2["vault-operator"]))
|
|
10864
|
+
throw new Fault(400, "invalid_request", "Use --vault-operator contracts or env.");
|
|
10865
|
+
if (values2["vault-account"] !== undefined && values2["vault-operator"] !== undefined)
|
|
10866
|
+
throw new Fault(400, "conflicting_options", "Use either --vault-account or --vault-operator.");
|
|
10782
10867
|
const source = hasVault ? {
|
|
10783
10868
|
kind: "vault",
|
|
10784
10869
|
key: values2["vault-key"],
|
|
10785
10870
|
url: values2["vault-url"],
|
|
10786
10871
|
executable: values2["vault-cli"] ?? Bun.which("secrets"),
|
|
10787
|
-
operator: values2["vault-account"] ? { kind: "keychain", account: values2["vault-account"] } : { kind: "
|
|
10872
|
+
operator: values2["vault-account"] !== undefined ? { kind: "keychain", account: values2["vault-account"] } : { kind: values2["vault-operator"] ?? "contracts" }
|
|
10788
10873
|
} : { kind: "keychain", service: values2["keychain-service"], account: values2["keychain-account"] };
|
|
10789
10874
|
output(await credentials.bindings.bind(parse2(credentialBindingSchema, { schema: 1, ...bindingTarget(id2, values2["credential-env"], values2.origin), source })));
|
|
10790
10875
|
return;
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { type CredentialChainOptions } from "@hasna/contracts/client";
|
|
2
3
|
import { CommandInterrupted, type ProviderInput } from "./domain";
|
|
3
4
|
declare const keychain: z.ZodObject<{
|
|
4
5
|
kind: z.ZodLiteral<"keychain">;
|
|
@@ -13,7 +14,7 @@ declare const keychain: z.ZodObject<{
|
|
|
13
14
|
service: string;
|
|
14
15
|
account: string;
|
|
15
16
|
}>;
|
|
16
|
-
export declare const credentialBindingSchema: z.ZodObject<{
|
|
17
|
+
export declare const credentialBindingSchema: z.ZodEffects<z.ZodObject<{
|
|
17
18
|
schema: z.ZodLiteral<1>;
|
|
18
19
|
credentialEnv: z.ZodString;
|
|
19
20
|
origins: z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">;
|
|
@@ -32,9 +33,15 @@ export declare const credentialBindingSchema: z.ZodObject<{
|
|
|
32
33
|
}>, z.ZodObject<{
|
|
33
34
|
kind: z.ZodLiteral<"vault">;
|
|
34
35
|
key: z.ZodString;
|
|
35
|
-
url: z.ZodEffects<z.ZodString, string, string
|
|
36
|
+
url: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
36
37
|
executable: z.ZodEffects<z.ZodString, string, string>;
|
|
37
38
|
operator: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
|
|
39
|
+
kind: z.ZodLiteral<"contracts">;
|
|
40
|
+
}, "strict", z.ZodTypeAny, {
|
|
41
|
+
kind: "contracts";
|
|
42
|
+
}, {
|
|
43
|
+
kind: "contracts";
|
|
44
|
+
}>, z.ZodObject<{
|
|
38
45
|
kind: z.ZodLiteral<"env">;
|
|
39
46
|
}, "strict", z.ZodTypeAny, {
|
|
40
47
|
kind: "env";
|
|
@@ -42,7 +49,7 @@ export declare const credentialBindingSchema: z.ZodObject<{
|
|
|
42
49
|
kind: "env";
|
|
43
50
|
}>, z.ZodObject<{
|
|
44
51
|
kind: z.ZodLiteral<"keychain">;
|
|
45
|
-
account: z.ZodString
|
|
52
|
+
account: z.ZodEffects<z.ZodString, string, string>;
|
|
46
53
|
}, "strict", z.ZodTypeAny, {
|
|
47
54
|
kind: "keychain";
|
|
48
55
|
account: string;
|
|
@@ -51,27 +58,31 @@ export declare const credentialBindingSchema: z.ZodObject<{
|
|
|
51
58
|
account: string;
|
|
52
59
|
}>]>;
|
|
53
60
|
}, "strict", z.ZodTypeAny, {
|
|
54
|
-
url: string;
|
|
55
61
|
key: string;
|
|
56
62
|
executable: string;
|
|
57
63
|
kind: "vault";
|
|
58
64
|
operator: {
|
|
65
|
+
kind: "contracts";
|
|
66
|
+
} | {
|
|
59
67
|
kind: "env";
|
|
60
68
|
} | {
|
|
61
69
|
kind: "keychain";
|
|
62
70
|
account: string;
|
|
63
71
|
};
|
|
72
|
+
url?: string | undefined;
|
|
64
73
|
}, {
|
|
65
|
-
url: string;
|
|
66
74
|
key: string;
|
|
67
75
|
executable: string;
|
|
68
76
|
kind: "vault";
|
|
69
77
|
operator: {
|
|
78
|
+
kind: "contracts";
|
|
79
|
+
} | {
|
|
70
80
|
kind: "env";
|
|
71
81
|
} | {
|
|
72
82
|
kind: "keychain";
|
|
73
83
|
account: string;
|
|
74
84
|
};
|
|
85
|
+
url?: string | undefined;
|
|
75
86
|
}>]>;
|
|
76
87
|
}, "strict", z.ZodTypeAny, {
|
|
77
88
|
credentialEnv: string;
|
|
@@ -80,16 +91,18 @@ export declare const credentialBindingSchema: z.ZodObject<{
|
|
|
80
91
|
service: string;
|
|
81
92
|
account: string;
|
|
82
93
|
} | {
|
|
83
|
-
url: string;
|
|
84
94
|
key: string;
|
|
85
95
|
executable: string;
|
|
86
96
|
kind: "vault";
|
|
87
97
|
operator: {
|
|
98
|
+
kind: "contracts";
|
|
99
|
+
} | {
|
|
88
100
|
kind: "env";
|
|
89
101
|
} | {
|
|
90
102
|
kind: "keychain";
|
|
91
103
|
account: string;
|
|
92
104
|
};
|
|
105
|
+
url?: string | undefined;
|
|
93
106
|
};
|
|
94
107
|
schema: 1;
|
|
95
108
|
origins: string[];
|
|
@@ -100,16 +113,62 @@ export declare const credentialBindingSchema: z.ZodObject<{
|
|
|
100
113
|
service: string;
|
|
101
114
|
account: string;
|
|
102
115
|
} | {
|
|
103
|
-
url: string;
|
|
104
116
|
key: string;
|
|
105
117
|
executable: string;
|
|
106
118
|
kind: "vault";
|
|
107
119
|
operator: {
|
|
120
|
+
kind: "contracts";
|
|
121
|
+
} | {
|
|
108
122
|
kind: "env";
|
|
109
123
|
} | {
|
|
110
124
|
kind: "keychain";
|
|
111
125
|
account: string;
|
|
112
126
|
};
|
|
127
|
+
url?: string | undefined;
|
|
128
|
+
};
|
|
129
|
+
schema: 1;
|
|
130
|
+
origins: string[];
|
|
131
|
+
}>, {
|
|
132
|
+
credentialEnv: string;
|
|
133
|
+
source: {
|
|
134
|
+
kind: "keychain";
|
|
135
|
+
service: string;
|
|
136
|
+
account: string;
|
|
137
|
+
} | {
|
|
138
|
+
key: string;
|
|
139
|
+
executable: string;
|
|
140
|
+
kind: "vault";
|
|
141
|
+
operator: {
|
|
142
|
+
kind: "contracts";
|
|
143
|
+
} | {
|
|
144
|
+
kind: "env";
|
|
145
|
+
} | {
|
|
146
|
+
kind: "keychain";
|
|
147
|
+
account: string;
|
|
148
|
+
};
|
|
149
|
+
url?: string | undefined;
|
|
150
|
+
};
|
|
151
|
+
schema: 1;
|
|
152
|
+
origins: string[];
|
|
153
|
+
}, {
|
|
154
|
+
credentialEnv: string;
|
|
155
|
+
source: {
|
|
156
|
+
kind: "keychain";
|
|
157
|
+
service: string;
|
|
158
|
+
account: string;
|
|
159
|
+
} | {
|
|
160
|
+
key: string;
|
|
161
|
+
executable: string;
|
|
162
|
+
kind: "vault";
|
|
163
|
+
operator: {
|
|
164
|
+
kind: "contracts";
|
|
165
|
+
} | {
|
|
166
|
+
kind: "env";
|
|
167
|
+
} | {
|
|
168
|
+
kind: "keychain";
|
|
169
|
+
account: string;
|
|
170
|
+
};
|
|
171
|
+
url?: string | undefined;
|
|
113
172
|
};
|
|
114
173
|
schema: 1;
|
|
115
174
|
origins: string[];
|
|
@@ -130,16 +189,18 @@ export declare class CredentialBindings {
|
|
|
130
189
|
service: string;
|
|
131
190
|
account: string;
|
|
132
191
|
} | {
|
|
133
|
-
url: string;
|
|
134
192
|
key: string;
|
|
135
193
|
executable: string;
|
|
136
194
|
kind: "vault";
|
|
137
195
|
operator: {
|
|
196
|
+
kind: "contracts";
|
|
197
|
+
} | {
|
|
138
198
|
kind: "env";
|
|
139
199
|
} | {
|
|
140
200
|
kind: "keychain";
|
|
141
201
|
account: string;
|
|
142
202
|
};
|
|
203
|
+
url?: string | undefined;
|
|
143
204
|
};
|
|
144
205
|
schema: 1;
|
|
145
206
|
origins: string[];
|
|
@@ -173,6 +234,8 @@ export declare class CredentialResolver {
|
|
|
173
234
|
export declare class CredentialInterrupted extends CommandInterrupted {
|
|
174
235
|
constructor(exitCode: number);
|
|
175
236
|
}
|
|
237
|
+
/** Select an operator through the shared credential seam, then pin that choice. */
|
|
238
|
+
export declare function vaultEnvironment(binding: CredentialBinding, env: NodeJS.ProcessEnv, options?: Pick<CredentialChainOptions, "keychain">): Promise<NodeJS.ProcessEnv>;
|
|
176
239
|
/** Internal child mode: the key travels only through an authenticated loopback request. */
|
|
177
240
|
export declare function deliverVaultCredential(): Promise<void>;
|
|
178
241
|
export {};
|
package/dist/domain.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type RoutingEvent } from "./model-policy-schema";
|
|
|
3
3
|
export { modelPolicySchema, routingEventSchema, routingEventsSchema } from "./model-policy-schema";
|
|
4
4
|
export type { ModelPolicy, RoutingEvent } from "./model-policy-schema";
|
|
5
5
|
export type { AuthStyle } from "./auth";
|
|
6
|
-
export declare const VERSION = "0.1.
|
|
6
|
+
export declare const VERSION = "0.1.4";
|
|
7
7
|
export declare const harnessSchema: z.ZodEnum<["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]>;
|
|
8
8
|
export declare const protocolSchema: z.ZodEnum<["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]>;
|
|
9
9
|
export declare const idSchema: z.ZodString;
|
package/dist/index.js
CHANGED
|
@@ -200,7 +200,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
// src/sdk.ts
|
|
203
|
-
import {
|
|
203
|
+
import { createClientTransport } from "@hasna/contracts/client";
|
|
204
204
|
|
|
205
205
|
// src/presets.ts
|
|
206
206
|
var route = (protocol, baseUrl, options = {}) => ({
|
|
@@ -361,6 +361,7 @@ function apiError(status, data, apiKey) {
|
|
|
361
361
|
|
|
362
362
|
class SwitcherClient {
|
|
363
363
|
options;
|
|
364
|
+
transport;
|
|
364
365
|
baseUrl;
|
|
365
366
|
constructor(options) {
|
|
366
367
|
this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
|
|
@@ -368,9 +369,50 @@ class SwitcherClient {
|
|
|
368
369
|
throw new Error("Switcher API key is required.");
|
|
369
370
|
this.options = { ...options };
|
|
370
371
|
}
|
|
372
|
+
static fromEnvironment(env = process.env, options = {}) {
|
|
373
|
+
const shared = createClientTransport("switcher", env, {
|
|
374
|
+
credentials: options.credentials,
|
|
375
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
376
|
+
retry: false,
|
|
377
|
+
fetchImpl: async (input, init) => {
|
|
378
|
+
const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
|
|
379
|
+
const key = new Headers(init?.headers).get("x-api-key") ?? "";
|
|
380
|
+
let response;
|
|
381
|
+
try {
|
|
382
|
+
response = await (options.fetch ?? fetch)(address, init);
|
|
383
|
+
} catch {
|
|
384
|
+
throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
|
|
385
|
+
}
|
|
386
|
+
if (response.status === 401 || response.status === 403) {
|
|
387
|
+
await response.body?.cancel().catch(() => {});
|
|
388
|
+
throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
|
|
389
|
+
}
|
|
390
|
+
let data;
|
|
391
|
+
try {
|
|
392
|
+
data = await boundedJson(response);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
|
|
395
|
+
}
|
|
396
|
+
if (!response.ok)
|
|
397
|
+
throw apiError(response.status, data, key);
|
|
398
|
+
return Response.json(data);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
|
|
402
|
+
throw new Error("Shared credential transport was not invoked.");
|
|
403
|
+
} });
|
|
404
|
+
client.transport = shared.client;
|
|
405
|
+
return client;
|
|
406
|
+
}
|
|
371
407
|
async request(method, path, body, options = {}) {
|
|
372
408
|
if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
|
|
373
409
|
throw new Error("Invalid API path.");
|
|
410
|
+
if (this.transport)
|
|
411
|
+
return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
|
|
412
|
+
idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
|
|
413
|
+
headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
|
|
414
|
+
retry: false
|
|
415
|
+
});
|
|
374
416
|
const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
|
|
375
417
|
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
376
418
|
throw new Error("Switcher API key is required.");
|
|
@@ -467,11 +509,8 @@ class SwitcherClient {
|
|
|
467
509
|
return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
|
|
468
510
|
}
|
|
469
511
|
}
|
|
470
|
-
function clientFromEnv(env = process.env) {
|
|
471
|
-
|
|
472
|
-
if (!env.HASNA_SWITCHER_API_URL || !credential())
|
|
473
|
-
throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
|
|
474
|
-
return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
|
|
512
|
+
function clientFromEnv(env = process.env, options = {}) {
|
|
513
|
+
return SwitcherClient.fromEnvironment(env, options);
|
|
475
514
|
}
|
|
476
515
|
export {
|
|
477
516
|
providerFromPreset,
|
package/dist/mcp/index.js
CHANGED
|
@@ -55,7 +55,7 @@ var routingEventSchema = z.object({
|
|
|
55
55
|
var routingEventsSchema = z.array(routingEventSchema).max(1000);
|
|
56
56
|
|
|
57
57
|
// src/domain.ts
|
|
58
|
-
var VERSION = "0.1.
|
|
58
|
+
var VERSION = "0.1.4";
|
|
59
59
|
var harnessSchema = z2.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
|
|
60
60
|
var protocolSchema = z2.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
|
|
61
61
|
var idSchema = z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
|
|
@@ -199,7 +199,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
// src/sdk.ts
|
|
202
|
-
import {
|
|
202
|
+
import { createClientTransport } from "@hasna/contracts/client";
|
|
203
203
|
|
|
204
204
|
// src/presets.ts
|
|
205
205
|
var route = (protocol, baseUrl, options = {}) => ({
|
|
@@ -321,6 +321,7 @@ function apiError(status, data, apiKey) {
|
|
|
321
321
|
|
|
322
322
|
class SwitcherClient {
|
|
323
323
|
options;
|
|
324
|
+
transport;
|
|
324
325
|
baseUrl;
|
|
325
326
|
constructor(options) {
|
|
326
327
|
this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
|
|
@@ -328,9 +329,50 @@ class SwitcherClient {
|
|
|
328
329
|
throw new Error("Switcher API key is required.");
|
|
329
330
|
this.options = { ...options };
|
|
330
331
|
}
|
|
332
|
+
static fromEnvironment(env = process.env, options = {}) {
|
|
333
|
+
const shared = createClientTransport("switcher", env, {
|
|
334
|
+
credentials: options.credentials,
|
|
335
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
336
|
+
retry: false,
|
|
337
|
+
fetchImpl: async (input, init) => {
|
|
338
|
+
const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
|
|
339
|
+
const key = new Headers(init?.headers).get("x-api-key") ?? "";
|
|
340
|
+
let response;
|
|
341
|
+
try {
|
|
342
|
+
response = await (options.fetch ?? fetch)(address, init);
|
|
343
|
+
} catch {
|
|
344
|
+
throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
|
|
345
|
+
}
|
|
346
|
+
if (response.status === 401 || response.status === 403) {
|
|
347
|
+
await response.body?.cancel().catch(() => {});
|
|
348
|
+
throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
|
|
349
|
+
}
|
|
350
|
+
let data;
|
|
351
|
+
try {
|
|
352
|
+
data = await boundedJson(response);
|
|
353
|
+
} catch {
|
|
354
|
+
throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
|
|
355
|
+
}
|
|
356
|
+
if (!response.ok)
|
|
357
|
+
throw apiError(response.status, data, key);
|
|
358
|
+
return Response.json(data);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
|
|
362
|
+
throw new Error("Shared credential transport was not invoked.");
|
|
363
|
+
} });
|
|
364
|
+
client.transport = shared.client;
|
|
365
|
+
return client;
|
|
366
|
+
}
|
|
331
367
|
async request(method, path, body, options = {}) {
|
|
332
368
|
if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
|
|
333
369
|
throw new Error("Invalid API path.");
|
|
370
|
+
if (this.transport)
|
|
371
|
+
return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
|
|
372
|
+
idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
|
|
373
|
+
headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
|
|
374
|
+
retry: false
|
|
375
|
+
});
|
|
334
376
|
const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
|
|
335
377
|
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
336
378
|
throw new Error("Switcher API key is required.");
|
|
@@ -427,11 +469,8 @@ class SwitcherClient {
|
|
|
427
469
|
return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
|
|
428
470
|
}
|
|
429
471
|
}
|
|
430
|
-
function clientFromEnv(env = process.env) {
|
|
431
|
-
|
|
432
|
-
if (!env.HASNA_SWITCHER_API_URL || !credential())
|
|
433
|
-
throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
|
|
434
|
-
return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
|
|
472
|
+
function clientFromEnv(env = process.env, options = {}) {
|
|
473
|
+
return SwitcherClient.fromEnvironment(env, options);
|
|
435
474
|
}
|
|
436
475
|
|
|
437
476
|
// src/mcp.ts
|
|
@@ -467,6 +506,6 @@ tool("runs_get", "Get launch metadata.", { id: z3.string() }, (p) => clientFromE
|
|
|
467
506
|
if (process.argv.includes("--version"))
|
|
468
507
|
console.log(VERSION);
|
|
469
508
|
else if (process.argv.includes("--help"))
|
|
470
|
-
console.log("switcher-mcp: authenticated Switcher API tools over MCP stdio.
|
|
509
|
+
console.log("switcher-mcp: authenticated Switcher API tools over MCP stdio. Resolves API URL/key through @hasna/contracts (Keychain, canonical config/credentials, or environment).");
|
|
471
510
|
else
|
|
472
511
|
await server.connect(new StdioServerTransport);
|
package/dist/sdk.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { components } from "./generated/api";
|
|
2
|
+
import { type CredentialChainOptions } from "@hasna/contracts/client";
|
|
2
3
|
export { providerFromPreset } from "./presets";
|
|
3
4
|
export type ProviderPreset = components["schemas"]["ProviderPreset"];
|
|
4
5
|
export type ProviderInput = components["schemas"]["ProviderInput"];
|
|
@@ -29,10 +30,18 @@ export type ClientOptions = {
|
|
|
29
30
|
fetch?: typeof fetch;
|
|
30
31
|
timeoutMs?: number;
|
|
31
32
|
};
|
|
33
|
+
export type EnvironmentClientOptions = {
|
|
34
|
+
credentials?: CredentialChainOptions;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
};
|
|
32
38
|
export declare class SwitcherClient {
|
|
33
39
|
private readonly options;
|
|
40
|
+
private transport?;
|
|
34
41
|
readonly baseUrl: string;
|
|
35
42
|
constructor(options: ClientOptions);
|
|
43
|
+
/** Use Contracts' paired authority/credential resolution on every request. */
|
|
44
|
+
static fromEnvironment(env?: Record<string, string | undefined>, options?: EnvironmentClientOptions): SwitcherClient;
|
|
36
45
|
request<T>(method: string, path: string, body?: unknown, options?: {
|
|
37
46
|
version?: number;
|
|
38
47
|
idempotencyKey?: string;
|
|
@@ -617,4 +626,4 @@ export declare class SwitcherClient {
|
|
|
617
626
|
routingEventsDropped?: number;
|
|
618
627
|
}>;
|
|
619
628
|
}
|
|
620
|
-
export declare function clientFromEnv(env?: Record<string, string | undefined
|
|
629
|
+
export declare function clientFromEnv(env?: Record<string, string | undefined>, options?: EnvironmentClientOptions): SwitcherClient;
|
package/dist/sdk.js
CHANGED
|
@@ -200,7 +200,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
// src/sdk.ts
|
|
203
|
-
import {
|
|
203
|
+
import { createClientTransport } from "@hasna/contracts/client";
|
|
204
204
|
|
|
205
205
|
// src/presets.ts
|
|
206
206
|
var route = (protocol, baseUrl, options = {}) => ({
|
|
@@ -361,6 +361,7 @@ function apiError(status, data, apiKey) {
|
|
|
361
361
|
|
|
362
362
|
class SwitcherClient {
|
|
363
363
|
options;
|
|
364
|
+
transport;
|
|
364
365
|
baseUrl;
|
|
365
366
|
constructor(options) {
|
|
366
367
|
this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
|
|
@@ -368,9 +369,50 @@ class SwitcherClient {
|
|
|
368
369
|
throw new Error("Switcher API key is required.");
|
|
369
370
|
this.options = { ...options };
|
|
370
371
|
}
|
|
372
|
+
static fromEnvironment(env = process.env, options = {}) {
|
|
373
|
+
const shared = createClientTransport("switcher", env, {
|
|
374
|
+
credentials: options.credentials,
|
|
375
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
376
|
+
retry: false,
|
|
377
|
+
fetchImpl: async (input, init) => {
|
|
378
|
+
const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
|
|
379
|
+
const key = new Headers(init?.headers).get("x-api-key") ?? "";
|
|
380
|
+
let response;
|
|
381
|
+
try {
|
|
382
|
+
response = await (options.fetch ?? fetch)(address, init);
|
|
383
|
+
} catch {
|
|
384
|
+
throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
|
|
385
|
+
}
|
|
386
|
+
if (response.status === 401 || response.status === 403) {
|
|
387
|
+
await response.body?.cancel().catch(() => {});
|
|
388
|
+
throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
|
|
389
|
+
}
|
|
390
|
+
let data;
|
|
391
|
+
try {
|
|
392
|
+
data = await boundedJson(response);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
|
|
395
|
+
}
|
|
396
|
+
if (!response.ok)
|
|
397
|
+
throw apiError(response.status, data, key);
|
|
398
|
+
return Response.json(data);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
|
|
402
|
+
throw new Error("Shared credential transport was not invoked.");
|
|
403
|
+
} });
|
|
404
|
+
client.transport = shared.client;
|
|
405
|
+
return client;
|
|
406
|
+
}
|
|
371
407
|
async request(method, path, body, options = {}) {
|
|
372
408
|
if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
|
|
373
409
|
throw new Error("Invalid API path.");
|
|
410
|
+
if (this.transport)
|
|
411
|
+
return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
|
|
412
|
+
idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
|
|
413
|
+
headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
|
|
414
|
+
retry: false
|
|
415
|
+
});
|
|
374
416
|
const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
|
|
375
417
|
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
376
418
|
throw new Error("Switcher API key is required.");
|
|
@@ -467,11 +509,8 @@ class SwitcherClient {
|
|
|
467
509
|
return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
|
|
468
510
|
}
|
|
469
511
|
}
|
|
470
|
-
function clientFromEnv(env = process.env) {
|
|
471
|
-
|
|
472
|
-
if (!env.HASNA_SWITCHER_API_URL || !credential())
|
|
473
|
-
throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
|
|
474
|
-
return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
|
|
512
|
+
function clientFromEnv(env = process.env, options = {}) {
|
|
513
|
+
return SwitcherClient.fromEnvironment(env, options);
|
|
475
514
|
}
|
|
476
515
|
export {
|
|
477
516
|
providerFromPreset,
|
package/dist/serve/index.js
CHANGED
|
@@ -62,7 +62,7 @@ function canonicalPolicyJSON(value) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// src/domain.ts
|
|
65
|
-
var VERSION = "0.1.
|
|
65
|
+
var VERSION = "0.1.4";
|
|
66
66
|
var harnessSchema = z2.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
|
|
67
67
|
var protocolSchema = z2.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
|
|
68
68
|
var idSchema = z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
|
|
@@ -808,7 +808,7 @@ var openapi_default = {
|
|
|
808
808
|
openapi: "3.0.3",
|
|
809
809
|
info: {
|
|
810
810
|
title: "Switcher API",
|
|
811
|
-
version: "0.1.
|
|
811
|
+
version: "0.1.4",
|
|
812
812
|
description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
|
|
813
813
|
},
|
|
814
814
|
security: [
|
package/openapi.json
CHANGED
package/package.json
CHANGED