@basein/runner 0.2.12 → 0.3.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.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -13,13 +13,27 @@
13
13
  * reports failures as ordinary text with `isError: false`, `postgres-mcp` among
14
14
  * them, is taken at its word on both sides: its `Error: relation does not exist`
15
15
  * is a successful call here, as it was when it was recorded. A built-in's
16
- * hook-shaped response is never judged.
16
+ * hook-shaped response is never judged here: the host says whether a built-in
17
+ * failed (`PostToolUseFailure`), and the control server hands that verdict to
18
+ * `ReplayController.postTool` itself.
17
19
  */
18
20
  /**
19
21
  * The error a serialized `CallToolResult` reports, or undefined when it does
20
22
  * not. `serialized` is the proxy's own serialization — the whole result, as JSON.
21
23
  */
22
24
  export declare function toolResultError(serialized: string): string | undefined;
25
+ /**
26
+ * Whether a serialized result is no result at all.
27
+ *
28
+ * A proxy that reports a call its upstream abandoned — the server died
29
+ * mid-call (`proxy/intercept.ts`, `onAbandoned`) — sends `isError` and no
30
+ * result, and the control server threads `serializeCapped(redact(undefined))`,
31
+ * which is `null`. A real `CallToolResult` is always an object, so this can
32
+ * only mean the call never produced one: it was sent, and may or may not have
33
+ * done its work. Read as a success, the next step was pinned and auto-approved
34
+ * on top of a call nobody saw finish.
35
+ */
36
+ export declare function isNoResult(serialized: string | undefined): boolean;
23
37
  /**
24
38
  * What a server says when a tool no longer exists — the one failure a grace
25
39
  * period cannot cure (plan-services.md D8, kind 2).
@@ -13,7 +13,9 @@
13
13
  * reports failures as ordinary text with `isError: false`, `postgres-mcp` among
14
14
  * them, is taken at its word on both sides: its `Error: relation does not exist`
15
15
  * is a successful call here, as it was when it was recorded. A built-in's
16
- * hook-shaped response is never judged.
16
+ * hook-shaped response is never judged here: the host says whether a built-in
17
+ * failed (`PostToolUseFailure`), and the control server hands that verdict to
18
+ * `ReplayController.postTool` itself.
17
19
  */
18
20
  const MAX_ERROR_CHARS = 500;
19
21
  /**
@@ -31,10 +33,40 @@ export function toolResultError(serialized) {
31
33
  if (!parsed || typeof parsed !== "object")
32
34
  return undefined;
33
35
  const result = parsed;
36
+ // A protocol-level failure. When a `tools/call` is answered with a JSON-RPC
37
+ // `error` instead of a result, the proxy's step report carries that error
38
+ // object as the "result" (`proxy/intercept.ts`): `{ code, message }`, with no
39
+ // `content` and no `isError`. Read as a success, a steered step the upstream
40
+ // refused (bad arguments, a crashed handler) threaded as if it had worked, and
41
+ // the next step was pinned and auto-approved on top of it. A real
42
+ // `CallToolResult` always carries `content`, so the shape cannot be mistaken
43
+ // for one.
44
+ if (result.content === undefined &&
45
+ typeof result.code === "number" &&
46
+ typeof result.message === "string") {
47
+ return clip(`${result.code}: ${result.message}`);
48
+ }
34
49
  if (result.isError !== true)
35
50
  return undefined;
36
51
  return clip(firstText(result.content) ?? "the tool reported an error");
37
52
  }
53
+ /**
54
+ * Whether a serialized result is no result at all.
55
+ *
56
+ * A proxy that reports a call its upstream abandoned — the server died
57
+ * mid-call (`proxy/intercept.ts`, `onAbandoned`) — sends `isError` and no
58
+ * result, and the control server threads `serializeCapped(redact(undefined))`,
59
+ * which is `null`. A real `CallToolResult` is always an object, so this can
60
+ * only mean the call never produced one: it was sent, and may or may not have
61
+ * done its work. Read as a success, the next step was pinned and auto-approved
62
+ * on top of a call nobody saw finish.
63
+ */
64
+ export function isNoResult(serialized) {
65
+ if (serialized === undefined)
66
+ return true;
67
+ const trimmed = serialized.trim();
68
+ return trimmed === "" || trimmed === "null" || trimmed === "undefined";
69
+ }
38
70
  /**
39
71
  * What a server says when a tool no longer exists — the one failure a grace
40
72
  * period cannot cure (plan-services.md D8, kind 2).
@@ -211,10 +211,13 @@ export declare const OUTCOME_RANK: Record<ExecutionOutcome, number>;
211
211
  * What became of one step of a replay.
212
212
  *
213
213
  * `ok` — its tool ran here and its output threaded.
214
- * `recorded` — its tool could not run here, so its recorded output stood in.
215
- * A pass, with an asterisk: the values are from the source run.
216
- * `skipped` — its tool could not run and there was no recorded output. The
217
- * step did not happen at all; later steps read nothing from it.
214
+ * `recorded` — its tool could not be sent from here, so its recorded output
215
+ * stood in (only with `BIR_REPLAY_RECORDED_OUTPUTS=1`, and `bir
216
+ * replay`). A pass, with an asterisk: the values are from the
217
+ * source run.
218
+ * `skipped` — its call produced no result here: it could not be sent, or it
219
+ * was sent and never answered (its error says which). The run
220
+ * stops at it; nothing after it ran.
218
221
  * `failed` — the *scenario's own logic* threw, or its tool ran and reported
219
222
  * an error (stage `tool_call`). Either way the step's work did
220
223
  * not happen, and a plan cannot claim success past it.
@@ -12,9 +12,9 @@
12
12
  * The primitive here is therefore message-level: {@link send} takes any
13
13
  * `JsonRpcMessage` verbatim and {@link onMessage} hands back any message the
14
14
  * upstream originates, in either role. `request`/`notify` remain, implemented on
15
- * top, for the few places that genuinely want a call (`bir doctor`, tests) — and
16
- * they allocate ids in the reserved `bir:` space so they can never collide with a
17
- * host id passing through (Phase 1.3).
15
+ * top, for the few places that genuinely want a call (`bir doctor`, tests, the
16
+ * replay work loop) — and they allocate ids in the reserved `bir:` space so they
17
+ * can never collide with a host id passing through (Phase 1.3).
18
18
  */
19
19
  import type { JsonRpcMessage } from "../jsonrpc/types.js";
20
20
  /** Why the upstream connection ended. */
@@ -23,6 +23,25 @@ export interface UpstreamCloseInfo {
23
23
  signal?: NodeJS.Signals | null;
24
24
  error?: Error;
25
25
  }
26
+ /**
27
+ * An answer the upstream produced but the transport could not deliver — today,
28
+ * a response line over the framing cap. `id` is what could be recovered from
29
+ * the line's ends; the relay uses it to answer the host's request instead of
30
+ * leaving it waiting forever.
31
+ */
32
+ export interface UndeliverableInfo {
33
+ id?: string | number;
34
+ /**
35
+ * The line was recognisably an answer (its first bytes say `result` or
36
+ * `error`), and no proxy-originated request was out when it arrived. Only
37
+ * then may an answer without a recoverable id be matched to the single host
38
+ * call in flight; anything less is a guess that can hand the wrong call an
39
+ * error and then its real answer as well.
40
+ */
41
+ response?: boolean;
42
+ bytes: number;
43
+ reason: string;
44
+ }
26
45
  export interface UpstreamClient {
27
46
  /** Resolves when the transport is usable; rejects if the upstream is unusable. */
28
47
  readonly ready: Promise<void>;
@@ -34,11 +53,28 @@ export interface UpstreamClient {
34
53
  onMessage(handler: (msg: JsonRpcMessage) => void): void;
35
54
  /** Called once when the upstream connection ends, for any reason. */
36
55
  onClose(handler: (info: UpstreamCloseInfo) => void): void;
56
+ /**
57
+ * An upstream answer that could not be delivered (see {@link UndeliverableInfo}).
58
+ * Optional: a transport that can never drop an answer need not offer it.
59
+ */
60
+ onUndeliverable?(handler: (info: UndeliverableInfo) => void): void;
61
+ /**
62
+ * The upstream died and is being restarted (`--restart`). Nothing that was in
63
+ * flight will ever be answered by the new process, so the relay answers it.
64
+ */
65
+ onRestart?(handler: (info: UpstreamCloseInfo) => void): void;
37
66
  /** Convenience call in the reserved `bir:` id space. Never used by the relay. */
38
67
  request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
39
68
  /** Fire-and-forget notification toward the upstream. */
40
69
  notify(method: string, params?: unknown): void;
41
70
  close(): void;
71
+ /**
72
+ * Close politely and wait, bounded by `graceMs` plus a short kill window:
73
+ * the upstream is given the chance to exit on its own before it (and, on
74
+ * Windows, everything it started) is killed. Optional; `close()` is the
75
+ * fallback.
76
+ */
77
+ shutdown?(graceMs?: number): Promise<void>;
42
78
  }
43
79
  /**
44
80
  * The id prefix reserved for requests the proxy itself originates. Host ids pass
@@ -57,14 +93,20 @@ export declare abstract class BaseUpstreamClient implements UpstreamClient {
57
93
  abstract readonly ready: Promise<void>;
58
94
  private readonly messageHandlers;
59
95
  private readonly closeHandlers;
96
+ private readonly undeliverableHandlers;
97
+ private readonly restartHandlers;
60
98
  private readonly pending;
61
99
  private nextId;
62
100
  private isDead;
63
101
  get dead(): boolean;
102
+ /** Proxy-originated (`bir:`) requests still waiting for their answer. */
103
+ protected get ownRequestsInFlight(): number;
64
104
  abstract send(msg: JsonRpcMessage): void;
65
105
  abstract close(): void;
66
106
  onMessage(handler: (msg: JsonRpcMessage) => void): void;
67
107
  onClose(handler: (info: UpstreamCloseInfo) => void): void;
108
+ onUndeliverable(handler: (info: UndeliverableInfo) => void): void;
109
+ onRestart(handler: (info: UpstreamCloseInfo) => void): void;
68
110
  request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
69
111
  notify(method: string, params?: unknown): void;
70
112
  /**
@@ -72,6 +114,17 @@ export declare abstract class BaseUpstreamClient implements UpstreamClient {
72
114
  * consumed here and never reach the relay; everything else fans out verbatim.
73
115
  */
74
116
  protected accept(msg: JsonRpcMessage): void;
117
+ /**
118
+ * Report an answer that could not be delivered. A `bir:` id is ours and is
119
+ * failed here; anything else goes to the relay, which owns host requests.
120
+ */
121
+ protected undeliverable(info: UndeliverableInfo): void;
122
+ /**
123
+ * The process behind this client died and a new one is being started. Every
124
+ * in-flight `request()` fails (the new process never saw it), and the relay
125
+ * is told so it can answer the host's in-flight requests likewise.
126
+ */
127
+ protected restarting(info: UpstreamCloseInfo): void;
75
128
  /** Mark the connection over. Idempotent; fails every in-flight `request()`. */
76
129
  protected closed(info: UpstreamCloseInfo): void;
77
130
  }
@@ -12,9 +12,9 @@
12
12
  * The primitive here is therefore message-level: {@link send} takes any
13
13
  * `JsonRpcMessage` verbatim and {@link onMessage} hands back any message the
14
14
  * upstream originates, in either role. `request`/`notify` remain, implemented on
15
- * top, for the few places that genuinely want a call (`bir doctor`, tests) — and
16
- * they allocate ids in the reserved `bir:` space so they can never collide with a
17
- * host id passing through (Phase 1.3).
15
+ * top, for the few places that genuinely want a call (`bir doctor`, tests, the
16
+ * replay work loop) — and they allocate ids in the reserved `bir:` space so they
17
+ * can never collide with a host id passing through (Phase 1.3).
18
18
  */
19
19
  /**
20
20
  * The id prefix reserved for requests the proxy itself originates. Host ids pass
@@ -34,12 +34,18 @@ export function isBirId(id) {
34
34
  export class BaseUpstreamClient {
35
35
  messageHandlers = [];
36
36
  closeHandlers = [];
37
+ undeliverableHandlers = [];
38
+ restartHandlers = [];
37
39
  pending = new Map();
38
40
  nextId = 1;
39
41
  isDead = false;
40
42
  get dead() {
41
43
  return this.isDead;
42
44
  }
45
+ /** Proxy-originated (`bir:`) requests still waiting for their answer. */
46
+ get ownRequestsInFlight() {
47
+ return this.pending.size;
48
+ }
43
49
  onMessage(handler) {
44
50
  this.messageHandlers.push(handler);
45
51
  }
@@ -50,14 +56,33 @@ export class BaseUpstreamClient {
50
56
  }
51
57
  this.closeHandlers.push(handler);
52
58
  }
59
+ onUndeliverable(handler) {
60
+ this.undeliverableHandlers.push(handler);
61
+ }
62
+ onRestart(handler) {
63
+ this.restartHandlers.push(handler);
64
+ }
53
65
  request(method, params, signal) {
54
66
  if (this.isDead)
55
67
  return Promise.reject(new Error("upstream is not available"));
68
+ if (signal?.aborted)
69
+ return Promise.reject(new Error(`${method} aborted`));
56
70
  const id = `${BIR_ID_PREFIX}${this.nextId++}`;
57
71
  return new Promise((resolve, reject) => {
58
72
  const onAbort = () => {
59
- this.pending.delete(id);
73
+ if (!this.pending.delete(id))
74
+ return;
60
75
  reject(new Error(`${method} aborted`));
76
+ // The caller has stopped waiting, but the upstream has not stopped
77
+ // working: a replay step that timed out would otherwise still run to
78
+ // completion — after the control server may already have substituted
79
+ // a recorded output for it, doubling a side effect nobody can see.
80
+ // A late answer to this id is dropped by `accept`, which finds no
81
+ // waiter left for it.
82
+ const reason = signal?.reason?.name === "TimeoutError"
83
+ ? "timeout"
84
+ : "aborted";
85
+ this.notify("notifications/cancelled", { requestId: id, reason });
61
86
  };
62
87
  signal?.addEventListener("abort", onAbort, { once: true });
63
88
  this.pending.set(id, {
@@ -95,6 +120,38 @@ export class BaseUpstreamClient {
95
120
  for (const handler of this.messageHandlers)
96
121
  handler(msg);
97
122
  }
123
+ /**
124
+ * Report an answer that could not be delivered. A `bir:` id is ours and is
125
+ * failed here; anything else goes to the relay, which owns host requests.
126
+ */
127
+ undeliverable(info) {
128
+ if (isBirId(info.id)) {
129
+ const waiter = this.pending.get(info.id);
130
+ if (waiter) {
131
+ this.pending.delete(info.id);
132
+ waiter.cleanup();
133
+ waiter.reject(new Error(info.reason));
134
+ }
135
+ return;
136
+ }
137
+ for (const handler of this.undeliverableHandlers)
138
+ handler(info);
139
+ }
140
+ /**
141
+ * The process behind this client died and a new one is being started. Every
142
+ * in-flight `request()` fails (the new process never saw it), and the relay
143
+ * is told so it can answer the host's in-flight requests likewise.
144
+ */
145
+ restarting(info) {
146
+ const err = info.error ?? new Error("upstream restarted");
147
+ for (const [, waiter] of this.pending) {
148
+ waiter.cleanup();
149
+ waiter.reject(err);
150
+ }
151
+ this.pending.clear();
152
+ for (const handler of this.restartHandlers)
153
+ handler(info);
154
+ }
98
155
  /** Mark the connection over. Idempotent; fails every in-flight `request()`. */
99
156
  closed(info) {
100
157
  if (this.isDead)
@@ -0,0 +1,43 @@
1
+ /**
2
+ * headers — the credentials a remote upstream is reached with.
3
+ *
4
+ * WHERE THEY COME FROM. A wrapped remote server is a stdio entry that runs
5
+ * `bir-proxy --url …`, so its headers reach the proxy at spawn time. Two ways,
6
+ * both kept:
7
+ *
8
+ * - `BIR_UPSTREAM_HEADERS`, a JSON object of header name → string value, in
9
+ * the entry's `env` — where an install puts them now, because an entry's
10
+ * `args` show up in process listings and its `env` does not;
11
+ * - `--header 'Name: value'` arguments, repeatable — what older entries and
12
+ * hand-written `bir wrap` lines carry. On a clash the argument wins.
13
+ *
14
+ * THE VALUES ARE SECRETS. Nothing here logs one, or the raw variable: a
15
+ * problem is described by its shape ("not JSON", "2 entries skipped") and, at
16
+ * most, a count. The variable is removed from the proxy's environment as soon
17
+ * as it is read (bir-proxy.ts), so no process the proxy starts inherits it.
18
+ */
19
+ /** The environment variable a wrapped remote entry carries its headers in. */
20
+ export declare const UPSTREAM_HEADERS_ENV = "BIR_UPSTREAM_HEADERS";
21
+ export interface ParsedHeaders {
22
+ headers: Record<string, string>;
23
+ /** Why some or all of the input was not used. Never contains a value. */
24
+ problem?: string;
25
+ }
26
+ /**
27
+ * Keep only the headers Node can send. A bad name or value would otherwise
28
+ * make *every* request to the upstream fail at the socket, as "unreachable".
29
+ */
30
+ export declare function sendableHeaders(input: Record<string, string>): ParsedHeaders;
31
+ /**
32
+ * Parse `BIR_UPSTREAM_HEADERS`. Absent or empty → no headers and no problem.
33
+ * Not JSON, or not an object of strings → whatever is usable, and a problem
34
+ * that says what was wrong without repeating any of it.
35
+ */
36
+ export declare function parseUpstreamHeaders(raw: string | undefined): ParsedHeaders;
37
+ /**
38
+ * `base` overlaid with `override`, header names compared case-insensitively
39
+ * (HTTP's rule): `authorization` in one and `Authorization` in the other is
40
+ * one header, and `override`'s spelling and value are the ones kept.
41
+ */
42
+ export declare function mergeHeaders(base: Record<string, string>, override: Record<string, string>): Record<string, string>;
43
+ //# sourceMappingURL=headers.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * headers — the credentials a remote upstream is reached with.
3
+ *
4
+ * WHERE THEY COME FROM. A wrapped remote server is a stdio entry that runs
5
+ * `bir-proxy --url …`, so its headers reach the proxy at spawn time. Two ways,
6
+ * both kept:
7
+ *
8
+ * - `BIR_UPSTREAM_HEADERS`, a JSON object of header name → string value, in
9
+ * the entry's `env` — where an install puts them now, because an entry's
10
+ * `args` show up in process listings and its `env` does not;
11
+ * - `--header 'Name: value'` arguments, repeatable — what older entries and
12
+ * hand-written `bir wrap` lines carry. On a clash the argument wins.
13
+ *
14
+ * THE VALUES ARE SECRETS. Nothing here logs one, or the raw variable: a
15
+ * problem is described by its shape ("not JSON", "2 entries skipped") and, at
16
+ * most, a count. The variable is removed from the proxy's environment as soon
17
+ * as it is read (bir-proxy.ts), so no process the proxy starts inherits it.
18
+ */
19
+ /** The environment variable a wrapped remote entry carries its headers in. */
20
+ export const UPSTREAM_HEADERS_ENV = "BIR_UPSTREAM_HEADERS";
21
+ /** An HTTP header name: RFC 9110's `token`. Node refuses anything else at send time. */
22
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
23
+ /** A header value Node will send: no CR, LF, NUL or other control bytes, nothing past Latin-1. */
24
+ const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
25
+ /**
26
+ * Keep only the headers Node can send. A bad name or value would otherwise
27
+ * make *every* request to the upstream fail at the socket, as "unreachable".
28
+ */
29
+ export function sendableHeaders(input) {
30
+ const headers = {};
31
+ let skipped = 0;
32
+ for (const [name, value] of Object.entries(input)) {
33
+ if (!HEADER_NAME.test(name) || typeof value !== "string" || !HEADER_VALUE.test(value)) {
34
+ skipped += 1;
35
+ continue;
36
+ }
37
+ headers[name] = value;
38
+ }
39
+ return skipped ? { headers, problem: `${skipped} header(s) skipped: not a valid HTTP header name or value` } : { headers };
40
+ }
41
+ /**
42
+ * Parse `BIR_UPSTREAM_HEADERS`. Absent or empty → no headers and no problem.
43
+ * Not JSON, or not an object of strings → whatever is usable, and a problem
44
+ * that says what was wrong without repeating any of it.
45
+ */
46
+ export function parseUpstreamHeaders(raw) {
47
+ if (raw === undefined || raw.trim() === "")
48
+ return { headers: {} };
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ }
53
+ catch {
54
+ return { headers: {}, problem: "not valid JSON; ignored" };
55
+ }
56
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
57
+ return { headers: {}, problem: "not a JSON object of header name to value; ignored" };
58
+ }
59
+ const strings = {};
60
+ let nonString = 0;
61
+ for (const [name, value] of Object.entries(parsed)) {
62
+ if (typeof value === "string")
63
+ strings[name] = value;
64
+ else
65
+ nonString += 1;
66
+ }
67
+ const sendable = sendableHeaders(strings);
68
+ const problems = [
69
+ nonString ? `${nonString} header(s) skipped: the value is not a string` : undefined,
70
+ sendable.problem,
71
+ ].filter((p) => p !== undefined);
72
+ return problems.length ? { headers: sendable.headers, problem: problems.join("; ") } : { headers: sendable.headers };
73
+ }
74
+ /**
75
+ * `base` overlaid with `override`, header names compared case-insensitively
76
+ * (HTTP's rule): `authorization` in one and `Authorization` in the other is
77
+ * one header, and `override`'s spelling and value are the ones kept.
78
+ */
79
+ export function mergeHeaders(base, override) {
80
+ const out = { ...base };
81
+ for (const [name, value] of Object.entries(override)) {
82
+ const lower = name.toLowerCase();
83
+ for (const key of Object.keys(out))
84
+ if (key !== name && key.toLowerCase() === lower)
85
+ delete out[key];
86
+ out[name] = value;
87
+ }
88
+ return out;
89
+ }
90
+ //# sourceMappingURL=headers.js.map