@oscharko-dev/keiko 0.1.0-beta.0 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +98 -570
  2. package/dist/cli/gen-tests.js +8 -3
  3. package/dist/cli/init.d.ts +8 -0
  4. package/dist/cli/init.js +122 -0
  5. package/dist/cli/investigate.js +6 -2
  6. package/dist/cli/lifecycle.d.ts +18 -0
  7. package/dist/cli/lifecycle.js +289 -0
  8. package/dist/cli/models.js +2 -2
  9. package/dist/cli/runner.js +21 -28
  10. package/dist/gateway/capabilities.d.ts +1 -0
  11. package/dist/gateway/capabilities.data.js +5 -203
  12. package/dist/gateway/capabilities.js +18 -0
  13. package/dist/gateway/config.d.ts +2 -1
  14. package/dist/gateway/config.js +98 -9
  15. package/dist/gateway/gateway.js +3 -3
  16. package/dist/gateway/index.d.ts +2 -2
  17. package/dist/gateway/index.js +2 -2
  18. package/dist/gateway/model-selection.d.ts +3 -1
  19. package/dist/gateway/model-selection.js +15 -4
  20. package/dist/gateway/types.d.ts +1 -0
  21. package/dist/harness/session.d.ts +1 -1
  22. package/dist/harness/session.js +1 -1
  23. package/dist/sdk/index.d.ts +1 -1
  24. package/dist/sdk/index.js +1 -1
  25. package/dist/tools/patch-normalize.js +1 -2
  26. package/dist/tools/terminal-policy.js +1 -8
  27. package/dist/ui/chat-handlers.js +26 -12
  28. package/dist/ui/csp-hashes.json +6 -6
  29. package/dist/ui/deps.d.ts +14 -0
  30. package/dist/ui/deps.js +92 -20
  31. package/dist/ui/gateway-setup.d.ts +3 -0
  32. package/dist/ui/gateway-setup.js +235 -0
  33. package/dist/ui/read-handlers.js +14 -7
  34. package/dist/ui/routes.js +6 -4
  35. package/dist/ui/run-handlers.js +3 -2
  36. package/dist/ui/server.d.ts +1 -1
  37. package/dist/ui/server.js +1 -1
  38. package/dist/ui/static/404.html +1 -1
  39. package/dist/ui/static/_next/static/chunks/44-17c259c8e72fb82f.js +1 -0
  40. package/dist/ui/static/_next/static/chunks/app/_not-found/{page-75825b09bcecad97.js → page-7bd871301b874ae0.js} +1 -1
  41. package/dist/ui/static/_next/static/chunks/app/launch/{page-9c86a13c29884245.js → page-3bd098d60d6df513.js} +1 -1
  42. package/dist/ui/static/_next/static/chunks/app/layout-091bb8be985f5c03.js +1 -0
  43. package/dist/ui/static/_next/static/chunks/app/{page-4168c12c68b7a853.js → page-2006f21df58c2bb9.js} +1 -1
  44. package/dist/ui/static/_next/static/chunks/{main-app-30679af7240d63e9.js → main-app-e8144a306630b76d.js} +1 -1
  45. package/dist/ui/static/_next/static/css/{be7cb54d5c5673b6.css → 3d68155c8db012f4.css} +1 -1
  46. package/dist/ui/static/index.html +1 -1
  47. package/dist/ui/static/index.txt +3 -3
  48. package/dist/ui/static/launch.html +1 -1
  49. package/dist/ui/static/launch.txt +3 -3
  50. package/dist/ui/store-handlers.js +16 -12
  51. package/dist/workflows/bug-investigation/model-loop.js +1 -4
  52. package/dist/workflows/bug-investigation/parse.js +5 -3
  53. package/dist/workflows/unit-tests/model-loop.js +1 -1
  54. package/dist/workspace/retrieval.js +1 -1
  55. package/package.json +1 -1
  56. package/dist/ui/static/_next/static/chunks/4-be1fef693af8e088.js +0 -1
  57. package/dist/ui/static/_next/static/chunks/app/layout-bdea63fe87947d50.js +0 -1
  58. /package/dist/ui/static/_next/static/{ca-A01hy9W98aRvMZKdAw → VbDWcDBTN0u8CNeSDaz0o}/_buildManifest.js +0 -0
  59. /package/dist/ui/static/_next/static/{ca-A01hy9W98aRvMZKdAw → VbDWcDBTN0u8CNeSDaz0o}/_ssgManifest.js +0 -0
@@ -2,12 +2,13 @@
2
2
  // behind the existing ModelPort/Gateway boundary: the browser sends only chat content and a registry
3
3
  // model id, while provider endpoints and keys remain resolved from the local gateway config/.env.
4
4
  import { basename } from "node:path";
5
- import { GatewayError, findCapability } from "../gateway/index.js";
5
+ import { GatewayError, findCapability, findConfiguredCapability, listConfiguredCapabilities, } from "../gateway/index.js";
6
6
  import { UiStoreError, isProjectAvailable, } from "./store/index.js";
7
7
  import { validateProjectPath } from "./store/validation.js";
8
+ import { currentGatewayConfig } from "./deps.js";
8
9
  import { errorBody } from "./routes.js";
9
- const DEFAULT_CHAT_MODEL = "gpt-oss-120b";
10
- const DEFAULT_CHAT_TITLE = "GPT OSS 120B";
10
+ const DEFAULT_CHAT_MODEL = "example-chat-model";
11
+ const DEFAULT_CHAT_TITLE = "New chat";
11
12
  const MAX_BODY_BYTES = 128_000;
12
13
  const MAX_CHAT_INPUT_CHARS = 16_000;
13
14
  const MAX_CONTEXT_MESSAGES = 24;
@@ -75,13 +76,26 @@ async function readJsonObject(req) {
75
76
  function isRouteResult(value) {
76
77
  return isRecord(value) && typeof value.status === "number" && "body" in value;
77
78
  }
78
- function modelFromBody(body) {
79
+ function chatCapability(deps, modelId) {
80
+ const config = currentGatewayConfig(deps);
81
+ return config === undefined ? findCapability(modelId) : findConfiguredCapability(config, modelId);
82
+ }
83
+ function defaultChatModelId(deps) {
84
+ const config = currentGatewayConfig(deps);
85
+ if (config === undefined) {
86
+ return DEFAULT_CHAT_MODEL;
87
+ }
88
+ const configured = listConfiguredCapabilities(config);
89
+ return (configured.find((model) => model.id === DEFAULT_CHAT_MODEL && model.kind === "chat") ??
90
+ configured.find((model) => model.kind === "chat"))?.id ?? DEFAULT_CHAT_MODEL;
91
+ }
92
+ function modelFromBody(body, deps) {
79
93
  const modelId = typeof body.modelId === "string" && body.modelId.length > 0
80
94
  ? body.modelId
81
- : DEFAULT_CHAT_MODEL;
82
- const capability = findCapability(modelId);
95
+ : defaultChatModelId(deps);
96
+ const capability = chatCapability(deps, modelId);
83
97
  if (capability?.kind !== "chat") {
84
- return { status: 400, body: errorBody("BAD_REQUEST", "modelId must be a chat model registry id.") };
98
+ return { status: 400, body: errorBody("BAD_REQUEST", "modelId must be a configured chat model id.") };
85
99
  }
86
100
  return modelId;
87
101
  }
@@ -176,12 +190,12 @@ function sendRequestFromBody(body) {
176
190
  modelId: typeof body.modelId === "string" && body.modelId.length > 0 ? body.modelId : undefined,
177
191
  };
178
192
  }
179
- function invalidChatModelResult(modelId) {
180
- const capability = findCapability(modelId);
193
+ function invalidChatModelResult(modelId, deps) {
194
+ const capability = chatCapability(deps, modelId);
181
195
  if (capability?.kind === "chat") {
182
196
  return undefined;
183
197
  }
184
- return { status: 400, body: errorBody("BAD_REQUEST", "modelId must be a chat model registry id.") };
198
+ return { status: 400, body: errorBody("BAD_REQUEST", "modelId must be a configured chat model id.") };
185
199
  }
186
200
  function createUserMessage(deps, request) {
187
201
  return deps.store.createMessage({
@@ -242,7 +256,7 @@ export async function handleCreateDesktopChat(ctx, deps) {
242
256
  const body = await readJsonObject(ctx.req);
243
257
  if (isRouteResult(body))
244
258
  return body;
245
- const modelId = modelFromBody(body);
259
+ const modelId = modelFromBody(body, deps);
246
260
  if (isRouteResult(modelId))
247
261
  return modelId;
248
262
  try {
@@ -274,7 +288,7 @@ export async function handleSendDesktopChat(ctx, deps) {
274
288
  return { status: 404, body: errorBody("NOT_FOUND", "Chat not found.") };
275
289
  }
276
290
  const modelId = request.modelId ?? chat.selectedModel;
277
- const invalidModel = invalidChatModelResult(modelId);
291
+ const invalidModel = invalidChatModelResult(modelId, deps);
278
292
  if (invalidModel !== undefined)
279
293
  return invalidModel;
280
294
  return persistModelChatTurn(deps, request, chat, modelId);
@@ -1,15 +1,15 @@
1
1
  [
2
2
  "'sha256-FhLHRUQz4c4ntLU9VkfEesX7PnzNLENSe/16Hi523Kk='",
3
+ "'sha256-JvBgydXt9/FYJ748ftCVGdeG0nrCETPaVqZP4+fgvkQ='",
3
4
  "'sha256-NMmsYxPlvKu6BMNDUuiUA/0HWXXhODWSkUJ3CrerHAI='",
4
5
  "'sha256-OBTN3RiyCV4Bq7dFqZ5a2pAXjnCcCYeTJMO2I/LYKeo='",
5
- "'sha256-OogwkdfeAY//FSbFGNeTewmi/U11IUHAkAgKNEwJCG0='",
6
+ "'sha256-QWoY+4yWw+h0ada8BNW4WC/53Meu6uTVMY0kaHHULrY='",
6
7
  "'sha256-U9W+ZoRW19rf6ohEfUh2oSN8UmJ8mZjCoxp31AbEGYM='",
7
- "'sha256-bDep1P+WNj2FZ2j0g3tUvt5ISNYG1nCzFE65Y9sE1c8='",
8
8
  "'sha256-bg+CWjI8RppcgHYH6RuW4z4OnLAUEUPDXRoYUo9Tyok='",
9
- "'sha256-iqYTUJ5u/EtWNaG9+D7Y0DM2wPtGyVS2aRVXbTOsfag='",
10
- "'sha256-oJekvGO4J2NYcfsZB+NVPai5HyR1UVOFcmKB3PORB8M='",
11
- "'sha256-pQjgKfZ7Pcb9s5HrqqpJnRrMazbJ0Nhk6e1aFQNuFsU='",
12
- "'sha256-q3VO3K+1hbob0r8DheOST7SIt4DQWr76alv4VzyZ44s='",
9
+ "'sha256-dkXQrDpCKNXpywUKLu04aGkL4/5JXS2LWCKQ806R0rU='",
10
+ "'sha256-eiKIlsoY8dMxZVHJy1NjkZmYFylb22dM84TY7IBd+Wk='",
11
+ "'sha256-kOkrNfRDf9dGqK13HJzI9gCFZNXlg2juEtCMMZ5zeis='",
12
+ "'sha256-o5xPG0ZoS77FlzyLEClBD+u6el5Y3HrgzBsy+JPyHx8='",
13
13
  "'sha256-qBQ7RdQKJEJuW7Fj1MbGjDbF6lnRdfu+KV0V4A5MTRg='",
14
14
  "'sha256-qjuzziE6xLU3Cras89VlShlRYHgYZuOxceXUDmuvClo='",
15
15
  "'sha256-xLP5QIbvR88RAxDKoSWqs6CVxNIRu17hhr7S/Q6hlU0='",
package/dist/ui/deps.d.ts CHANGED
@@ -7,6 +7,12 @@ import { type TerminalExecutionManager } from "./terminal.js";
7
7
  import { type BrowserSessionManager } from "../tools/browser/index.js";
8
8
  export type Redactor = (value: unknown) => unknown;
9
9
  export type ModelPortFactory = (modelId: string) => ModelPort | undefined;
10
+ export interface RuntimeGatewayConfig {
11
+ readonly storagePath: string;
12
+ current(): GatewayConfig | undefined;
13
+ present(): boolean;
14
+ set(config: GatewayConfig | undefined, present: boolean): void;
15
+ }
10
16
  export interface UiHandlerDeps {
11
17
  readonly config: GatewayConfig | undefined;
12
18
  readonly configPresent: boolean;
@@ -20,6 +26,9 @@ export interface UiHandlerDeps {
20
26
  readonly uiDbPath?: string | undefined;
21
27
  readonly terminal?: TerminalExecutionManager | undefined;
22
28
  readonly browser?: BrowserSessionManager | undefined;
29
+ readonly gatewayConfig?: RuntimeGatewayConfig | undefined;
30
+ readonly gatewaySetupTester?: ((config: GatewayConfig, candidateModelIds: readonly string[]) => Promise<readonly string[]>) | undefined;
31
+ readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string) => Promise<readonly string[]>) | undefined;
23
32
  }
24
33
  export interface BuildHandlerDepsOptions {
25
34
  readonly configPath: string | undefined;
@@ -29,6 +38,11 @@ export interface BuildHandlerDepsOptions {
29
38
  readonly modelPortFactory?: ModelPortFactory | undefined;
30
39
  readonly uiDbPath?: string | undefined;
31
40
  readonly store?: UiStore | undefined;
41
+ readonly gatewaySetupTester?: ((config: GatewayConfig, candidateModelIds: readonly string[]) => Promise<readonly string[]>) | undefined;
42
+ readonly gatewayModelDiscovery?: ((baseUrl: string, apiKey: string) => Promise<readonly string[]>) | undefined;
32
43
  }
44
+ export declare function currentGatewayConfig(deps: UiHandlerDeps): GatewayConfig | undefined;
45
+ export declare function currentGatewayConfigPresent(deps: UiHandlerDeps): boolean;
33
46
  export declare function buildRedactor(env: EnvSource, config?: GatewayConfig): Redactor;
47
+ export declare function currentRedactionSecrets(deps: UiHandlerDeps): readonly string[];
34
48
  export declare function buildUiHandlerDeps(options: BuildHandlerDepsOptions): UiHandlerDeps;
package/dist/ui/deps.js CHANGED
@@ -5,12 +5,13 @@
5
5
  // 3-arg `createUiServer({ staticRoot, csp, port })` form still compiles and the Wave 1 server tests
6
6
  // pass unchanged; the handlers degrade gracefully (no config → 400 NO_MODEL on a run, null config on
7
7
  // the inspector; no store → an empty evidence list).
8
- import { listCapabilities, loadConfigFromFile, parseGatewayConfig, } from "../gateway/index.js";
8
+ import { createDefaultChatCapability, loadConfigFromFile, parseGatewayConfig, } from "../gateway/index.js";
9
9
  import { GatewayError, Gateway } from "../gateway/index.js";
10
10
  import { GatewayModelPort } from "../harness/index.js";
11
11
  import { createAuditRedactor } from "../audit/index.js";
12
12
  import { deepRedactStrings } from "../audit/redaction.js";
13
13
  import { createNodeEvidenceStore, resolveEvidenceDir } from "../audit/store.js";
14
+ import { dirname, join } from "node:path";
14
15
  import { createRunRegistry } from "./runs.js";
15
16
  import { createNodeUiStore, resolveUiDbPath, } from "./store/index.js";
16
17
  import { createTerminalExecutionManager, } from "./terminal.js";
@@ -18,16 +19,38 @@ import { createBrowserSessionManager, } from "../tools/browser/index.js";
18
19
  function envModelToken(modelId) {
19
20
  return modelId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
20
21
  }
22
+ function envModelIdFromApiKeyName(name) {
23
+ const prefix = "KEIKO_MODEL_";
24
+ const suffix = "_API_KEY";
25
+ if (!name.startsWith(prefix) || !name.endsWith(suffix)) {
26
+ return undefined;
27
+ }
28
+ const token = name.slice(prefix.length, -suffix.length);
29
+ return token.length === 0 ? undefined : token.toLowerCase().replace(/_/g, "-");
30
+ }
21
31
  function hasEnvProvider(modelId, env) {
22
32
  const token = envModelToken(modelId);
23
33
  const baseUrl = env[`KEIKO_MODEL_${token}_BASE_URL`];
24
34
  const apiKey = env[`KEIKO_MODEL_${token}_API_KEY`];
25
35
  return baseUrl !== undefined && baseUrl.length > 0 && apiKey !== undefined && apiKey.length > 0;
26
36
  }
37
+ function envModelIds(env) {
38
+ const modelIds = [];
39
+ for (const key of Object.keys(env)) {
40
+ const modelId = envModelIdFromApiKeyName(key);
41
+ if (modelId !== undefined && hasEnvProvider(modelId, env)) {
42
+ modelIds.push(modelId);
43
+ }
44
+ }
45
+ return Array.from(new Set(modelIds));
46
+ }
27
47
  function resolveEnvOnlyConfig(env) {
28
- const providers = listCapabilities()
29
- .filter((capability) => capability.kind === "chat" && hasEnvProvider(capability.id, env))
30
- .map((capability) => ({ modelId: capability.id, baseUrl: "", apiKey: "" }));
48
+ const providers = envModelIds(env).map((modelId) => ({
49
+ modelId,
50
+ baseUrl: "",
51
+ apiKey: "",
52
+ capability: createDefaultChatCapability(modelId),
53
+ }));
31
54
  if (providers.length === 0) {
32
55
  return undefined;
33
56
  }
@@ -41,11 +64,25 @@ function resolveEnvOnlyConfig(env) {
41
64
  throw error;
42
65
  }
43
66
  }
67
+ function localGatewayConfigPath(uiDbPath) {
68
+ return join(dirname(uiDbPath), "keiko.config.json");
69
+ }
44
70
  // Loads the config without leaking the path or any secret on failure: a missing/invalid config file
45
71
  // falls back to KEIKO_MODEL_* env wiring when present, otherwise it is a normal "no config" state.
46
- function resolveConfig(configPath, env) {
72
+ function resolveConfig(configPath, env, localConfigPath) {
47
73
  if (configPath === undefined) {
48
- const config = resolveEnvOnlyConfig(env);
74
+ let config;
75
+ try {
76
+ config = loadConfigFromFile(localConfigPath, env);
77
+ }
78
+ catch (error) {
79
+ if (error instanceof GatewayError) {
80
+ config = resolveEnvOnlyConfig(env);
81
+ }
82
+ else {
83
+ throw error;
84
+ }
85
+ }
49
86
  return { config, configPresent: config !== undefined };
50
87
  }
51
88
  try {
@@ -59,6 +96,25 @@ function resolveConfig(configPath, env) {
59
96
  throw error;
60
97
  }
61
98
  }
99
+ function createRuntimeGatewayConfig(initial, initialPresent, storagePath) {
100
+ let config = initial;
101
+ let present = initialPresent;
102
+ return {
103
+ storagePath,
104
+ current: () => config,
105
+ present: () => present,
106
+ set(next, nextPresent) {
107
+ config = next;
108
+ present = nextPresent;
109
+ },
110
+ };
111
+ }
112
+ export function currentGatewayConfig(deps) {
113
+ return deps.gatewayConfig?.current() ?? deps.config;
114
+ }
115
+ export function currentGatewayConfigPresent(deps) {
116
+ return deps.gatewayConfig?.present() ?? deps.configPresent;
117
+ }
62
118
  function isKeikoApiKeyEnvName(name) {
63
119
  return (name === "KEIKO_DEFAULT_API_KEY" ||
64
120
  (name.startsWith("KEIKO_MODEL_") && name.endsWith("_API_KEY")));
@@ -85,28 +141,44 @@ export function buildRedactor(env, config) {
85
141
  const redactString = createAuditRedactor({ additionalSecrets: redactionSecrets(env, config) }, env);
86
142
  return (value) => deepRedactStrings(value, redactString);
87
143
  }
144
+ export function currentRedactionSecrets(deps) {
145
+ return redactionSecrets(deps.env, currentGatewayConfig(deps));
146
+ }
88
147
  // The production ModelPort factory: a GatewayModelPort over a Gateway built from the resolved
89
148
  // config (mirrors the CLI's `new GatewayModelPort(new Gateway(config))`). Returns undefined when no
90
149
  // config was resolved so the run route answers 400 NO_MODEL rather than constructing a broken port.
91
- function defaultModelPortFactory(config) {
150
+ function defaultModelPortFactory(runtimeConfig) {
92
151
  return () => {
152
+ const config = runtimeConfig.current();
93
153
  if (config === undefined) {
94
154
  return undefined;
95
155
  }
96
156
  return new GatewayModelPort(new Gateway(config));
97
157
  };
98
158
  }
159
+ function buildTerminalManager(options) {
160
+ return createTerminalExecutionManager({
161
+ store: options.store,
162
+ evidenceStore: options.evidenceStore,
163
+ processEnv: options.env,
164
+ redactor: (value) => {
165
+ const redacted = options.liveRedactor(value);
166
+ return typeof redacted === "string" ? redacted : value;
167
+ },
168
+ });
169
+ }
99
170
  // Assembles the handler deps for the real `keiko ui` process, mirroring the CLI config/evidence
100
171
  // wiring (loadConfigFromFile / resolveEvidenceDir / createNodeEvidenceStore). The UI store is
101
172
  // created at the resolved UI-DB path (explicit → KEIKO_UI_DATA_DIR → ~/.keiko/keiko-ui.db) unless
102
173
  // an injected store is supplied (tests).
103
174
  export function buildUiHandlerDeps(options) {
104
- const { config, configPresent } = resolveConfig(options.configPath, options.env);
105
- const evidenceStore = createNodeEvidenceStore(resolveEvidenceDir(options.evidenceDir, options.env));
106
- const secrets = redactionSecrets(options.env, config);
107
- const redactString = createAuditRedactor({ additionalSecrets: secrets }, options.env);
108
- const liveRedactor = buildRedactor(options.env, config);
109
175
  const resolvedUiDbPath = resolveUiDbPath(options.uiDbPath, options.env);
176
+ const runtimeConfigPath = localGatewayConfigPath(resolvedUiDbPath);
177
+ const { config, configPresent } = resolveConfig(options.configPath, options.env, runtimeConfigPath);
178
+ const runtimeConfig = createRuntimeGatewayConfig(config, configPresent, runtimeConfigPath);
179
+ const evidenceStore = createNodeEvidenceStore(resolveEvidenceDir(options.evidenceDir, options.env));
180
+ const redactString = (value) => createAuditRedactor({ additionalSecrets: redactionSecrets(options.env, runtimeConfig.current()) }, options.env)(value);
181
+ const liveRedactor = (value) => deepRedactStrings(value, redactString);
110
182
  const uiStore = options.store ?? createNodeUiStore(resolvedUiDbPath, { redactString });
111
183
  return {
112
184
  config,
@@ -115,18 +187,18 @@ export function buildUiHandlerDeps(options) {
115
187
  env: options.env,
116
188
  redactor: liveRedactor,
117
189
  registry: options.registry ?? createRunRegistry(),
118
- modelPortFactory: options.modelPortFactory ?? defaultModelPortFactory(config),
119
- redactionSecrets: secrets,
190
+ modelPortFactory: options.modelPortFactory ?? defaultModelPortFactory(runtimeConfig),
191
+ redactionSecrets: redactionSecrets(options.env, runtimeConfig.current()),
120
192
  store: uiStore,
121
193
  uiDbPath: resolvedUiDbPath,
122
- terminal: createTerminalExecutionManager({
194
+ gatewayConfig: runtimeConfig,
195
+ gatewaySetupTester: options.gatewaySetupTester,
196
+ gatewayModelDiscovery: options.gatewayModelDiscovery,
197
+ terminal: buildTerminalManager({
123
198
  store: uiStore,
124
199
  evidenceStore,
125
- processEnv: options.env,
126
- redactor: (value) => {
127
- const redacted = liveRedactor(value);
128
- return typeof redacted === "string" ? redacted : value;
129
- },
200
+ env: options.env,
201
+ liveRedactor,
130
202
  }),
131
203
  browser: createBrowserSessionManager({
132
204
  evidenceDir: resolveEvidenceDir(options.evidenceDir, options.env),
@@ -0,0 +1,3 @@
1
+ import type { RouteContext, RouteResult } from "./routes.js";
2
+ import type { UiHandlerDeps } from "./deps.js";
3
+ export declare function handleGatewaySetup(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
@@ -0,0 +1,235 @@
1
+ // First-run gateway setup for non-technical UI users. The browser provides only a base URL and API
2
+ // token; the loopback BFF builds the local provider config, performs a real chat-completions smoke
3
+ // call, stores the resulting config on disk with private permissions, and updates the in-memory
4
+ // runtime config without exposing credentials back to the browser.
5
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { Gateway, createDefaultChatCapability, listConfiguredCapabilities, parseGatewayConfig, toSafeObject, } from "../gateway/index.js";
8
+ import { redact } from "../gateway/redaction.js";
9
+ import { errorBody } from "./routes.js";
10
+ const MAX_BODY_BYTES = 64_000;
11
+ const MAX_DISCOVERED_MODELS = 25;
12
+ class BodyTooLargeError extends Error {
13
+ constructor() {
14
+ super("request body too large");
15
+ this.name = "BodyTooLargeError";
16
+ }
17
+ }
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function readBody(req) {
22
+ return new Promise((resolve, reject) => {
23
+ const chunks = [];
24
+ let total = 0;
25
+ let capped = false;
26
+ req.on("data", (chunk) => {
27
+ total += chunk.length;
28
+ if (total > MAX_BODY_BYTES) {
29
+ if (!capped) {
30
+ capped = true;
31
+ chunks.length = 0;
32
+ reject(new BodyTooLargeError());
33
+ req.resume();
34
+ }
35
+ return;
36
+ }
37
+ chunks.push(chunk);
38
+ });
39
+ req.on("end", () => {
40
+ if (!capped) {
41
+ resolve(Buffer.concat(chunks).toString("utf8"));
42
+ }
43
+ });
44
+ req.on("error", reject);
45
+ });
46
+ }
47
+ function normalizeBaseUrl(raw) {
48
+ let value = raw.trim().replace(/\/+$/u, "");
49
+ if (value.endsWith("/chat/completions")) {
50
+ value = value.slice(0, -"/chat/completions".length).replace(/\/+$/u, "");
51
+ }
52
+ return value;
53
+ }
54
+ function candidateBaseUrls(baseUrl) {
55
+ const primary = normalizeBaseUrl(baseUrl);
56
+ const candidates = [primary];
57
+ if (!primary.endsWith("/v1")) {
58
+ candidates.push(`${primary}/v1`);
59
+ }
60
+ return Array.from(new Set(candidates));
61
+ }
62
+ function providerRaw(modelId, baseUrl, apiKey) {
63
+ return {
64
+ modelId,
65
+ baseUrl,
66
+ apiKey,
67
+ capability: createDefaultChatCapability(modelId),
68
+ timeoutMs: 30_000,
69
+ maxRetries: 2,
70
+ retryBaseDelayMs: 500,
71
+ };
72
+ }
73
+ function buildRawConfig(baseUrl, apiKey, modelIds) {
74
+ return {
75
+ providers: modelIds.map((modelId) => providerRaw(modelId, baseUrl, apiKey)),
76
+ circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000, halfOpenProbes: 2 },
77
+ };
78
+ }
79
+ function modelsEndpoint(baseUrl) {
80
+ return `${baseUrl}/models`;
81
+ }
82
+ function parseModelList(payload) {
83
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
84
+ throw new Error("model discovery response must contain a data array");
85
+ }
86
+ const ids = [];
87
+ for (const item of payload.data) {
88
+ if (!isRecord(item) || typeof item.id !== "string" || item.id.trim().length === 0) {
89
+ continue;
90
+ }
91
+ ids.push(item.id.trim());
92
+ }
93
+ const unique = Array.from(new Set(ids));
94
+ if (unique.length === 0) {
95
+ throw new Error("model discovery returned no model ids");
96
+ }
97
+ return unique.slice(0, MAX_DISCOVERED_MODELS);
98
+ }
99
+ async function defaultGatewayModelDiscovery(baseUrl, apiKey) {
100
+ const response = await fetch(modelsEndpoint(baseUrl), {
101
+ method: "GET",
102
+ headers: { authorization: `Bearer ${apiKey}` },
103
+ signal: AbortSignal.timeout(30_000),
104
+ });
105
+ if (!response.ok) {
106
+ throw new Error(`model discovery returned HTTP ${String(response.status)}`);
107
+ }
108
+ let payload;
109
+ try {
110
+ payload = await response.json();
111
+ }
112
+ catch {
113
+ throw new Error("model discovery response was not readable JSON");
114
+ }
115
+ return parseModelList(payload);
116
+ }
117
+ async function defaultGatewaySetupTester(config, candidateModelIds) {
118
+ const gateway = new Gateway(config);
119
+ const tested = [];
120
+ for (const modelId of candidateModelIds) {
121
+ try {
122
+ await gateway.chat({
123
+ modelId,
124
+ messages: [{ role: "user", content: "Reply with exactly: OK" }],
125
+ });
126
+ tested.push(modelId);
127
+ }
128
+ catch {
129
+ // Non-chat models can appear in OpenAI-compatible model discovery responses. They are
130
+ // intentionally ignored so only chat-callable models become selectable in the UI.
131
+ }
132
+ }
133
+ if (tested.length === 0) {
134
+ throw new Error("no discovered model accepted the chat-completions smoke test");
135
+ }
136
+ return tested;
137
+ }
138
+ function savePrivateJson(path, raw) {
139
+ const dir = dirname(path);
140
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
141
+ if (process.platform !== "win32") {
142
+ chmodSync(dir, 0o700);
143
+ }
144
+ writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
145
+ if (process.platform !== "win32") {
146
+ chmodSync(path, 0o600);
147
+ }
148
+ }
149
+ function readSetupRequest(raw) {
150
+ if (!isRecord(raw)) {
151
+ return { status: 400, body: errorBody("BAD_REQUEST", "Request body must be a JSON object.") };
152
+ }
153
+ const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
154
+ const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
155
+ if (baseUrl.length === 0 || apiKey.length === 0) {
156
+ return { status: 400, body: errorBody("BAD_REQUEST", "baseUrl and apiKey are required.") };
157
+ }
158
+ return { baseUrl, apiKey };
159
+ }
160
+ function safeError(error, secrets) {
161
+ if (error instanceof Error) {
162
+ return redact(error.message, secrets);
163
+ }
164
+ return "Gateway setup failed.";
165
+ }
166
+ async function verifySetupCandidate(baseUrl, apiKey, tester, discovery) {
167
+ const candidateModelIds = await discovery(baseUrl, apiKey);
168
+ const candidateRawConfig = buildRawConfig(baseUrl, apiKey, candidateModelIds);
169
+ const candidateConfig = parseGatewayConfig(candidateRawConfig);
170
+ const testedModelIds = await tester(candidateConfig, candidateModelIds);
171
+ const rawConfig = buildRawConfig(baseUrl, apiKey, testedModelIds);
172
+ const config = parseGatewayConfig(rawConfig);
173
+ return { rawConfig, config, testedModelIds };
174
+ }
175
+ function setupSuccessResult(config, testedModelIds) {
176
+ const testedModelId = testedModelIds[0] ?? "unknown";
177
+ return {
178
+ status: 200,
179
+ body: {
180
+ ok: true,
181
+ testedModelId,
182
+ testedModelIds,
183
+ providerCount: config.providers.length,
184
+ models: listConfiguredCapabilities(config),
185
+ config: toSafeObject(config),
186
+ },
187
+ };
188
+ }
189
+ function setupFailureResult(errors) {
190
+ return {
191
+ status: 502,
192
+ body: errorBody("GATEWAY_SETUP_FAILED", `Credentials could not be verified. ${errors.join(" ")}`),
193
+ };
194
+ }
195
+ export async function handleGatewaySetup(ctx, deps) {
196
+ if (deps.gatewayConfig === undefined) {
197
+ return { status: 500, body: errorBody("GATEWAY_SETUP_UNAVAILABLE", "Gateway setup is unavailable.") };
198
+ }
199
+ let bodyText;
200
+ try {
201
+ bodyText = await readBody(ctx.req);
202
+ }
203
+ catch (error) {
204
+ if (error instanceof BodyTooLargeError) {
205
+ return { status: 413, body: errorBody("PAYLOAD_TOO_LARGE", "Request body exceeds the size limit.") };
206
+ }
207
+ throw error;
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(bodyText);
212
+ }
213
+ catch {
214
+ return { status: 400, body: errorBody("BAD_REQUEST", "Request body is not valid JSON.") };
215
+ }
216
+ const request = readSetupRequest(parsed);
217
+ if ("status" in request) {
218
+ return request;
219
+ }
220
+ const tester = deps.gatewaySetupTester ?? defaultGatewaySetupTester;
221
+ const discovery = deps.gatewayModelDiscovery ?? defaultGatewayModelDiscovery;
222
+ const errors = [];
223
+ for (const baseUrl of candidateBaseUrls(request.baseUrl)) {
224
+ try {
225
+ const verified = await verifySetupCandidate(baseUrl, request.apiKey, tester, discovery);
226
+ savePrivateJson(deps.gatewayConfig.storagePath, verified.rawConfig);
227
+ deps.gatewayConfig.set(verified.config, true);
228
+ return setupSuccessResult(verified.config, verified.testedModelIds);
229
+ }
230
+ catch (error) {
231
+ errors.push(`candidate ${String(errors.length + 1)}: ${safeError(error, [request.apiKey, baseUrl])}`);
232
+ }
233
+ }
234
+ return setupFailureResult(errors);
235
+ }
@@ -1,27 +1,34 @@
1
1
  // The six read-only BFF endpoints (ADR-0011 D5 routes 2,3,4,10,11,12). Each returns a redacted JSON
2
- // projection of already-safe data: config via `toSafeObject` (strips apiKey), the full capability
3
- // registry, the workflow launch-form descriptors, the workspace summary built from the workspace
2
+ // projection of already-safe data: config via `toSafeObject` (strips apiKey), configured model
3
+ // capabilities, the workflow launch-form descriptors, the workspace summary built from the workspace
4
4
  // layer, and evidence list/detail served straight from the store (manifests are redacted-by-
5
5
  // construction on disk, served as-is per D9). No secret reaches any response; the config route
6
6
  // never leaks the config path even on a load failure (handled upstream in deps.ts, which yields
7
7
  // `config: undefined` rather than throwing).
8
- import { toSafeObject, listCapabilities } from "../gateway/index.js";
8
+ import { toSafeObject, listConfiguredCapabilities } from "../gateway/index.js";
9
9
  import { UNIT_TEST_WORKFLOW_DESCRIPTOR, BUG_INVESTIGATION_WORKFLOW_DESCRIPTOR, } from "../workflows/index.js";
10
10
  import { DEFAULT_LIMITS } from "../harness/index.js";
11
11
  import { listEvidence, loadEvidence, assertValidRunId, EvidenceReadError, EvidenceSchemaError, } from "../audit/index.js";
12
12
  import { buildContextPackFromFiles, buildWorkspaceSummary, DEFAULT_CONTEXT_REQUEST, detectWorkspace, discoverWithStats, WORKSPACE_CODES, WorkspaceError, } from "../workspace/index.js";
13
13
  import { errorBody } from "./routes.js";
14
+ import { currentGatewayConfig, currentGatewayConfigPresent } from "./deps.js";
14
15
  import { validateProjectPath } from "./store/validation.js";
15
16
  // Route 2 — resolved config (SafeGatewayConfig, never apiKey/baseUrl) or null when no config was resolved.
16
17
  export function handleConfig(_ctx, deps) {
17
- const config = deps.config === undefined ? null : toSafeObject(deps.config);
18
- return { status: 200, body: { config, configPresent: deps.configPresent } };
18
+ const config = currentGatewayConfig(deps);
19
+ return {
20
+ status: 200,
21
+ body: {
22
+ config: config === undefined ? null : toSafeObject(config),
23
+ configPresent: currentGatewayConfigPresent(deps),
24
+ },
25
+ };
19
26
  }
20
27
  // Route 3 — models published by the resolved UI gateway config. If no config is resolved, no
21
28
  // model-backed run can start, so the endpoint returns an empty list.
22
29
  export function handleModels(_ctx, deps) {
23
- const configured = new Set(deps.config?.providers.map((provider) => provider.modelId) ?? []);
24
- const models = listCapabilities().filter((model) => configured.has(model.id));
30
+ const config = currentGatewayConfig(deps);
31
+ const models = config === undefined ? [] : listConfiguredCapabilities(config);
25
32
  return { status: 200, body: { models } };
26
33
  }
27
34
  // Route 4 — launch-form metadata: the workflow descriptors plus the synthesized explain-plan and
package/dist/ui/routes.js CHANGED
@@ -9,6 +9,7 @@ import { handleConfig, handleModels, handleWorkflows, handleWorkspace, handleEvi
9
9
  import { handleCreateRun, handleCreateChatRun, handleRunEvents, handleCancelRun, handleGetRun, handleApplyRun, } from "./run-handlers.js";
10
10
  import { handleListProjects, handleCreateProject, handleUpdateProject, handleDeleteProject, handleListChats, handleCreateChat, handleUpdateChat, handleDeleteChat, handleListMessages, handleCreateMessage, handleCreateRunSummaryPair, handleUpdateMessage, } from "./store-handlers.js";
11
11
  import { handleCreateDesktopChat, handleSendDesktopChat } from "./chat-handlers.js";
12
+ import { handleGatewaySetup } from "./gateway-setup.js";
12
13
  import { handleCreateTerminalExecution, handleDeleteTerminalExecution, handleTerminalDirectories, handleTerminalEvents, handleTerminalPolicy, } from "./terminal-routes.js";
13
14
  import { handleFilesDirectories, handleFilesPreview, handleFilesTree, } from "./files.js";
14
15
  import { handleBrowserApplyScreenshot, handleBrowserContent, handleBrowserEvents, handleBrowserNavigate, handleBrowserScreenshot, handleBrowserStatus, handleCreateBrowserSession, handleDeleteBrowserSession, } from "./browser.js";
@@ -16,14 +17,15 @@ export const STREAMING = Symbol("streaming");
16
17
  function health() {
17
18
  return { status: 200, body: { status: "ok", version: SDK_VERSION } };
18
19
  }
19
- // The full route contract: the twelve original (ADR-0011 D5), the 10 additive UI-store
20
- // routes (ADR-0013 D7), three Issue #66 run-summary routes, two desktop chat routes,
21
- // desktop terminal JSON routes, and read-only Files widget routes. Terminal byte I/O
22
- // uses a token-scoped WebSocket upgrade path.
20
+ // The full route contract: the twelve original (ADR-0011 D5), the first-run gateway setup
21
+ // endpoint, the 10 additive UI-store routes (ADR-0013 D7), three Issue #66 run-summary routes,
22
+ // two desktop chat routes, desktop terminal JSON routes, and read-only Files widget routes.
23
+ // Terminal byte I/O uses a token-scoped WebSocket upgrade path.
23
24
  export const API_ROUTES = [
24
25
  { method: "GET", pattern: "/api/health", handler: health },
25
26
  { method: "GET", pattern: "/api/config", handler: handleConfig },
26
27
  { method: "GET", pattern: "/api/models", handler: handleModels },
28
+ { method: "POST", pattern: "/api/gateway/setup", handler: handleGatewaySetup },
27
29
  { method: "GET", pattern: "/api/workflows", handler: handleWorkflows },
28
30
  { method: "POST", pattern: "/api/runs", handler: handleCreateRun },
29
31
  { method: "GET", pattern: "/api/runs/:runId/events", handler: handleRunEvents },
@@ -11,6 +11,7 @@ import { startRun, applyRun } from "./run-engine.js";
11
11
  import { ActiveRunLimitError } from "./runs.js";
12
12
  import { SSE_HEADERS, writeEvent, readyMessage } from "./sse.js";
13
13
  import { errorBody, STREAMING } from "./routes.js";
14
+ import { currentRedactionSecrets } from "./deps.js";
14
15
  import { UiStoreError } from "./store/index.js";
15
16
  const MAX_BODY_BYTES = 1_000_000;
16
17
  const VERIFY_NOOP_MODEL = {
@@ -221,7 +222,7 @@ function engineContextFor(deps, request, model) {
221
222
  evidence: {
222
223
  store: deps.evidenceStore,
223
224
  env: deps.env,
224
- additionalSecrets: deps.redactionSecrets ?? [],
225
+ additionalSecrets: currentRedactionSecrets(deps),
225
226
  },
226
227
  };
227
228
  }
@@ -288,7 +289,7 @@ export async function handleCreateRun(ctx, deps) {
288
289
  evidence: {
289
290
  store: deps.evidenceStore,
290
291
  env: deps.env,
291
- additionalSecrets: deps.redactionSecrets ?? [],
292
+ additionalSecrets: currentRedactionSecrets(deps),
292
293
  },
293
294
  };
294
295
  try {