@oscharko-dev/keiko 0.1.3 → 0.1.5

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 CHANGED
@@ -1,8 +1,22 @@
1
1
  # Keiko
2
2
 
3
- Keiko is a local enterprise coding assistant for regulated engineering teams. It helps inspect a repository, chat with configured language models, generate reviewable unit tests, investigate bugs, run verification, and keep redacted evidence for human review.
3
+ Keiko is a governed agentic workspace for knowledge work that learns from experience.
4
4
 
5
- Keiko is developer-controlled by design. It does not commit, push, open pull requests, merge code, or apply changes without an explicit local action. The manifest-producing surfaces emit redacted evidence for audit.
5
+ The current npm release starts with local developer-assist workflows for regulated engineering teams. Keiko helps inspect a repository, chat with configured language models, generate reviewable unit tests, investigate bugs, run verification, and keep redacted evidence for human review.
6
+
7
+ Keiko is human-controlled by design. It does not commit, push, open pull requests, merge code, or apply changes without an explicit local action. The manifest-producing surfaces emit redacted evidence for audit.
8
+
9
+ ## Vision
10
+
11
+ Keiko's long-term direction is a governed workspace where people can delegate knowledge work to learning agents without giving up control, oversight, or accountability.
12
+
13
+ - **Governed delegation:** agents start with a task, not standing rights.
14
+ - **Harness-first control:** agent actions, tool calls, connector access, approvals, failures, and outcomes flow through one observable control layer.
15
+ - **Keiko Twin:** a governed work representative that can build controlled memory about user preferences, project routines, accepted outcomes, and recurring corrections.
16
+ - **Learning from experience:** Keiko should improve future tool selection, escalation, policy suggestions, and workflow quality from structured evidence and feedback.
17
+ - **Enterprise boundaries:** learning can improve suggestions and routines, but it must never grant itself authority or bypass human and organizational policy.
18
+
19
+ Software engineering is the first use case because repositories, tests, reviews, and tool calls create hard evidence. The product direction is broader: a controlled agentic workspace for enterprise knowledge work.
6
20
 
7
21
  ## Requirements
8
22
 
@@ -45,8 +59,12 @@ If no model gateway is configured, the UI asks for:
45
59
 
46
60
  - Base URL, for example `https://llm-gateway.example.com/v1`
47
61
  - API token
62
+ - Optional API-key header, only when your gateway admin provides a custom header
63
+ - Deployment names, only when the gateway cannot expose a reliable model list
64
+
65
+ Keiko calls the gateway model list endpoint, tests discovered chat models with a small chat-completions request, and stores only callable chat models in the local runtime configuration. LiteLLM-compatible gateways can also provide model metadata that lets Keiko filter non-chat models before testing. Credentials stay on the local machine and are not returned to the browser.
48
66
 
49
- Keiko calls the gateway model list endpoint, tests discovered chat models with a small chat-completions request, and stores only callable chat models in the local runtime configuration. Credentials stay on the local machine and are not returned to the browser.
67
+ For OpenAI-compatible gateways such as LiteLLM, usually leave deployment names empty. For Azure AI Foundry, paste the deployment names you want Keiko to offer in the UI.
50
68
 
51
69
  The UI runs on loopback only. The `--host` option can validate a loopback host value; the server always binds `127.0.0.1`.
52
70
 
@@ -89,7 +107,8 @@ The UI can create a local runtime config during first-run setup. For scripted us
89
107
  {
90
108
  "modelId": "example-chat-model",
91
109
  "baseUrl": "https://llm-gateway.example.com/v1",
92
- "apiKey": "replace-me"
110
+ "apiKey": "replace-me",
111
+ "apiKeyHeaderName": "authorization"
93
112
  }
94
113
  ]
95
114
  }
@@ -97,14 +116,18 @@ The UI can create a local runtime config during first-run setup. For scripted us
97
116
 
98
117
  Environment variables can override file values:
99
118
 
100
- | Variable | Purpose |
101
- | --------------------------- | ------------------------------ |
102
- | `KEIKO_CONFIG_FILE` | Path to a gateway config file. |
103
- | `KEIKO_DEFAULT_BASE_URL` | Fallback gateway base URL. |
104
- | `KEIKO_DEFAULT_API_KEY` | Fallback gateway API token. |
105
- | `KEIKO_MODEL_<ID>_BASE_URL` | Per-model base URL override. |
106
- | `KEIKO_MODEL_<ID>_API_KEY` | Per-model API token override. |
107
- | `KEIKO_UI_PORT` | Local UI port override. |
119
+ | Variable | Purpose |
120
+ | -------------------------------------- | --------------------------------- |
121
+ | `KEIKO_CONFIG_FILE` | Path to a gateway config file. |
122
+ | `KEIKO_DEFAULT_BASE_URL` | Fallback gateway base URL. |
123
+ | `KEIKO_DEFAULT_API_KEY` | Fallback gateway API token. |
124
+ | `KEIKO_DEFAULT_API_KEY_HEADER_NAME` | Fallback credential header name. |
125
+ | `KEIKO_MODEL_<ID>_BASE_URL` | Per-model base URL override. |
126
+ | `KEIKO_MODEL_<ID>_API_KEY` | Per-model API token override. |
127
+ | `KEIKO_MODEL_<ID>_API_KEY_HEADER_NAME` | Per-model credential header name. |
128
+ | `KEIKO_UI_PORT` | Local UI port override. |
129
+
130
+ Supported credential headers are `authorization`, `x-litellm-key`, `x-api-key`, and `api-key`.
108
131
 
109
132
  Do not commit gateway config files, API tokens, `.keiko/`, or evidence that contains project-specific review material unless your process explicitly requires it.
110
133
 
@@ -129,13 +152,14 @@ Known limits:
129
152
 
130
153
  ## Troubleshooting
131
154
 
132
- | Symptom | Check |
133
- | --------------------- | ------------------------------------------------------------------------------------------------------- |
134
- | UI does not open | Run `npm run keiko:status`, then inspect `.keiko/ui.log`. |
135
- | Port is busy | Start with `KEIKO_UI_PORT=1984 npm run keiko:start` or stop the process using the port. |
136
- | No model appears | Reopen Settings, verify the base URL and token, then run the credential test again. |
137
- | Credential test fails | Confirm the gateway accepts OpenAI-compatible chat-completions requests at the configured base URL. |
138
- | Stale process state | Run `npm run keiko:stop`, delete `.keiko/ui.pid` if the process is no longer running, then start again. |
155
+ | Symptom | Check |
156
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
157
+ | UI does not open | Run `npm run keiko:status`, then inspect `.keiko/ui.log`. |
158
+ | Port is busy | Start with `KEIKO_UI_PORT=1984 npm run keiko:start` or stop the process using the port. |
159
+ | No model appears | Reopen Settings, verify the base URL and token, then run the credential test again. |
160
+ | Credential test fails | Confirm the gateway accepts OpenAI-compatible chat-completions requests at the configured base URL. |
161
+ | Custom proxy key fails | Confirm whether your gateway expects `Authorization` or a custom API-key header such as `X-Litellm-Key`. |
162
+ | Stale process state | Run `npm run keiko:stop`, delete `.keiko/ui.pid` if the process is no longer running, then start again. |
139
163
 
140
164
  ## Further Reading
141
165
 
@@ -2,6 +2,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write
2
2
  import { spawn } from "node:child_process";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { SDK_VERSION } from "../sdk/index.js";
5
6
  import { DEFAULT_UI_PORT, UI_HOST } from "../ui/index.js";
6
7
  const ALLOWED_HOSTS = new Set(["127.0.0.1", "localhost"]);
7
8
  const LIFECYCLE_FLAG_SETTERS = {
@@ -106,6 +107,33 @@ function logFile(options) {
106
107
  function healthUrl(options) {
107
108
  return `http://${options.host}:${String(options.port)}/api/health`;
108
109
  }
110
+ function healthVersion(payload) {
111
+ if (typeof payload !== "object" || payload === null)
112
+ return undefined;
113
+ const version = payload.version;
114
+ return typeof version === "string" ? version : undefined;
115
+ }
116
+ async function probeHealth(options, fetchImpl) {
117
+ try {
118
+ const response = await fetchImpl(healthUrl(options), {
119
+ signal: AbortSignal.timeout(1_000),
120
+ });
121
+ if (!response.ok) {
122
+ return { reachable: false, version: undefined };
123
+ }
124
+ let body;
125
+ try {
126
+ body = await response.json();
127
+ }
128
+ catch {
129
+ return { reachable: true, version: undefined };
130
+ }
131
+ return { reachable: true, version: healthVersion(body) };
132
+ }
133
+ catch {
134
+ return { reachable: false, version: undefined };
135
+ }
136
+ }
109
137
  function readPid(path) {
110
138
  if (!existsSync(path))
111
139
  return undefined;
@@ -195,8 +223,20 @@ async function waitForHealth(options, pid, deps) {
195
223
  async function cmdStart(options, io, env, deps, cwd) {
196
224
  const running = runningPid(options, deps.isProcessAlive);
197
225
  if (running !== undefined) {
198
- io.out(`Keiko UI already running on ${healthUrl(options).replace("/api/health", "")} (pid ${String(running)}).\n`);
199
- return 0;
226
+ const health = await probeHealth(options, deps.fetchImpl);
227
+ if (health.version === SDK_VERSION) {
228
+ io.out(`Keiko UI already running on ${healthUrl(options).replace("/api/health", "")} (pid ${String(running)}).\n`);
229
+ return 0;
230
+ }
231
+ const reason = !health.reachable
232
+ ? "health check is unreachable"
233
+ : health.version === undefined
234
+ ? "health check did not return the current Keiko version"
235
+ : `running version ${health.version} differs from installed version ${SDK_VERSION}`;
236
+ io.out(`Keiko UI process is stale (${reason}); restarting pid ${String(running)}.\n`);
237
+ const stopped = await cmdStop(options, io, deps);
238
+ if (stopped !== 0)
239
+ return stopped;
200
240
  }
201
241
  const { child, logPath } = spawnUiProcess(options, env, deps, cwd);
202
242
  if (child.pid === undefined) {
@@ -204,7 +244,7 @@ async function cmdStart(options, io, env, deps, cwd) {
204
244
  return 1;
205
245
  }
206
246
  child.unref();
207
- writeFileSync(pidFile(options), `${String(child.pid)}\n`, "utf8");
247
+ writeFileSync(pidFile(options), `${String(child.pid)}\n`, { encoding: "utf8", mode: 0o600 });
208
248
  io.out(`Starting Keiko UI on ${healthUrl(options).replace("/api/health", "")} ...\n`);
209
249
  const healthy = await waitForHealth(options, child.pid, deps);
210
250
  if (healthy) {
@@ -1,7 +1,10 @@
1
1
  import type { CircuitBreakerConfig, GatewayConfig, ModelCapability } from "./types.js";
2
+ export declare const DEFAULT_API_KEY_HEADER_NAME = "authorization";
3
+ export declare const SUPPORTED_API_KEY_HEADER_NAMES: readonly ["authorization", "x-litellm-key", "x-api-key", "api-key"];
2
4
  export type EnvSource = Readonly<Record<string, string | undefined>>;
3
5
  export interface SafeProviderConfig {
4
6
  readonly modelId: string;
7
+ readonly credentialHeaderName: string;
5
8
  readonly timeoutMs: number;
6
9
  readonly maxRetries: number;
7
10
  readonly retryBaseDelayMs: number;
@@ -11,6 +14,9 @@ export interface SafeGatewayConfig {
11
14
  readonly circuitBreaker: CircuitBreakerConfig;
12
15
  readonly capabilities?: readonly ModelCapability[] | undefined;
13
16
  }
17
+ export declare function normalizeApiKeyHeaderName(value: unknown, path: string, fallback?: string): string;
18
+ export declare function apiKeyHeaderValue(headerName: string, apiKey: string): string;
19
+ export declare function validateBaseUrl(baseUrl: string, path: string): void;
14
20
  export declare function parseGatewayConfig(raw: unknown, env?: EnvSource): GatewayConfig;
15
21
  export declare function loadConfigFromFile(path: string, env?: EnvSource): GatewayConfig;
16
22
  export declare function toSafeObject(config: GatewayConfig): SafeGatewayConfig;
@@ -3,6 +3,7 @@
3
3
  // API keys are sourced only from environment or the config file, never CLI flags,
4
4
  // and are excluded from every serialisation path.
5
5
  import { readFileSync } from "node:fs";
6
+ import { isIP } from "node:net";
6
7
  import { ConfigInvalidError } from "./errors.js";
7
8
  const DEFAULT_TIMEOUT_MS = 30_000;
8
9
  const DEFAULT_MAX_RETRIES = 3;
@@ -10,6 +11,20 @@ const DEFAULT_RETRY_BASE_DELAY_MS = 500;
10
11
  const DEFAULT_FAILURE_THRESHOLD = 5;
11
12
  const DEFAULT_COOLDOWN_MS = 30_000;
12
13
  const DEFAULT_HALF_OPEN_PROBES = 2;
14
+ export const DEFAULT_API_KEY_HEADER_NAME = "authorization";
15
+ const MAX_API_KEY_HEADER_NAME_LENGTH = 64;
16
+ const API_KEY_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
17
+ export const SUPPORTED_API_KEY_HEADER_NAMES = [
18
+ DEFAULT_API_KEY_HEADER_NAME,
19
+ "x-litellm-key",
20
+ "x-api-key",
21
+ "api-key",
22
+ ];
23
+ const SUPPORTED_API_KEY_HEADER_NAME_SET = new Set(SUPPORTED_API_KEY_HEADER_NAMES);
24
+ const BEARER_API_KEY_HEADER_NAME_SET = new Set([
25
+ DEFAULT_API_KEY_HEADER_NAME,
26
+ "x-litellm-key",
27
+ ]);
13
28
  function isRecord(value) {
14
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
15
30
  }
@@ -58,6 +73,33 @@ function optionalNonEmptyString(value, path, fallback) {
58
73
  }
59
74
  return requireNonEmptyString(value, path);
60
75
  }
76
+ export function normalizeApiKeyHeaderName(value, path, fallback = DEFAULT_API_KEY_HEADER_NAME) {
77
+ if (value === undefined) {
78
+ return fallback;
79
+ }
80
+ if (typeof value !== "string") {
81
+ throw new ConfigInvalidError(`${path} must be a string`);
82
+ }
83
+ const headerName = value.trim().toLowerCase();
84
+ if (headerName.length === 0) {
85
+ return fallback;
86
+ }
87
+ if (headerName.length > MAX_API_KEY_HEADER_NAME_LENGTH ||
88
+ !API_KEY_HEADER_NAME_RE.test(headerName)) {
89
+ throw new ConfigInvalidError(`${path} must be a valid HTTP header name`);
90
+ }
91
+ if (!SUPPORTED_API_KEY_HEADER_NAME_SET.has(headerName)) {
92
+ throw new ConfigInvalidError(`${path} must be one of ${SUPPORTED_API_KEY_HEADER_NAMES.join(", ")}`);
93
+ }
94
+ return headerName;
95
+ }
96
+ export function apiKeyHeaderValue(headerName, apiKey) {
97
+ if (BEARER_API_KEY_HEADER_NAME_SET.has(headerName) &&
98
+ !apiKey.toLowerCase().startsWith("bearer ")) {
99
+ return `Bearer ${apiKey}`;
100
+ }
101
+ return apiKey;
102
+ }
61
103
  function requireEnum(value, path, allowed) {
62
104
  if (typeof value !== "string" || !allowed.includes(value)) {
63
105
  throw new ConfigInvalidError(`${path} must be one of ${allowed.join(", ")}`);
@@ -79,17 +121,32 @@ function resolveSecret(modelId, fileValue, env, suffix) {
79
121
  const fallback = env[`KEIKO_DEFAULT_${suffix}`];
80
122
  return fallback ?? "";
81
123
  }
124
+ function resolveApiKeyHeaderName(rawValue, path, modelId, env) {
125
+ const token = envModelToken(modelId);
126
+ const perModelName = `KEIKO_MODEL_${token}_API_KEY_HEADER_NAME`;
127
+ const perModel = env[perModelName];
128
+ if (perModel !== undefined && perModel.length > 0) {
129
+ return normalizeApiKeyHeaderName(perModel, perModelName);
130
+ }
131
+ if (rawValue !== undefined) {
132
+ return normalizeApiKeyHeaderName(rawValue, path);
133
+ }
134
+ return normalizeApiKeyHeaderName(env.KEIKO_DEFAULT_API_KEY_HEADER_NAME, "KEIKO_DEFAULT_API_KEY_HEADER_NAME");
135
+ }
82
136
  // Validates a resolved baseUrl for scheme and credential hygiene. Host/IP is
83
137
  // intentionally NOT restricted: Keiko addresses private network endpoints
84
138
  // (private IPs are a valid, first-class target); this guard is scheme/credential
85
139
  // hygiene + defence-in-depth, not host filtering.
86
140
  function isLoopbackHost(hostname) {
87
- return (hostname === "localhost" ||
88
- hostname === "::1" ||
89
- hostname === "[::1]" ||
90
- hostname.startsWith("127."));
141
+ if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
142
+ return true;
143
+ }
144
+ // Real IPv4 loopback only. isIP === 4 guarantees a well-formed dotted-quad, so a "127." prefix
145
+ // here is the 127.0.0.0/8 block — never a domain such as "127.evil.com" or "127.0.0.1.evil.com".
146
+ // The WHATWG URL parser has already canonicalised IPv4 shorthand/hex into url.hostname.
147
+ return isIP(hostname) === 4 && hostname.startsWith("127.");
91
148
  }
92
- function validateBaseUrl(baseUrl, path) {
149
+ export function validateBaseUrl(baseUrl, path) {
93
150
  let url;
94
151
  try {
95
152
  url = new URL(baseUrl);
@@ -100,6 +157,9 @@ function validateBaseUrl(baseUrl, path) {
100
157
  if (url.protocol !== "http:" && url.protocol !== "https:") {
101
158
  throw new ConfigInvalidError(`${path}.baseUrl must use the http or https scheme`);
102
159
  }
160
+ if (url.search !== "" || url.hash !== "") {
161
+ throw new ConfigInvalidError(`${path}.baseUrl must not contain a query string or fragment`);
162
+ }
103
163
  if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
104
164
  throw new ConfigInvalidError(`${path}.baseUrl must use https unless it targets localhost or loopback`);
105
165
  }
@@ -161,6 +221,7 @@ function parseProviderConfig(raw, path, modelId, env) {
161
221
  modelId,
162
222
  baseUrl,
163
223
  apiKey,
224
+ apiKeyHeaderName: resolveApiKeyHeaderName(raw.apiKeyHeaderName, `${path}.apiKeyHeaderName`, modelId, env),
164
225
  timeoutMs: requirePositiveInt(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS, `${path}.timeoutMs`),
165
226
  maxRetries: requireNonNegativeInt(raw.maxRetries ?? DEFAULT_MAX_RETRIES, `${path}.maxRetries`),
166
227
  retryBaseDelayMs: requirePositiveInt(raw.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS, `${path}.retryBaseDelayMs`),
@@ -233,6 +294,7 @@ export function toSafeObject(config) {
233
294
  return {
234
295
  providers: config.providers.map((provider) => ({
235
296
  modelId: provider.modelId,
297
+ credentialHeaderName: provider.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME,
236
298
  timeoutMs: provider.timeoutMs,
237
299
  maxRetries: provider.maxRetries,
238
300
  retryBaseDelayMs: provider.retryBaseDelayMs,
@@ -1,6 +1,8 @@
1
+ export declare const MAX_RESPONSE_BYTES = 10000000;
1
2
  export interface GatewayFetchOptions extends RequestInit {
2
3
  readonly fetchImpl?: typeof fetch | undefined;
3
4
  readonly useCaFallback?: boolean | undefined;
4
5
  }
5
6
  export declare function isMissingIssuerError(error: unknown): boolean;
6
7
  export declare function gatewayFetch(url: string, options?: GatewayFetchOptions): Promise<Response>;
8
+ export declare function readJsonCapped(response: Response, maxBytes?: number): Promise<unknown>;
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { rootCertificates } from "node:tls";
4
+ // Caps a single gateway response at 10 MB; real chat completions are far smaller.
5
+ export const MAX_RESPONSE_BYTES = 10_000_000;
4
6
  function headersFromNode(headers) {
5
7
  const out = new Headers();
6
8
  for (const [name, value] of Object.entries(headers)) {
@@ -80,7 +82,14 @@ function fetchWithCaBundle(url, init) {
80
82
  signal: init.signal ?? undefined,
81
83
  }, (res) => {
82
84
  const chunks = [];
85
+ let total = 0;
83
86
  res.on("data", (chunk) => {
87
+ total += chunk.length;
88
+ if (total > MAX_RESPONSE_BYTES) {
89
+ req.destroy();
90
+ reject(new Error("gateway response exceeded the size limit"));
91
+ return;
92
+ }
84
93
  chunks.push(chunk);
85
94
  });
86
95
  res.on("end", () => {
@@ -109,3 +118,25 @@ export async function gatewayFetch(url, options = {}) {
109
118
  throw error;
110
119
  }
111
120
  }
121
+ export async function readJsonCapped(response, maxBytes = MAX_RESPONSE_BYTES) {
122
+ if (response.body === null) {
123
+ return response.json();
124
+ }
125
+ const reader = response.body.getReader();
126
+ const decoder = new TextDecoder();
127
+ const parts = [];
128
+ let total = 0;
129
+ for (;;) {
130
+ const { done, value } = await reader.read();
131
+ if (done)
132
+ break;
133
+ total += value.byteLength;
134
+ if (total > maxBytes) {
135
+ await reader.cancel();
136
+ throw new Error("response body exceeded the size limit");
137
+ }
138
+ parts.push(decoder.decode(value, { stream: true }));
139
+ }
140
+ parts.push(decoder.decode());
141
+ return JSON.parse(parts.join(""));
142
+ }
@@ -1,6 +1,6 @@
1
1
  export type { CircuitBreakerConfig, CircuitBreakerStatus, CircuitState, ChatMessage, Clock, CostClass, FinishReason, GatewayConfig, GatewayRequest, LatencyClass, ModelCapability, ModelKind, ModelProviderConfig, NormalizedResponse, NormalizedToolCall, ProviderAdapter, ResponseFormat, StreamDelta, StreamEvent, ToolDefinition, UsageMetadata, } from "./types.js";
2
2
  export { CAPABILITY_REGISTRY, createDefaultChatCapability, findCapability, listCapabilities, selectCheapest, type CapabilityQuery, } from "./capabilities.js";
3
- export { loadConfigFromFile, parseGatewayConfig, toSafeObject, type EnvSource, type SafeGatewayConfig, type SafeProviderConfig, } from "./config.js";
3
+ export { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME, loadConfigFromFile, normalizeApiKeyHeaderName, parseGatewayConfig, toSafeObject, validateBaseUrl, type EnvSource, type SafeGatewayConfig, type SafeProviderConfig, } from "./config.js";
4
4
  export { Gateway, type GatewayDeps } from "./gateway.js";
5
5
  export { OpenAiAdapter, type AdapterDeps } from "./openai-adapter.js";
6
6
  export { assertConfiguredModel, findConfiguredCapability, listConfiguredCapabilities, selectConfiguredModel, type ModelSelectionQuery, } from "./model-selection.js";
@@ -1,7 +1,7 @@
1
1
  // Public barrel for the model gateway: all types, the Gateway orchestrator, the
2
2
  // capability registry helpers, config loaders, and the typed error taxonomy.
3
3
  export { CAPABILITY_REGISTRY, createDefaultChatCapability, findCapability, listCapabilities, selectCheapest, } from "./capabilities.js";
4
- export { loadConfigFromFile, parseGatewayConfig, toSafeObject, } from "./config.js";
4
+ export { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME, loadConfigFromFile, normalizeApiKeyHeaderName, parseGatewayConfig, toSafeObject, validateBaseUrl, } from "./config.js";
5
5
  export { Gateway } from "./gateway.js";
6
6
  export { OpenAiAdapter } from "./openai-adapter.js";
7
7
  export { assertConfiguredModel, findConfiguredCapability, listConfiguredCapabilities, selectConfiguredModel, } from "./model-selection.js";
@@ -3,7 +3,8 @@
3
3
  // with no network I/O and no real time. The raw provider body is never echoed into
4
4
  // an error; only a redacted, status-level summary is surfaced.
5
5
  import { AuthenticationError, CancelledError, ContextOverflowError, ModelRefusalError, ProviderError, RateLimitError, TimeoutError, TransportError, } from "./errors.js";
6
- import { gatewayFetch } from "./http.js";
6
+ import { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME } from "./config.js";
7
+ import { gatewayFetch, readJsonCapped } from "./http.js";
7
8
  import { normalizeChatResponse } from "./normalize.js";
8
9
  import { redact } from "./redaction.js";
9
10
  function buildMessage(message) {
@@ -116,6 +117,10 @@ function mapHttpError(response, modelId, secrets, payload) {
116
117
  }
117
118
  throw new ProviderError(`provider returned HTTP ${String(response.status)} for '${modelId}'`, response.status, secrets);
118
119
  }
120
+ function apiKeyHeaders(config) {
121
+ const headerName = config.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME;
122
+ return { [headerName]: apiKeyHeaderValue(headerName, config.apiKey) };
123
+ }
119
124
  export class OpenAiAdapter {
120
125
  deps;
121
126
  now;
@@ -149,7 +154,7 @@ export class OpenAiAdapter {
149
154
  const body = JSON.stringify(buildBody(request));
150
155
  const headers = {
151
156
  "content-type": "application/json",
152
- authorization: `Bearer ${config.apiKey}`,
157
+ ...apiKeyHeaders(config),
153
158
  };
154
159
  try {
155
160
  return await gatewayFetch(url, {
@@ -178,7 +183,7 @@ export class OpenAiAdapter {
178
183
  }
179
184
  async readBody(response, config, secrets) {
180
185
  try {
181
- return await response.json();
186
+ return await readJsonCapped(response);
182
187
  }
183
188
  catch {
184
189
  throw new TransportError(`provider sent an unreadable body for '${config.modelId}'`, secrets);
@@ -186,7 +191,7 @@ export class OpenAiAdapter {
186
191
  }
187
192
  async readErrorBody(response) {
188
193
  try {
189
- return await response.json();
194
+ return await readJsonCapped(response);
190
195
  }
191
196
  catch {
192
197
  return null;
@@ -19,6 +19,7 @@ export interface ModelProviderConfig {
19
19
  readonly modelId: string;
20
20
  readonly baseUrl: string;
21
21
  readonly apiKey: string;
22
+ readonly apiKeyHeaderName?: string | undefined;
22
23
  readonly timeoutMs: number;
23
24
  readonly maxRetries: number;
24
25
  readonly retryBaseDelayMs: number;
@@ -1,7 +1,7 @@
1
1
  import type { Clock } from "../gateway/types.js";
2
2
  import type { EventSink, Fingerprinter, IdSource, ModelPort, ToolPort } from "./ports.js";
3
3
  import { type HarnessLimits, type RunResult, type TaskInput } from "./types.js";
4
- export declare const HARNESS_VERSION = "0.1.3";
4
+ export declare const HARNESS_VERSION = "0.1.5";
5
5
  export interface AgentConfig {
6
6
  readonly model: string;
7
7
  readonly workingDirectory: string;
@@ -10,7 +10,7 @@ import { runLoop } from "./loop.js";
10
10
  import { MemoryEventSink } from "./sinks.js";
11
11
  import { resolveTaskPlan } from "./tasks/policy.js";
12
12
  import { DEFAULT_LIMITS, } from "./types.js";
13
- export const HARNESS_VERSION = "0.1.3";
13
+ export const HARNESS_VERSION = "0.1.5";
14
14
  function resolveLimits(config) {
15
15
  return { ...DEFAULT_LIMITS, ...config.limits };
16
16
  }
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "0.1.3";
1
+ export declare const SDK_VERSION = "0.1.5";
2
2
  export { createSession, type AgentConfig, type AgentSession, type HarnessDeps, type RunResult, type TaskInput, type TaskType, } from "../harness/index.js";
3
3
  export { runAgent, type SdkAgentConfig, type SdkEvidenceOptions } from "./run-agent.js";
4
4
  export { buildWorkspaceSummary, detectWorkspace, summarizeForAudit, type AuditEntry, type AuditSummary, type ContextEntrySummary, type ContextPackSummary, type WorkspaceInfo, type WorkspaceSummary, } from "../workspace/index.js";
package/dist/sdk/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Single-sourced package version; CLI and SDK both read this to avoid drift.
2
- export const SDK_VERSION = "0.1.3";
2
+ export const SDK_VERSION = "0.1.5";
3
3
  // The typed agent surface. AgentConfig, the session factory, the run result, and the
4
4
  // session handle all live in the harness module (ADR-0004); the SDK re-exports them so
5
5
  // callers import the agent API from one place.
@@ -75,6 +75,21 @@ export const DEFAULT_COMMAND_RULES = Object.freeze([
75
75
  "--namespace",
76
76
  "--exec-path",
77
77
  ]),
78
+ // Deny git's code-execution / external-driver flags. `git -c diff.external=<cmd> diff` (and
79
+ // --config-env/--ext-diff/--textconv) make git spawn an arbitrary command via its OWN shell,
80
+ // defeating the Node spawn's shell:false; --exec-path redirects git to attacker-supplied sub-binaries.
81
+ // hasDeniedFlag runs BEFORE subcommand resolution and matches both `--flag value` and
82
+ // `--flag=value`. `-C`/--git-dir/--work-tree stay value-flags (location only, not execution).
83
+ denyFlags: Object.freeze([
84
+ "-c",
85
+ "--config-env",
86
+ "--exec-path",
87
+ "--ext-diff",
88
+ "--textconv",
89
+ "--no-index",
90
+ "--output",
91
+ "--contents",
92
+ ]),
78
93
  },
79
94
  ]);
80
95
  export const DEFAULT_PATCH_LIMITS = {
@@ -1,17 +1,17 @@
1
1
  [
2
2
  "'sha256-6JBR+C4qigE40tMbninopqoSguR9H/AhsfWgllRr6y0='",
3
- "'sha256-DSZruQ2vcS+6pG958yMjEei/9gtD6NyThy7uKiGoiyg='",
4
3
  "'sha256-FhLHRUQz4c4ntLU9VkfEesX7PnzNLENSe/16Hi523Kk='",
5
4
  "'sha256-NMmsYxPlvKu6BMNDUuiUA/0HWXXhODWSkUJ3CrerHAI='",
6
5
  "'sha256-OBTN3RiyCV4Bq7dFqZ5a2pAXjnCcCYeTJMO2I/LYKeo='",
7
- "'sha256-PbnZrJeiluw2LIBQCiPtFtG9jmUCwypFCLKHbgBtUlw='",
6
+ "'sha256-RgGBVXhSba32fqKyKK1LJ4hBlUyuzsh4X4Wz8X/2ViQ='",
8
7
  "'sha256-U9W+ZoRW19rf6ohEfUh2oSN8UmJ8mZjCoxp31AbEGYM='",
9
- "'sha256-ZyoCerHb/k3dCxBy25xejgU3nfPTwNTF4yBHXaS2sns='",
8
+ "'sha256-Zh9WKxSpBbdbuXpYaiD8KL+3eIzvCyeUVc9aNk+MlSs='",
10
9
  "'sha256-bg+CWjI8RppcgHYH6RuW4z4OnLAUEUPDXRoYUo9Tyok='",
11
- "'sha256-eUSdg12DtpaM7G/VBVS226GbsPqkzkyalIiXO6diQS8='",
12
10
  "'sha256-qBQ7RdQKJEJuW7Fj1MbGjDbF6lnRdfu+KV0V4A5MTRg='",
13
- "'sha256-qFjHb7sY6bZLxg005pqG16u4FRF/5pjgtIEUIUQjJO0='",
14
11
  "'sha256-qjuzziE6xLU3Cras89VlShlRYHgYZuOxceXUDmuvClo='",
12
+ "'sha256-t76xM9aAYbelOV2iEJQ/BU9dEqpvvktljSu14Uyvvd4='",
13
+ "'sha256-uOX/uiQI1VZtrqlMRTLtY3W15cHbKX4Aeibg+uXF+sI='",
15
14
  "'sha256-xLP5QIbvR88RAxDKoSWqs6CVxNIRu17hhr7S/Q6hlU0='",
16
- "'sha256-xz80fPjhAczg/tByXnm3xfZrdAUWODPmQtD4solyj1c='"
15
+ "'sha256-xz80fPjhAczg/tByXnm3xfZrdAUWODPmQtD4solyj1c='",
16
+ "'sha256-y0tT9cT5l9EPcFOld+ed41V7GuZjHHO53TGAT/8F/PU='"
17
17
  ]
package/dist/ui/deps.d.ts CHANGED
@@ -28,7 +28,7 @@ export interface UiHandlerDeps {
28
28
  readonly browser?: BrowserSessionManager | undefined;
29
29
  readonly gatewayConfig?: RuntimeGatewayConfig | undefined;
30
30
  readonly gatewaySetupTester?: ((config: GatewayConfig, candidateModelIds: readonly string[]) => Promise<readonly string[]>) | undefined;
31
- readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string) => Promise<readonly string[]>) | undefined;
31
+ readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string, apiKeyHeaderName?: string) => Promise<readonly string[]>) | undefined;
32
32
  }
33
33
  export interface BuildHandlerDepsOptions {
34
34
  readonly configPath: string | undefined;
@@ -39,7 +39,7 @@ export interface BuildHandlerDepsOptions {
39
39
  readonly uiDbPath?: string | undefined;
40
40
  readonly store?: UiStore | undefined;
41
41
  readonly gatewaySetupTester?: ((config: GatewayConfig, candidateModelIds: readonly string[]) => Promise<readonly string[]>) | undefined;
42
- readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string) => Promise<readonly string[]>) | undefined;
42
+ readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string, apiKeyHeaderName?: string) => Promise<readonly string[]>) | undefined;
43
43
  }
44
44
  export declare function currentGatewayConfig(deps: UiHandlerDeps): GatewayConfig | undefined;
45
45
  export declare function currentGatewayConfigPresent(deps: UiHandlerDeps): boolean;