@uncensoredcode/openbridge 0.1.0 → 0.1.1

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.
@@ -1,5 +1,6 @@
1
+ import { spawn } from "node:child_process";
1
2
  import crypto from "node:crypto";
2
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import process from "node:process";
5
6
  import { createInterface } from "node:readline/promises";
@@ -10,17 +11,23 @@ import { clientModule } from "../client/index.js";
10
11
  import { configModule } from "../config/index.js";
11
12
  import { httpModule } from "../http/index.js";
12
13
  import { securityModule } from "../security/index.js";
13
- const { createBridgeChatCompletion, DEFAULT_BRIDGE_API_BASE_URL, streamBridgeChatCompletion } = clientModule;
14
+ const { createBridgeChatCompletion, createBridgeModel, createBridgeProvider, DEFAULT_BRIDGE_API_BASE_URL, deleteBridgeProvider, deleteBridgeProviderSessionPackage, deleteBridgeSession, getBridgeProvider, getBridgeProviderSessionPackage, getBridgeSession, listBridgeModels, listBridgeProviders, listBridgeSessions, putBridgeProviderSessionPackage, streamBridgeChatCompletion, updateBridgeProvider, checkBridgeHealth } = clientModule;
14
15
  const { getBridgeServerStartupWarnings, loadBridgeServerConfig } = configModule;
15
16
  const { clearLocalSessionVault, formatLiveProviderExtractionCanaryResult, runLiveProviderExtractionCanary } = bridgeModule;
16
17
  const { sanitizeSensitiveText } = securityModule;
17
18
  const { startBridgeApiServer } = httpModule;
19
+ const DEFAULT_LOG_TAIL_LINES = 50;
20
+ const DAEMON_READY_TIMEOUT_MS = 4_000;
21
+ const DAEMON_POLL_INTERVAL_MS = 100;
22
+ const STOP_TIMEOUT_MS = 5_000;
23
+ const LOG_FOLLOW_POLL_INTERVAL_MS = 250;
18
24
  async function runBridgeServerCli(input) {
19
25
  const stdout = input.stdout ?? process.stdout;
20
26
  const stderr = input.stderr ?? process.stderr;
21
27
  const stdin = input.stdin ?? process.stdin;
22
28
  const startServer = input.startServer ?? startBridgeApiServer;
23
29
  const runLiveCanary = input.runLiveCanary ?? runLiveProviderExtractionCanary;
30
+ const spawnDetachedServerProcess = input.spawnDetachedServerProcess ?? defaultSpawnDetachedServerProcess;
24
31
  let command;
25
32
  try {
26
33
  command = parseBridgeServerCliArgs({
@@ -90,28 +97,200 @@ async function runBridgeServerCli(input) {
90
97
  stdout.write(`Emptied session vault at ${sessionVaultPath}\n`);
91
98
  return 0;
92
99
  }
100
+ if (command.kind === "status") {
101
+ const status = await getServerStatus(command.config, input.fetchImpl);
102
+ writeJson(stdout, status);
103
+ return status.running && status.healthy !== false ? 0 : 1;
104
+ }
105
+ if (command.kind === "stop") {
106
+ const result = await stopServer(command.config);
107
+ stdout.write(`${result.message}\n`);
108
+ return 0;
109
+ }
110
+ if (command.kind === "logs") {
111
+ await printServerLogs(command.config, {
112
+ follow: command.follow,
113
+ lines: command.lines,
114
+ stdout
115
+ });
116
+ return 0;
117
+ }
118
+ if (command.kind === "providers-list") {
119
+ writeJson(stdout, await listBridgeProviders({
120
+ baseUrl: command.baseUrl,
121
+ fetchImpl: input.fetchImpl
122
+ }));
123
+ return 0;
124
+ }
125
+ if (command.kind === "providers-get") {
126
+ writeJson(stdout, await getBridgeProvider({
127
+ baseUrl: command.baseUrl,
128
+ id: command.id,
129
+ fetchImpl: input.fetchImpl
130
+ }));
131
+ return 0;
132
+ }
133
+ if (command.kind === "providers-add") {
134
+ writeJson(stdout, await createBridgeProvider({
135
+ baseUrl: command.baseUrl,
136
+ id: command.id,
137
+ kind: command.providerKind,
138
+ label: command.label,
139
+ enabled: command.enabled,
140
+ config: command.config,
141
+ fetchImpl: input.fetchImpl
142
+ }));
143
+ return 0;
144
+ }
145
+ if (command.kind === "providers-remove") {
146
+ writeJson(stdout, await deleteBridgeProvider({
147
+ baseUrl: command.baseUrl,
148
+ id: command.id,
149
+ fetchImpl: input.fetchImpl
150
+ }));
151
+ return 0;
152
+ }
153
+ if (command.kind === "providers-enable" || command.kind === "providers-disable") {
154
+ writeJson(stdout, await updateBridgeProvider({
155
+ baseUrl: command.baseUrl,
156
+ id: command.id,
157
+ patch: {
158
+ enabled: command.kind === "providers-enable"
159
+ },
160
+ fetchImpl: input.fetchImpl
161
+ }));
162
+ return 0;
163
+ }
164
+ if (command.kind === "providers-import-session") {
165
+ const sessionPackage = await readSessionPackageInput({
166
+ filePath: command.filePath,
167
+ stdin,
168
+ stderr
169
+ });
170
+ writeJson(stdout, await putBridgeProviderSessionPackage({
171
+ baseUrl: command.baseUrl,
172
+ id: command.id,
173
+ sessionPackage,
174
+ fetchImpl: input.fetchImpl
175
+ }));
176
+ return 0;
177
+ }
178
+ if (command.kind === "providers-session-status") {
179
+ writeJson(stdout, await getBridgeProviderSessionPackage({
180
+ baseUrl: command.baseUrl,
181
+ id: command.id,
182
+ fetchImpl: input.fetchImpl
183
+ }));
184
+ return 0;
185
+ }
186
+ if (command.kind === "providers-clear-session") {
187
+ writeJson(stdout, await deleteBridgeProviderSessionPackage({
188
+ baseUrl: command.baseUrl,
189
+ id: command.id,
190
+ fetchImpl: input.fetchImpl
191
+ }));
192
+ return 0;
193
+ }
194
+ if (command.kind === "models-list") {
195
+ writeJson(stdout, await listBridgeModels({
196
+ baseUrl: command.baseUrl,
197
+ fetchImpl: input.fetchImpl
198
+ }));
199
+ return 0;
200
+ }
201
+ if (command.kind === "models-add") {
202
+ writeJson(stdout, await createBridgeModel({
203
+ baseUrl: command.baseUrl,
204
+ provider: command.provider,
205
+ model: command.model,
206
+ fetchImpl: input.fetchImpl
207
+ }));
208
+ return 0;
209
+ }
210
+ if (command.kind === "sessions-list") {
211
+ writeJson(stdout, await listBridgeSessions({
212
+ baseUrl: command.baseUrl,
213
+ fetchImpl: input.fetchImpl
214
+ }));
215
+ return 0;
216
+ }
217
+ if (command.kind === "sessions-get") {
218
+ writeJson(stdout, await getBridgeSession({
219
+ baseUrl: command.baseUrl,
220
+ id: command.id,
221
+ fetchImpl: input.fetchImpl
222
+ }));
223
+ return 0;
224
+ }
225
+ if (command.kind === "sessions-remove") {
226
+ writeJson(stdout, await deleteBridgeSession({
227
+ baseUrl: command.baseUrl,
228
+ id: command.id,
229
+ fetchImpl: input.fetchImpl
230
+ }));
231
+ return 0;
232
+ }
93
233
  for (const warning of getBridgeServerStartupWarnings(command.config)) {
94
234
  stderr.write(`Warning: ${warning}\n`);
95
235
  }
96
- if (command.kind === "serve") {
97
- command = {
98
- ...command,
99
- config: await ensureCliVaultKey(command.config, {
100
- env: input.env ?? process.env,
101
- stdin,
102
- stdout,
103
- stderr,
104
- promptForVaultKey: input.promptForVaultKey
105
- })
106
- };
236
+ const preparedConfig = await ensureCliVaultKey(command.config, {
237
+ env: input.env ?? process.env,
238
+ stdin,
239
+ stdout,
240
+ stderr,
241
+ promptForVaultKey: input.promptForVaultKey
242
+ });
243
+ if (!command.foreground) {
244
+ const status = await getServerStatus(preparedConfig, input.fetchImpl);
245
+ if (status.running) {
246
+ throw new Error(`Bridge server is already running${status.pid ? ` (pid ${status.pid})` : ""}.`);
247
+ }
248
+ if (preparedConfig.port === 0) {
249
+ throw new Error("Detached start requires a fixed port. Use --foreground with --port 0.");
250
+ }
251
+ const processFiles = getServerProcessFiles(preparedConfig);
252
+ await ensureServerProcessDirectories(processFiles);
253
+ const daemonArgv = toForegroundStartArgv(input.argv);
254
+ const daemon = await spawnDetachedServerProcess({
255
+ argv: daemonArgv,
256
+ env: {
257
+ ...(input.env ?? process.env)
258
+ },
259
+ logPath: processFiles.logPath,
260
+ cwd: process.cwd()
261
+ });
262
+ if (!Number.isInteger(daemon.pid) || daemon.pid <= 0) {
263
+ throw new Error("Failed to spawn detached bridge server process.");
264
+ }
265
+ const readyState = await waitForServerReady({
266
+ config: preparedConfig,
267
+ pid: daemon.pid,
268
+ statePath: processFiles.statePath,
269
+ fetchImpl: input.fetchImpl
270
+ });
271
+ const statusMessage = readyState === null
272
+ ? [
273
+ `Bridge server started in background (pid ${daemon.pid}).`,
274
+ `Logs: ${processFiles.logPath}`,
275
+ `Startup is still pending; check status with "openbridge status".`
276
+ ].join("\n")
277
+ : [
278
+ `Bridge server started in background (pid ${readyState.pid}).`,
279
+ `Base URL: ${readyState.baseUrl}`,
280
+ `Logs: ${readyState.logPath}`
281
+ ].join("\n");
282
+ stdout.write(`${statusMessage}\n`);
283
+ return 0;
107
284
  }
108
285
  const server = await startServer({
109
- config: command.config
286
+ config: preparedConfig
110
287
  });
288
+ const processState = await writeRunningServerState(preparedConfig, server);
289
+ registerForegroundServerCleanup(server, processState);
111
290
  input.onServerStarted?.(server);
112
291
  const address = server.address();
113
292
  if (typeof address === "object" && address) {
114
- stdout.write(`Bridge server listening on http://${address.address}:${address.port}\n`);
293
+ stdout.write(`Bridge server listening on http://${formatHostForUrl(address.address)}:${address.port}\n`);
115
294
  }
116
295
  return 0;
117
296
  }
@@ -133,11 +312,28 @@ function parseBridgeServerCliArgs(input) {
133
312
  onStart(options) {
134
313
  parsedCommand = {
135
314
  kind: "serve",
136
- config: loadBridgeServerConfig(env, {
137
- host: optionalNonEmptyString(options.host, "host") ?? undefined,
138
- port: options.port,
139
- authToken: optionalNonEmptyString(options.token, "token") ?? undefined
140
- })
315
+ foreground: options.foreground === true,
316
+ config: loadCliServerConfig(env, options)
317
+ };
318
+ },
319
+ onStatus(options) {
320
+ parsedCommand = {
321
+ kind: "status",
322
+ config: loadCliServerConfig(env, options)
323
+ };
324
+ },
325
+ onStop(options) {
326
+ parsedCommand = {
327
+ kind: "stop",
328
+ config: loadCliServerConfig(env, options)
329
+ };
330
+ },
331
+ onLogs(options) {
332
+ parsedCommand = {
333
+ kind: "logs",
334
+ config: loadCliServerConfig(env, options),
335
+ follow: options.follow === true,
336
+ lines: readPositiveInteger(options.lines, "lines", DEFAULT_LOG_TAIL_LINES)
141
337
  };
142
338
  },
143
339
  onChat(options) {
@@ -178,6 +374,107 @@ function parseBridgeServerCliArgs(input) {
178
374
  sessionVaultPath: optionalNonEmptyString(options.sessionVaultPath, "session-vault-path") ?? undefined
179
375
  })
180
376
  };
377
+ },
378
+ onProvidersList(options) {
379
+ parsedCommand = {
380
+ kind: "providers-list",
381
+ baseUrl: resolveBaseUrl(options.baseUrl, env)
382
+ };
383
+ },
384
+ onProvidersGet(id, options) {
385
+ parsedCommand = {
386
+ kind: "providers-get",
387
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
388
+ id: requireNonEmptyString(id, "id")
389
+ };
390
+ },
391
+ onProvidersAdd(options) {
392
+ parsedCommand = {
393
+ kind: "providers-add",
394
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
395
+ id: requireNonEmptyString(options.id, "id"),
396
+ providerKind: requireNonEmptyString(options.kind, "kind"),
397
+ label: requireNonEmptyString(options.label, "label"),
398
+ enabled: options.disable !== true,
399
+ config: options.config ? parseJsonObject(options.config, "config") : undefined
400
+ };
401
+ },
402
+ onProvidersRemove(id, options) {
403
+ parsedCommand = {
404
+ kind: "providers-remove",
405
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
406
+ id: requireNonEmptyString(id, "id")
407
+ };
408
+ },
409
+ onProvidersEnable(id, options) {
410
+ parsedCommand = {
411
+ kind: "providers-enable",
412
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
413
+ id: requireNonEmptyString(id, "id")
414
+ };
415
+ },
416
+ onProvidersDisable(id, options) {
417
+ parsedCommand = {
418
+ kind: "providers-disable",
419
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
420
+ id: requireNonEmptyString(id, "id")
421
+ };
422
+ },
423
+ onProvidersImportSession(id, options) {
424
+ parsedCommand = {
425
+ kind: "providers-import-session",
426
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
427
+ id: requireNonEmptyString(id, "id"),
428
+ filePath: optionalNonEmptyString(options.file, "file") ?? undefined
429
+ };
430
+ },
431
+ onProvidersSessionStatus(id, options) {
432
+ parsedCommand = {
433
+ kind: "providers-session-status",
434
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
435
+ id: requireNonEmptyString(id, "id")
436
+ };
437
+ },
438
+ onProvidersClearSession(id, options) {
439
+ parsedCommand = {
440
+ kind: "providers-clear-session",
441
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
442
+ id: requireNonEmptyString(id, "id")
443
+ };
444
+ },
445
+ onModelsList(options) {
446
+ parsedCommand = {
447
+ kind: "models-list",
448
+ baseUrl: resolveBaseUrl(options.baseUrl, env)
449
+ };
450
+ },
451
+ onModelsAdd(options) {
452
+ parsedCommand = {
453
+ kind: "models-add",
454
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
455
+ provider: requireNonEmptyString(options.provider, "provider"),
456
+ model: requireNonEmptyString(options.model, "model")
457
+ };
458
+ },
459
+ onSessionsList(options) {
460
+ parsedCommand = {
461
+ kind: "sessions-list",
462
+ baseUrl: resolveBaseUrl(options.baseUrl, env)
463
+ };
464
+ },
465
+ onSessionsGet(id, options) {
466
+ parsedCommand = {
467
+ kind: "sessions-get",
468
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
469
+ id: requireNonEmptyString(id, "id")
470
+ };
471
+ },
472
+ onSessionsRemove(id, options) {
473
+ parsedCommand = {
474
+ kind: "sessions-remove",
475
+ baseUrl: resolveBaseUrl(options.baseUrl, env),
476
+ id: requireNonEmptyString(id, "id")
477
+ };
181
478
  }
182
479
  });
183
480
  const normalizedArgv = args.length === 0 || args[0]?.startsWith("--") ? ["start", ...args] : args;
@@ -197,31 +494,6 @@ function parseBridgeServerCliArgs(input) {
197
494
  function getBridgeServerCliHelpText() {
198
495
  return buildBridgeServerCliProgram(process.env).helpInformation();
199
496
  }
200
- function optionalNonEmptyString(value, key) {
201
- if (value === undefined) {
202
- return null;
203
- }
204
- const trimmed = value.trim();
205
- if (!trimmed) {
206
- throw new Error(`${key} must be a non-empty string.`);
207
- }
208
- return trimmed;
209
- }
210
- function requireNonEmptyString(value, key) {
211
- const normalized = optionalNonEmptyString(value, key);
212
- if (!normalized) {
213
- throw new Error(`${key} is required.`);
214
- }
215
- return normalized;
216
- }
217
- function formatCliError(error) {
218
- if (error instanceof ZodError) {
219
- return sanitizeSensitiveText(error.issues
220
- .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
221
- .join("; "));
222
- }
223
- return sanitizeSensitiveText(error instanceof Error ? error.message : String(error));
224
- }
225
497
  function buildBridgeServerCliProgram(env, handlers = {}) {
226
498
  const program = new Command();
227
499
  program
@@ -235,13 +507,48 @@ function buildBridgeServerCliProgram(env, handlers = {}) {
235
507
  });
236
508
  program
237
509
  .command("start")
238
- .description("Start the standalone bridge HTTP server.")
510
+ .description("Start the standalone bridge HTTP server. Defaults to detached/background mode.")
239
511
  .option("--host <host>", "Bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST) ?? "127.0.0.1")
240
512
  .option("--port <port>", "Bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
513
+ .option("--state-root <path>", "State root for bridge artifacts")
514
+ .option("--runtime-root <path>", "Runtime root for workspace execution")
241
515
  .option("--token <token>", "Optional local bridge auth token", trimOrUndefined(env.BRIDGE_AUTH_TOKEN))
516
+ .option("--foreground", "Keep the server attached to the current terminal")
242
517
  .action((options) => {
243
518
  handlers.onStart?.(options);
244
519
  });
520
+ program
521
+ .command("status")
522
+ .description("Show the detached bridge server status.")
523
+ .option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
524
+ .option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
525
+ .option("--state-root <path>", "State root for bridge artifacts")
526
+ .option("--runtime-root <path>", "Runtime root for workspace execution")
527
+ .action((options) => {
528
+ handlers.onStatus?.(options);
529
+ });
530
+ program
531
+ .command("stop")
532
+ .description("Stop the detached bridge server.")
533
+ .option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
534
+ .option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
535
+ .option("--state-root <path>", "State root for bridge artifacts")
536
+ .option("--runtime-root <path>", "Runtime root for workspace execution")
537
+ .action((options) => {
538
+ handlers.onStop?.(options);
539
+ });
540
+ program
541
+ .command("logs")
542
+ .description("Print bridge server logs.")
543
+ .option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
544
+ .option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
545
+ .option("--state-root <path>", "State root for bridge artifacts")
546
+ .option("--runtime-root <path>", "Runtime root for workspace execution")
547
+ .option("--follow", "Follow log output")
548
+ .option("--lines <count>", "Number of trailing log lines to print", String(DEFAULT_LOG_TAIL_LINES))
549
+ .action((options) => {
550
+ handlers.onLogs?.(options);
551
+ });
245
552
  program
246
553
  .command("chat")
247
554
  .description("Send a chat completion request through the standalone bridge HTTP API.")
@@ -283,8 +590,222 @@ function buildBridgeServerCliProgram(env, handlers = {}) {
283
590
  : undefined
284
591
  });
285
592
  });
593
+ const providersCommand = program
594
+ .command("providers")
595
+ .description("Manage installed providers and provider session packages.");
596
+ providersCommand
597
+ .command("list")
598
+ .description("List providers.")
599
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
600
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
601
+ DEFAULT_BRIDGE_API_BASE_URL)
602
+ .action((options) => {
603
+ handlers.onProvidersList?.(options);
604
+ });
605
+ providersCommand
606
+ .command("get <id>")
607
+ .description("Get a provider by id.")
608
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
609
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
610
+ DEFAULT_BRIDGE_API_BASE_URL)
611
+ .action((id, options) => {
612
+ handlers.onProvidersGet?.(id, options);
613
+ });
614
+ providersCommand
615
+ .command("add")
616
+ .description("Create a provider.")
617
+ .requiredOption("--id <id>", "Provider id")
618
+ .requiredOption("--kind <kind>", "Provider kind")
619
+ .requiredOption("--label <label>", "Provider label")
620
+ .option("--disable", "Create the provider in a disabled state")
621
+ .option("--config <json>", "Provider config JSON object")
622
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
623
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
624
+ DEFAULT_BRIDGE_API_BASE_URL)
625
+ .action((options) => {
626
+ handlers.onProvidersAdd?.(options);
627
+ });
628
+ providersCommand
629
+ .command("remove <id>")
630
+ .description("Delete a provider.")
631
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
632
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
633
+ DEFAULT_BRIDGE_API_BASE_URL)
634
+ .action((id, options) => {
635
+ handlers.onProvidersRemove?.(id, options);
636
+ });
637
+ providersCommand
638
+ .command("enable <id>")
639
+ .description("Enable a provider.")
640
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
641
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
642
+ DEFAULT_BRIDGE_API_BASE_URL)
643
+ .action((id, options) => {
644
+ handlers.onProvidersEnable?.(id, options);
645
+ });
646
+ providersCommand
647
+ .command("disable <id>")
648
+ .description("Disable a provider.")
649
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
650
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
651
+ DEFAULT_BRIDGE_API_BASE_URL)
652
+ .action((id, options) => {
653
+ handlers.onProvidersDisable?.(id, options);
654
+ });
655
+ providersCommand
656
+ .command("import-session <id>")
657
+ .description("Install or replace a provider session package from stdin or a JSON file.")
658
+ .option("--file <path>", "Read the session package from a JSON file")
659
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
660
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
661
+ DEFAULT_BRIDGE_API_BASE_URL)
662
+ .action((id, options) => {
663
+ handlers.onProvidersImportSession?.(id, options);
664
+ });
665
+ providersCommand
666
+ .command("session-status <id>")
667
+ .description("Show the safe session-package status for a provider.")
668
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
669
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
670
+ DEFAULT_BRIDGE_API_BASE_URL)
671
+ .action((id, options) => {
672
+ handlers.onProvidersSessionStatus?.(id, options);
673
+ });
674
+ providersCommand
675
+ .command("clear-session <id>")
676
+ .description("Delete a provider session package.")
677
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
678
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
679
+ DEFAULT_BRIDGE_API_BASE_URL)
680
+ .action((id, options) => {
681
+ handlers.onProvidersClearSession?.(id, options);
682
+ });
683
+ const modelsCommand = program.command("models").description("Manage exposed bridge models.");
684
+ modelsCommand
685
+ .command("list")
686
+ .description("List models.")
687
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
688
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
689
+ DEFAULT_BRIDGE_API_BASE_URL)
690
+ .action((options) => {
691
+ handlers.onModelsList?.(options);
692
+ });
693
+ modelsCommand
694
+ .command("add")
695
+ .description("Add a model id to an existing provider.")
696
+ .requiredOption("--provider <id>", "Provider id")
697
+ .requiredOption("--model <id>", "Model id")
698
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
699
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
700
+ DEFAULT_BRIDGE_API_BASE_URL)
701
+ .action((options) => {
702
+ handlers.onModelsAdd?.(options);
703
+ });
704
+ const sessionsCommand = program.command("sessions").description("Manage bridge sessions.");
705
+ sessionsCommand
706
+ .command("list")
707
+ .description("List sessions.")
708
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
709
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
710
+ DEFAULT_BRIDGE_API_BASE_URL)
711
+ .action((options) => {
712
+ handlers.onSessionsList?.(options);
713
+ });
714
+ sessionsCommand
715
+ .command("get <id>")
716
+ .description("Get a session by id.")
717
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
718
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
719
+ DEFAULT_BRIDGE_API_BASE_URL)
720
+ .action((id, options) => {
721
+ handlers.onSessionsGet?.(id, options);
722
+ });
723
+ sessionsCommand
724
+ .command("remove <id>")
725
+ .description("Delete a session.")
726
+ .option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
727
+ trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
728
+ DEFAULT_BRIDGE_API_BASE_URL)
729
+ .action((id, options) => {
730
+ handlers.onSessionsRemove?.(id, options);
731
+ });
286
732
  return program;
287
733
  }
734
+ function loadCliServerConfig(env, options) {
735
+ return loadBridgeServerConfig(env, {
736
+ host: optionalNonEmptyString(options.host, "host") ?? undefined,
737
+ port: options.port,
738
+ authToken: "token" in options
739
+ ? (optionalNonEmptyString(options.token, "token") ?? undefined)
740
+ : undefined,
741
+ stateRoot: optionalNonEmptyString(options.stateRoot, "state-root") ?? undefined,
742
+ runtimeRoot: optionalNonEmptyString(options.runtimeRoot, "runtime-root") ?? undefined
743
+ });
744
+ }
745
+ function resolveBaseUrl(value, env) {
746
+ return (optionalNonEmptyString(value, "base-url") ??
747
+ optionalNonEmptyString(env.BRIDGE_API_BASE_URL, "BRIDGE_API_BASE_URL") ??
748
+ optionalNonEmptyString(env.BRIDGE_SERVER_BASE_URL, "BRIDGE_SERVER_BASE_URL") ??
749
+ DEFAULT_BRIDGE_API_BASE_URL);
750
+ }
751
+ function optionalNonEmptyString(value, key) {
752
+ if (value === undefined) {
753
+ return null;
754
+ }
755
+ const trimmed = value.trim();
756
+ if (!trimmed) {
757
+ throw new Error(`${key} must be a non-empty string.`);
758
+ }
759
+ return trimmed;
760
+ }
761
+ function requireNonEmptyString(value, key) {
762
+ const normalized = optionalNonEmptyString(value, key);
763
+ if (!normalized) {
764
+ throw new Error(`${key} is required.`);
765
+ }
766
+ return normalized;
767
+ }
768
+ function readPositiveInteger(value, key, fallback) {
769
+ if (value === undefined) {
770
+ return fallback;
771
+ }
772
+ const normalized = optionalNonEmptyString(value, key);
773
+ if (!normalized) {
774
+ return fallback;
775
+ }
776
+ if (!/^\d+$/.test(normalized)) {
777
+ throw new Error(`${key} must be a positive integer.`);
778
+ }
779
+ const parsed = Number.parseInt(normalized, 10);
780
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
781
+ throw new Error(`${key} must be a positive integer.`);
782
+ }
783
+ return parsed;
784
+ }
785
+ function parseJsonObject(raw, key) {
786
+ let parsed;
787
+ try {
788
+ parsed = JSON.parse(raw);
789
+ }
790
+ catch {
791
+ throw new Error(`${key} must be valid JSON.`);
792
+ }
793
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
794
+ throw new Error(`${key} must be a JSON object.`);
795
+ }
796
+ return parsed;
797
+ }
798
+ function writeJson(stream, value) {
799
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
800
+ }
801
+ function formatCliError(error) {
802
+ if (error instanceof ZodError) {
803
+ return sanitizeSensitiveText(error.issues
804
+ .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
805
+ .join("; "));
806
+ }
807
+ return sanitizeSensitiveText(error instanceof Error ? error.message : String(error));
808
+ }
288
809
  function normalizeCommanderError(error) {
289
810
  if (error instanceof Error && "code" in error) {
290
811
  const code = String(error.code);
@@ -346,6 +867,389 @@ async function defaultPromptForVaultKey(input) {
346
867
  readline.close();
347
868
  }
348
869
  }
870
+ async function readSessionPackageInput(input) {
871
+ const raw = input.filePath
872
+ ? await readFile(path.resolve(input.filePath), "utf8")
873
+ : await readSessionPackageFromStdin(input.stdin, input.stderr);
874
+ return parseJsonObject(raw, "session package");
875
+ }
876
+ async function readSessionPackageFromStdin(stdin, stderr) {
877
+ if (stdin.isTTY) {
878
+ stderr.write("Paste the session package JSON, then press Ctrl-D to submit.\n");
879
+ }
880
+ return await readStreamToString(stdin);
881
+ }
882
+ function readStreamToString(stream) {
883
+ return new Promise((resolve, reject) => {
884
+ let data = "";
885
+ stream.setEncoding("utf8");
886
+ stream.on("data", (chunk) => {
887
+ data += chunk;
888
+ });
889
+ stream.once("end", () => resolve(data));
890
+ stream.once("error", reject);
891
+ });
892
+ }
893
+ function getServerProcessFiles(config) {
894
+ const runRoot = path.join(config.stateRoot, "run");
895
+ const logRoot = path.join(config.stateRoot, "logs");
896
+ return {
897
+ statePath: path.join(runRoot, "server-process.json"),
898
+ runRoot,
899
+ logPath: path.join(logRoot, "server.log"),
900
+ logRoot
901
+ };
902
+ }
903
+ async function ensureServerProcessDirectories(files) {
904
+ await mkdir(files.runRoot, {
905
+ recursive: true,
906
+ mode: 0o700
907
+ });
908
+ await mkdir(files.logRoot, {
909
+ recursive: true,
910
+ mode: 0o700
911
+ });
912
+ }
913
+ async function writeRunningServerState(config, server) {
914
+ const files = getServerProcessFiles(config);
915
+ await ensureServerProcessDirectories(files);
916
+ const address = server.address();
917
+ if (!address || typeof address === "string") {
918
+ throw new Error("Bridge server did not expose a network address.");
919
+ }
920
+ const state = {
921
+ pid: process.pid,
922
+ baseUrl: `http://${formatHostForUrl(address.address)}:${address.port}`,
923
+ host: address.address,
924
+ port: address.port,
925
+ logPath: files.logPath,
926
+ startedAt: new Date().toISOString(),
927
+ stateRoot: config.stateRoot
928
+ };
929
+ await writeServerProcessState(files.statePath, state);
930
+ return state;
931
+ }
932
+ async function writeServerProcessState(statePath, state) {
933
+ await mkdir(path.dirname(statePath), {
934
+ recursive: true,
935
+ mode: 0o700
936
+ });
937
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, {
938
+ encoding: "utf8",
939
+ mode: 0o600
940
+ });
941
+ }
942
+ async function readServerProcessState(statePath) {
943
+ const raw = await readOptionalFile(statePath);
944
+ if (!raw) {
945
+ return null;
946
+ }
947
+ let parsed;
948
+ try {
949
+ parsed = JSON.parse(raw);
950
+ }
951
+ catch {
952
+ return null;
953
+ }
954
+ if (typeof parsed !== "object" || parsed === null) {
955
+ return null;
956
+ }
957
+ const value = parsed;
958
+ if (!Number.isInteger(value.pid) ||
959
+ typeof value.baseUrl !== "string" ||
960
+ typeof value.host !== "string" ||
961
+ !Number.isInteger(value.port) ||
962
+ typeof value.logPath !== "string" ||
963
+ typeof value.startedAt !== "string" ||
964
+ typeof value.stateRoot !== "string") {
965
+ return null;
966
+ }
967
+ return value;
968
+ }
969
+ async function deleteServerProcessState(statePath) {
970
+ try {
971
+ await rm(statePath, {
972
+ force: true
973
+ });
974
+ }
975
+ catch (error) {
976
+ if (error.code !== "ENOENT") {
977
+ throw error;
978
+ }
979
+ }
980
+ }
981
+ function registerForegroundServerCleanup(server, state) {
982
+ if (typeof server.once !== "function" || typeof server.close !== "function") {
983
+ return;
984
+ }
985
+ let shuttingDown = false;
986
+ const statePath = path.join(state.stateRoot, "run", "server-process.json");
987
+ const cleanup = () => {
988
+ void deleteServerProcessState(statePath);
989
+ };
990
+ server.once("close", cleanup);
991
+ const handleSignal = (signal) => {
992
+ if (shuttingDown) {
993
+ return;
994
+ }
995
+ shuttingDown = true;
996
+ void closeServer(server)
997
+ .catch(() => { })
998
+ .finally(() => {
999
+ cleanup();
1000
+ process.exit(signal === "SIGINT" ? 130 : 0);
1001
+ });
1002
+ };
1003
+ process.once("SIGINT", () => {
1004
+ handleSignal("SIGINT");
1005
+ });
1006
+ process.once("SIGTERM", () => {
1007
+ handleSignal("SIGTERM");
1008
+ });
1009
+ }
1010
+ async function getServerStatus(config, fetchImpl) {
1011
+ const files = getServerProcessFiles(config);
1012
+ const state = await readServerProcessState(files.statePath);
1013
+ const pid = state?.pid ?? null;
1014
+ const baseUrl = state?.baseUrl ?? `http://${formatHostForUrl(config.host)}:${config.port}`;
1015
+ const logPath = state?.logPath ?? files.logPath;
1016
+ const running = state ? isProcessRunning(state.pid) : false;
1017
+ let healthy = null;
1018
+ if (running) {
1019
+ try {
1020
+ await checkBridgeHealth({
1021
+ baseUrl,
1022
+ fetchImpl
1023
+ });
1024
+ healthy = true;
1025
+ }
1026
+ catch {
1027
+ healthy = false;
1028
+ }
1029
+ }
1030
+ else if (state) {
1031
+ await deleteServerProcessState(files.statePath);
1032
+ }
1033
+ return {
1034
+ running,
1035
+ healthy,
1036
+ pid,
1037
+ baseUrl,
1038
+ logPath,
1039
+ startedAt: state?.startedAt ?? null
1040
+ };
1041
+ }
1042
+ async function stopServer(config) {
1043
+ const files = getServerProcessFiles(config);
1044
+ const state = await readServerProcessState(files.statePath);
1045
+ if (!state || !isProcessRunning(state.pid)) {
1046
+ await deleteServerProcessState(files.statePath);
1047
+ return {
1048
+ stopped: false,
1049
+ message: "Bridge server is not running."
1050
+ };
1051
+ }
1052
+ process.kill(state.pid, "SIGTERM");
1053
+ const terminated = await waitForProcessExit(state.pid, STOP_TIMEOUT_MS);
1054
+ if (!terminated) {
1055
+ process.kill(state.pid, "SIGKILL");
1056
+ const killed = await waitForProcessExit(state.pid, 1_000);
1057
+ if (!killed) {
1058
+ throw new Error(`Failed to stop bridge server pid ${state.pid}.`);
1059
+ }
1060
+ }
1061
+ await deleteServerProcessState(files.statePath);
1062
+ return {
1063
+ stopped: true,
1064
+ message: `Stopped bridge server pid ${state.pid}.`
1065
+ };
1066
+ }
1067
+ async function printServerLogs(config, input) {
1068
+ const files = getServerProcessFiles(config);
1069
+ const content = await readOptionalRawFile(files.logPath);
1070
+ if (content) {
1071
+ const tail = tailLines(content, input.lines);
1072
+ if (tail) {
1073
+ input.stdout.write(tail);
1074
+ if (!tail.endsWith("\n")) {
1075
+ input.stdout.write("\n");
1076
+ }
1077
+ }
1078
+ }
1079
+ else if (!input.follow) {
1080
+ throw new Error(`Bridge server log file was not found at ${files.logPath}.`);
1081
+ }
1082
+ if (!input.follow) {
1083
+ return;
1084
+ }
1085
+ let position = await getFileSize(files.logPath);
1086
+ const stopSignal = createStopSignal();
1087
+ while (!stopSignal.stopped) {
1088
+ await delay(LOG_FOLLOW_POLL_INTERVAL_MS);
1089
+ const size = await getFileSize(files.logPath);
1090
+ if (size < position) {
1091
+ position = 0;
1092
+ }
1093
+ if (size === position) {
1094
+ continue;
1095
+ }
1096
+ const chunk = await readFileSlice(files.logPath, position, size - position);
1097
+ if (chunk.length > 0) {
1098
+ input.stdout.write(chunk);
1099
+ }
1100
+ position = size;
1101
+ }
1102
+ }
1103
+ function createStopSignal() {
1104
+ const signal = {
1105
+ stopped: false
1106
+ };
1107
+ const stop = () => {
1108
+ signal.stopped = true;
1109
+ };
1110
+ process.once("SIGINT", stop);
1111
+ process.once("SIGTERM", stop);
1112
+ return signal;
1113
+ }
1114
+ function tailLines(text, lineCount) {
1115
+ if (!text) {
1116
+ return "";
1117
+ }
1118
+ const normalized = text.split(/\r?\n/);
1119
+ const hasTrailingNewline = text.endsWith("\n");
1120
+ const lines = hasTrailingNewline ? normalized.slice(0, -1) : normalized;
1121
+ const tail = lines.slice(-lineCount).join("\n");
1122
+ return tail.length === 0 ? "" : `${tail}${hasTrailingNewline ? "\n" : ""}`;
1123
+ }
1124
+ async function readFileSlice(filePath, offset, length) {
1125
+ const handle = await open(filePath, "r");
1126
+ try {
1127
+ const buffer = Buffer.alloc(length);
1128
+ const result = await handle.read(buffer, 0, length, offset);
1129
+ return buffer.subarray(0, result.bytesRead).toString("utf8");
1130
+ }
1131
+ finally {
1132
+ await handle.close();
1133
+ }
1134
+ }
1135
+ async function getFileSize(filePath) {
1136
+ try {
1137
+ const info = await stat(filePath);
1138
+ return info.size;
1139
+ }
1140
+ catch (error) {
1141
+ if (error.code === "ENOENT") {
1142
+ return 0;
1143
+ }
1144
+ throw error;
1145
+ }
1146
+ }
1147
+ async function waitForServerReady(input) {
1148
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
1149
+ while (Date.now() < deadline) {
1150
+ const state = await readServerProcessState(input.statePath);
1151
+ if (state && state.pid === input.pid) {
1152
+ try {
1153
+ await checkBridgeHealth({
1154
+ baseUrl: state.baseUrl,
1155
+ fetchImpl: input.fetchImpl
1156
+ });
1157
+ return state;
1158
+ }
1159
+ catch {
1160
+ // Ignore until timeout or process death.
1161
+ }
1162
+ }
1163
+ if (!isProcessRunning(input.pid)) {
1164
+ throw new Error(`Bridge server exited before becoming ready. Check logs at ${getServerProcessFiles(input.config).logPath}.`);
1165
+ }
1166
+ await delay(DAEMON_POLL_INTERVAL_MS);
1167
+ }
1168
+ return null;
1169
+ }
1170
+ async function defaultSpawnDetachedServerProcess(input) {
1171
+ const scriptPath = process.argv[1];
1172
+ if (!scriptPath) {
1173
+ throw new Error("Cannot determine the openbridge CLI entrypoint for detached start.");
1174
+ }
1175
+ await mkdir(path.dirname(input.logPath), {
1176
+ recursive: true,
1177
+ mode: 0o700
1178
+ });
1179
+ const logHandle = await open(input.logPath, "a", 0o600);
1180
+ try {
1181
+ const child = spawn(process.execPath, [scriptPath, ...input.argv], {
1182
+ cwd: input.cwd,
1183
+ env: input.env,
1184
+ detached: true,
1185
+ stdio: ["ignore", logHandle.fd, logHandle.fd]
1186
+ });
1187
+ child.unref();
1188
+ if (!child.pid) {
1189
+ throw new Error("Detached bridge server process did not expose a pid.");
1190
+ }
1191
+ return {
1192
+ pid: child.pid
1193
+ };
1194
+ }
1195
+ finally {
1196
+ await logHandle.close();
1197
+ }
1198
+ }
1199
+ function toForegroundStartArgv(argv) {
1200
+ const normalized = argv.length === 0 || argv[0]?.startsWith("--") ? ["start", ...argv] : [...argv];
1201
+ if (normalized[0] !== "start") {
1202
+ throw new Error("Detached launch is only supported for the start command.");
1203
+ }
1204
+ if (!normalized.includes("--foreground")) {
1205
+ normalized.push("--foreground");
1206
+ }
1207
+ return normalized;
1208
+ }
1209
+ function isProcessRunning(pid) {
1210
+ try {
1211
+ process.kill(pid, 0);
1212
+ return true;
1213
+ }
1214
+ catch (error) {
1215
+ if (error.code === "ESRCH") {
1216
+ return false;
1217
+ }
1218
+ if (error.code === "EPERM") {
1219
+ return true;
1220
+ }
1221
+ throw error;
1222
+ }
1223
+ }
1224
+ async function waitForProcessExit(pid, timeoutMs) {
1225
+ const deadline = Date.now() + timeoutMs;
1226
+ while (Date.now() < deadline) {
1227
+ if (!isProcessRunning(pid)) {
1228
+ return true;
1229
+ }
1230
+ await delay(100);
1231
+ }
1232
+ return !isProcessRunning(pid);
1233
+ }
1234
+ function delay(ms) {
1235
+ return new Promise((resolve) => {
1236
+ setTimeout(resolve, ms);
1237
+ });
1238
+ }
1239
+ function formatHostForUrl(host) {
1240
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1241
+ }
1242
+ function closeServer(server) {
1243
+ return new Promise((resolve, reject) => {
1244
+ server.close((error) => {
1245
+ if (error) {
1246
+ reject(error);
1247
+ return;
1248
+ }
1249
+ resolve();
1250
+ });
1251
+ });
1252
+ }
349
1253
  async function readOptionalFile(targetPath) {
350
1254
  try {
351
1255
  const value = (await readFile(targetPath, "utf8")).trim();
@@ -358,6 +1262,17 @@ async function readOptionalFile(targetPath) {
358
1262
  throw error;
359
1263
  }
360
1264
  }
1265
+ async function readOptionalRawFile(targetPath) {
1266
+ try {
1267
+ return await readFile(targetPath, "utf8");
1268
+ }
1269
+ catch (error) {
1270
+ if (error.code === "ENOENT") {
1271
+ return null;
1272
+ }
1273
+ throw error;
1274
+ }
1275
+ }
361
1276
  function requireConfigPath(value, key) {
362
1277
  if (!value) {
363
1278
  throw new Error(`${key} is required.`);