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