@skaile/transform-proxy 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Library: the anon trace records what the proxy sends to the provider **after** the pipeline and what the model answers **before** restore, so a reviewer can check that no clear-text personal data reached the provider. `TransformProxyOptions.trace?: TraceSink` (synchronous `record(event: TraceEvent)`) gets one `request` line per proxied request on the wire routes and exactly one `response` or `error` line for it, linked by `requestId`. Without `trace`, nothing is recorded or hashed. A sink that throws is logged (`transform proxy: trace sink failed: <msg>`) and never reaches the client.
6
+ - Library: a `request` line holds only the delta: the entries of the rewritten body not yet traced in this session, keyed by JSON path (`messages[i]` / `input[i]` per element, every other top-level key such as `system`, `tools`, `instructions` as a whole; `{}` when nothing is new). The harness resends the whole history each round, so a full body per line would repeat the conversation. `dropSession` forgets what was traced.
7
+ - Library: a `response` line holds the reply before restore: the parsed JSON body, or for SSE the message the walker assembles in the protocol's non-streamed shape (Anthropic text, thinking, redacted thinking and `tool_use` blocks with parsed `input`; OpenAI chat `chat.completion` with content, reasoning and tool calls; OpenAI responses the final `response` object). The SSE bytes to the client are the same with or without a trace. A stream that ends before a complete reply still gets its line, with no `content`. A non-2xx reply gives an `error` line with the status and the body cut to 4096 characters; an unreachable upstream gives `status: 502` and the fetch error.
8
+ - Library: `createFileTraceSink({ dir, maxChars? })` appends one JSON line per event to `<dir>/<sessionId>.jsonl` (an id outside `[A-Za-z0-9_-]{1,128}` becomes `sha256-<32 hex>`). The directory is created with mode 0700, files with 0600: they hold exactly the leaks the trace is meant to find. `serializeTraceEvent(event, maxChars?)` is the line format; `content` whose JSON is longer than `maxChars` (default `DEFAULT_TRACE_MAX_CHARS`, 262144) becomes that JSON text cut to `maxChars`, with `truncated: true`.
9
+ - Library: trace lines name the route: `anthropic.messages`, `anthropic.count_tokens`, `openai.chat` or `openai.responses`.
10
+ - Library: an always-on leak check scans every string value of the rewritten body, including fields no transform sees (`tools`, metadata, unknown fields), for known originals: the session's facts (source = publisher id) and the originals the pipeline has already replaced (`Transform.knownOriginals`, source `pipeline`). A value that one place replaced but another sends in clear text (a name in a tool description, or one the detector missed in a new context) is otherwise invisible. Matching is case-sensitive; values shorter than 3 characters are skipped (chance hits). A finding logs `log.warn('transform proxy: known original sent upstream', { sessionId, route, leaks })` with kind, source and JSON path of each finding, never the value, and goes into the `request` line's `leaks`. The request is still sent: the check warns, it does not block.
11
+ - Library: `Transform.knownOriginals?()` returns the originals an instance has already replaced in this session, each with its kind (forge-anon: its mapping). Without connectors (omp) there are no facts, so the leak check needs these to compare against. `composeTransforms` returns the union of all stages; a stage whose `knownOriginals()` throws is logged and skipped. The values are used only by the leak check and never sent or logged.
12
+ - Config: top-level key `trace: { dir, maxChars? }` in `transform-proxy.yaml`; `dir` resolves against the config file's directory, `maxChars` must be a positive integer.
13
+ - CLI: `--trace-dir <dir>` (relative to the cwd) turns the file trace on; `SKAILE_TRANSFORM_PROXY_TRACE_DIR` (relative to the cwd) overrides the flag and the config's `trace.dir`. With a trace, the CLI prints `[transform-proxy info] trace: <dir>` on stderr at start.
14
+ - CLI, **behaviour change**: log lines print non-string arguments as JSON (`JSON.stringify`) instead of `String(arg)`, so the leak warning reads `[transform-proxy warn] transform proxy: known original sent upstream {"sessionId":"…","route":"…","leaks":[…]}` instead of `[object Object]`. Anything that parses the proxy's stderr sees JSON where it saw `[object Object]` before.
15
+ - omp extension: `/anon status` shows `trace: <dir>` or `trace: off`. The own proxy child inherits `SKAILE_TRANSFORM_PROXY_TRACE_DIR`; without it, the status shows the config's `trace.dir`. With an external proxy (`SKAILE_TRANSFORM_PROXY_URL`) only the env var is known.
16
+ - omp extension: the extension keeps reading its proxy child's stderr after `listening <url>` (before, it dropped it) and turns each leak warning into a warning notice, once per session and kind for the child's life: `anon: a known <kind> value was sent to the provider in clear text (at <path>, route <route>). Value not shown.` Before, the warning was lost, and repeating it every round would flood the UI.
17
+
18
+ ## 0.4.0
19
+
20
+ - Library: `TransformProxy.inspect(sessionId, pipelineId?)` returns host-only transform state, `{ <transformId>: state }` for each stage of the session's pipeline instance (default `defaultPipeline`) that implements the new optional `Transform.inspect?()`. A host such as anon-ai reads forge-anon's mapping through it to show what was anonymized. It is in-process only (no HTTP route, never sent), returns `{}` for an unknown session, an unbuilt pipeline or a failed build, and never builds an instance or creates session state.
21
+
22
+ ## 0.3.1
23
+
24
+ - Anthropic walker: the system text that Anthropic's Claude subscription (OAuth) check reads verbatim passes untransformed: the leading Claude Code identity sentence and the leading `x-anthropic-billing-header:` line. The rest of each block is still transformed. forge-anon read `Claude` as a name and the `cc_version` as IP addresses, so every request of a subscription session with anon on got 429 `rate_limit_error` "Error".
25
+
3
26
  ## 0.3.0
4
27
 
5
28
  - omp extension, **behaviour change**: `/anon on` and `/anon off` switch only the omp process they run in (its subagents and its `/new`, `/resume`, `/fork` sessions included) and no longer save the default. Before, every `/anon on|off` wrote `~/.omp/agent/skaile-transform-proxy.json`, so turning anon off in one window turned it off for every omp started later. `/anon on --global` and `/anon off --global` also save the default. A saved `on` is applied at session start only until the first successful `/anon on|off` in that process, so a plain `/anon off` stays off across `/new`. An existing saved `enabled: true` still turns anon on in new omp processes; run `/anon off --global` to clear it.
package/README.md CHANGED
@@ -53,20 +53,25 @@ pipelines:
53
53
  defaultPipeline: anon
54
54
  allowUpstreams: # optional; default: any https origin, plus loopback http
55
55
  - https://api.anthropic.com
56
+ trace: # optional; see "Trace"
57
+ dir: anon-traces # relative to this file's directory
58
+ maxChars: 262144 # optional; positive integer, default 262144
56
59
  ```
57
60
 
58
- Keys are exactly the option fields. An unknown top-level key is an error. Module specifiers resolve against the config file's directory.
61
+ Keys are exactly the option fields, except `trace`, which configures the CLI's file sink (see [Trace](#trace)). An unknown top-level key, or an unknown key under `trace`, is an error. Module specifiers resolve against the config file's directory.
59
62
 
60
63
  ## CLI
61
64
 
62
65
  ```sh
63
- skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]
66
+ skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--trace-dir <dir>] [--exit-with-stdin]
64
67
  ```
65
68
 
66
69
  - The token comes from `--token` or `SKAILE_TRANSFORM_PROXY_TOKEN`.
67
70
  - Without `--config`, the proxy reads `./.skaile/transform-proxy.yaml`, then `~/.config/skaile/transform-proxy.yaml`.
68
71
  - On start it prints `listening <url>`. It stops on SIGINT or SIGTERM.
69
72
  - `--exit-with-stdin` also stops it when its stdin closes. A parent process that spawns the proxy with a stdin pipe and never writes to it takes the proxy down when it exits, even on SIGKILL.
73
+ - `--trace-dir <dir>` (relative to the cwd) writes the [trace](#trace) there; `SKAILE_TRANSFORM_PROXY_TRACE_DIR` overrides it. With a trace, the CLI prints `[transform-proxy info] trace: <dir>` on stderr at start.
74
+ - Warnings and errors go to stderr as `[transform-proxy warn] <message>` / `[transform-proxy error] <message>`, followed by each argument: strings as they are, everything else as JSON.
70
75
 
71
76
  ## Library
72
77
 
@@ -85,6 +90,67 @@ await proxy.dropSession('s1')
85
90
  await proxy.close()
86
91
  ```
87
92
 
93
+ `await proxy.inspect('s1')` lets the host read what the session's transforms hold, e.g. forge-anon's mapping for highlights: `{ <transformId>: state }`, one entry per stage of the pipeline (`inspect('s1', 'anon')`; default `defaultPipeline`) whose `Transform` implements `inspect()`. It is in-process only: there is no HTTP route, and the result is never sent anywhere. An unknown session, a pipeline that has not been built for the session yet, or a failed build gives `{}`; `inspect` never builds an instance or creates session state.
94
+
95
+ `trace: createFileTraceSink({ dir })` in the options turns on the [trace](#trace). Any object with a synchronous `record(event: TraceEvent)` is a `TraceSink`.
96
+
97
+ ## Trace
98
+
99
+ The trace records what the proxy sends to the provider **after** the pipeline, and what the model answers **before** restore. It exists to check that no clear-text personal data reaches the provider.
100
+
101
+ Turn it on in one of three places; the first that is set wins:
102
+
103
+ 1. `SKAILE_TRANSFORM_PROXY_TRACE_DIR=<dir>` (relative to the cwd; an empty value counts as unset)
104
+ 2. `--trace-dir <dir>` (relative to the cwd)
105
+ 3. `trace: { dir, maxChars? }` in `transform-proxy.yaml` (`dir` relative to the config file)
106
+
107
+ `maxChars` comes from the config only. In the library, pass `trace: createFileTraceSink({ dir, maxChars? })`, or any `TraceSink`.
108
+
109
+ `createFileTraceSink` writes one file per session, `<dir>/<sessionId>.jsonl`, and appends one JSON line per event. A session id outside `[A-Za-z0-9_-]{1,128}` becomes `sha256-` plus the first 32 hex characters of its sha256. The directory is created with mode 0700, the files with 0600. A sink that throws is logged (`transform proxy: trace sink failed: <msg>`) and does not change what the client gets.
110
+
111
+ Each proxied request on a wire route (`…/messages`, `…/messages/count_tokens`, `…/chat/completions`, `…/responses`) gives one `request` line, then exactly one `response` or `error` line. `GET …/models` is not traced. A line (`TraceEvent`) has these fields:
112
+
113
+ | Field | Content |
114
+ |---|---|
115
+ | `at` | ISO timestamp. |
116
+ | `requestId` | Random UUID per proxied request; links its request and response/error lines. |
117
+ | `sessionId` | The proxy session. |
118
+ | `pipeline` | The pipeline id. |
119
+ | `route` | `anthropic.messages`, `anthropic.count_tokens`, `openai.chat` or `openai.responses`. |
120
+ | `upstream` | The forward target, origin + path, without the query string. |
121
+ | `model` | The request body's `model`, when it is a string. |
122
+ | `phase` | `request`, `response` or `error`. |
123
+ | `status` | response/error: the HTTP status (502 when the upstream was unreachable). |
124
+ | `durationMs` | response/error: milliseconds since the request line. |
125
+ | `content` | See below. |
126
+ | `truncated` | `true` when `content` was cut. |
127
+ | `leaks` | request: the [leak check](#leak-check) findings (`[]` when none); response/error: `[]`. |
128
+
129
+ `content` by phase:
130
+
131
+ - `request`: only the delta, an object keyed by JSON path, e.g. `{ "messages[4]": {…}, "system": […], "tools": […] }`. Each element of `messages` and `input` is one entry (`messages[i]`, `input[i]`); every other top-level key of the rewritten body is one entry as a whole. An entry is written once per session: the proxy keeps a sha256 of each written entry, and a later request writes only the entries it has not seen, `{}` when nothing is new. Harnesses resend the whole history every round, so round two shows only the new turn. A harness that changes an earlier message (omp strips thinking blocks, for example) makes that message show up again. Placeholders are stable per session, so the hashes are too. `dropSession` forgets the hashes with the rest of the session.
132
+ - `response`: the reply before restore. A JSON reply is the parsed body. An SSE reply is the message the walker assembles from the stream, in the protocol's non-streamed shape: an Anthropic `message` with its text, thinking and `tool_use` blocks (`input` parsed from the streamed JSON), an OpenAI `chat.completion` with content, `reasoning_content` and `tool_calls`, or the `response` object of the final OpenAI responses event. The SSE bytes the client gets are the same with or without a trace. A stream that ends before a complete reply has no `content`.
133
+ - `error`: a non-2xx reply's body as text, cut to 4096 characters, or the fetch error when the upstream was unreachable.
134
+
135
+ When the JSON of `content` is longer than `maxChars` (default 262144, `DEFAULT_TRACE_MAX_CHARS`), `content` becomes that JSON text cut to `maxChars`, and `truncated` is `true`. `serializeTraceEvent(event, maxChars?)` gives the line without the trailing newline.
136
+
137
+ ### Leak check
138
+
139
+ The leak check runs on every request on a wire route, with or without a trace. After the pipeline has rewritten the request, it scans every string value of the whole body, fields no transform sees included (`tools`, metadata, unknown fields), for known originals:
140
+
141
+ - the session's facts, with the publisher id as `source`;
142
+ - the originals the pipeline has already replaced in this session (`Transform.knownOriginals()`, e.g. forge-anon's mapping), with `source` `pipeline`. Without connectors (omp) there are no facts, so these are what the check compares against: a name that the transform replaced in one place and that appears in clear text in another, in a tool description or in a context the detector missed.
143
+
144
+ Matching is case-sensitive and by substring; values shorter than 3 characters are skipped, because they match by chance. Object keys are not scanned. Each finding has `kind`, `source` and `at` (the JSON path, e.g. `tools[0].description`), once per kind, source and path. A request with findings logs
145
+
146
+ ```text
147
+ transform proxy: known original sent upstream { sessionId, route, leaks: [{ kind, source, at }] }
148
+ ```
149
+
150
+ as a warning (the CLI prints `[transform-proxy warn] transform proxy: known original sent upstream {"sessionId":"…","route":"…","leaks":[…]}`), and the findings go into the request line's `leaks`. The warning never contains the value. The request is still sent: the check warns, it does not block.
151
+
152
+ Trace files contain exactly the leaks they are meant to find, in clear text. Keep them local, never commit them, and delete them once reviewed.
153
+
88
154
  ## omp extension
89
155
 
90
156
  The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform-proxy/omp`, declared in the package manifest under `omp.extensions`):
@@ -115,7 +181,8 @@ statusLine:
115
181
  - While anon is on or in error, images are not sent: the extension sets omp's `images.blockImages` in omp's in-memory runtime settings layer, and omp replaces every image in the request, history included, with the note `Image reading is disabled.`. The proxy cannot anonymize images and refuses every request that carries one (400 `unsupported_content`), and omp resends the whole history each turn, so a single screenshot would otherwise block the session. `/anon off` drops the override, so your own `images.blockImages` setting applies again; the override is never written to `config.yml`. An omp without this settings API gets a warning instead, and the proxy keeps refusing images.
116
182
  - The proxy session follows the root omp session: on `/new`, `/resume`, `/fork` (`session_switch`) and on a root `session_start` with a new session id, every provider is re-routed with the new `x-skaile-transform-session`. The proxy keeps the old session's state in memory until it exits; no request carries that id again. A `session_start` of a headless `print` session (how omp runs subagents) keeps the parent's id, so subagents share its pseudonyms.
117
183
  - `/anon off` restores the original base URLs and headers.
118
- - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, whether images are blocked, and the routed and refused providers.
184
+ - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, the trace target (`trace: <dir>` or `trace: off`), whether images are blocked, and the routed and refused providers. The own proxy child inherits `SKAILE_TRANSFORM_PROXY_TRACE_DIR` with the rest of omp's environment, so the [trace](#trace) is on when that variable is set (shown resolved against the cwd) or when the config sets `trace.dir`. With an external proxy (`SKAILE_TRANSFORM_PROXY_URL`) only the variable is known; the status shows `trace: off` without it, whatever that proxy does.
185
+ - The extension keeps reading its proxy child's stderr after `listening <url>`. Each [leak warning](#leak-check) becomes a warning notice, once per session and kind while the child lives, so a leak in the history does not repeat every round: `anon: a known <kind> value was sent to the provider in clear text (at <path>, route <route>). Value not shown.` Leak warnings of an external proxy stay in that proxy's log.
119
186
  - `/anon on` and `/anon off` switch only the omp process they run in: that window, its subagents, and the sessions it opens with `/new`, `/resume` and `/fork`. Other omp windows are not touched, and the saved default does not change.
120
187
  - `/anon on --global` and `/anon off --global` also save the default for omp processes started later, in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path). A session start applies a saved `on` until the first successful `/anon on|off` in that process; from then on the process follows its own choice. A failed `/anon on --global` saves nothing.
121
188
  - `SKAILE_TRANSFORM_PROXY_MODE=required` (set by a skaile session in required mode) turns routing on at session start and refuses `/anon off`.
@@ -128,10 +195,13 @@ A module default-exports a `TransformModule`:
128
195
 
129
196
  - `open(config, { baseDir, log })` runs once per proxy. It validates the config and builds shared state. Throwing rejects startup.
130
197
  - It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
131
- - A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()` and `dispose?()`.
198
+ - A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()`, `dispose?()`, `inspect?()` (host-only state, read through `proxy.inspect`; the walkers never call it) and `knownOriginals?()`.
199
+ - `knownOriginals?(): Iterable<{ kind, value }>` returns the originals the instance has already replaced in this session, each with its kind (forge-anon: its mapping). Only the [leak check](#leak-check) reads them, as source `pipeline`; the values are never sent or logged. `composeTransforms` returns the union of all stages that implement it; a stage that throws is logged and skipped.
132
200
 
133
201
  The Anthropic walker appends the pipeline `instruction` after the host's system prompt: a string `system` gets it after a blank line, a block array gets it as one more text block at the end, and an absent `system` becomes the instruction. The host's leading blocks stay first, because Claude Code gateways (TeamClaude) refuse Sonnet requests with 429 `rate_limit_error` when the system prompt does not start with the Claude Code blocks. The OpenAI walkers still put the instruction first: a leading `system` message in chat completions, a prefix of `instructions` in responses.
134
202
 
203
+ Those leading blocks also pass untransformed: a system text that starts with the Claude Code identity sentence (`You are Claude Code, Anthropic's official CLI for Claude.`) keeps that sentence, and one that starts with `x-anthropic-billing-header:` keeps that first line; the rest of the text is transformed. Anthropic's Claude subscription (OAuth) check reads them verbatim and answers 429 `rate_limit_error` with the message `Error` when they differ, and a German NER reads `Claude` as a name and a `cc_version` as an IP address. The host writes these blocks; they carry no user data.
204
+
135
205
  `inboundStream().push(c1) + … + flush()` must equal `inbound(c1 + … + cn)` for every chunking.
136
206
 
137
207
  Connectors publish facts through `proxy.session(id).publisher(source)`. Facts are in-process only.
@@ -141,4 +211,6 @@ Connectors publish facts through `proxy.session(id).publisher(source)`. Facts ar
141
211
  - The default host is `127.0.0.1`. Set a token before binding elsewhere.
142
212
  - Use `allowUpstreams` to limit where requests can go.
143
213
  - Clear text never leaves the proxy in a transformed field. Auth headers are relayed, not logged.
144
- - Mappings, facts and the signature cache are kept in memory and are lost on restart.
214
+ - Fields the walkers do not rewrite (`tools`, metadata, unknown fields) are forwarded as they are. The [leak check](#leak-check) warns when one of them carries a known original; it does not block the request.
215
+ - Leak warnings carry kind, source and JSON path, never the value. Trace files and any other `TraceSink` get the clear text that leaked: keep them local, never commit them, delete them once reviewed.
216
+ - Mappings, facts, the signature cache and the trace hashes are kept in memory and are lost on restart.
package/dist/cli.d.mts CHANGED
@@ -1,6 +1,8 @@
1
- import { i as TransformProxy } from "./server-C_eKyCTV.mjs";
1
+ import { i as TransformProxy } from "./server-DdXMr4yF.mjs";
2
2
  /** Environment variable that supplies the client token when `--token` is absent. */
3
3
  export declare const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
4
+ /** Environment variable with the trace directory (relative to cwd); overrides `--trace-dir` and the config's `trace.dir`. */
5
+ export declare const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
4
6
  export interface CliEnvironment {
5
7
  readonly cwd: string;
6
8
  readonly home: string;
package/dist/cli.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { d as createTransformProxy, o as resolveProxyConfig, v as errorMessage } from "./config-BZsthgP-.mjs";
2
+ import { d as createTransformProxy, o as resolveProxyConfig, p as createFileTraceSink, x as errorMessage } from "./config-DHxYF3Tv.mjs";
3
3
  import { realpathSync } from "node:fs";
4
+ import { resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { homedir } from "node:os";
6
7
  import { parseArgs } from "node:util";
7
8
  // src/cli.ts
8
9
  /** Environment variable that supplies the client token when `--token` is absent. */
9
10
  const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
10
- const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--exit-with-stdin]";
11
+ /** Environment variable with the trace directory (relative to cwd); overrides `--trace-dir` and the config's `trace.dir`. */
12
+ const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
13
+ const USAGE = "usage: skaile-transform-proxy [--config <file>] [--port <n>] [--host <h>] [--token <t>] [--trace-dir <dir>] [--exit-with-stdin]";
11
14
  /** Parses the arguments, loads the config and starts the proxy. Never installs signal handlers. */
12
15
  async function runCli(argv, io) {
13
16
  let values;
@@ -20,6 +23,7 @@ async function runCli(argv, io) {
20
23
  port: { type: "string" },
21
24
  host: { type: "string" },
22
25
  token: { type: "string" },
26
+ "trace-dir": { type: "string" },
23
27
  help: {
24
28
  type: "boolean",
25
29
  short: "h"
@@ -52,9 +56,12 @@ async function runCli(argv, io) {
52
56
  io.stderr(errorMessage(err));
53
57
  return { code: 1 };
54
58
  }
59
+ const { trace: traceConfig, ...proxyOptions } = config.options;
60
+ const envTraceDir = io.env[TRACE_DIR_ENV];
61
+ const traceDir = envTraceDir !== void 0 && envTraceDir !== "" ? resolve(io.cwd, envTraceDir) : values["trace-dir"] !== void 0 ? resolve(io.cwd, values["trace-dir"]) : traceConfig?.dir;
55
62
  try {
56
63
  const proxy = await createTransformProxy({
57
- ...config.options,
64
+ ...proxyOptions,
58
65
  host: values.host,
59
66
  port,
60
67
  token: values.token ?? io.env["SKAILE_TRANSFORM_PROXY_TOKEN"],
@@ -63,8 +70,13 @@ async function runCli(argv, io) {
63
70
  info: () => {},
64
71
  warn: (msg, ...args) => io.stderr(format("warn", msg, args)),
65
72
  error: (msg, ...args) => io.stderr(format("error", msg, args))
66
- }
73
+ },
74
+ ...traceDir === void 0 ? {} : { trace: createFileTraceSink({
75
+ dir: traceDir,
76
+ maxChars: traceConfig?.maxChars
77
+ }) }
67
78
  });
79
+ if (traceDir !== void 0) io.stderr(format("info", `trace: ${traceDir}`, []));
68
80
  io.stdout(`listening ${proxy.url}`);
69
81
  return {
70
82
  code: 0,
@@ -82,7 +94,7 @@ function parsePort(value) {
82
94
  return port <= 65535 ? port : void 0;
83
95
  }
84
96
  function format(level, msg, args) {
85
- return [`[transform-proxy ${level}] ${msg}`, ...args.map(String)].join(" ");
97
+ return [`[transform-proxy ${level}] ${msg}`, ...args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg))].join(" ");
86
98
  }
87
99
  /** True when this module is the executed entry point (the bin, or `bun src/cli.ts`). */
88
100
  function isEntryPoint() {
@@ -123,4 +135,4 @@ if (isEntryPoint()) {
123
135
  }
124
136
  }
125
137
  }
126
- export { TOKEN_ENV, runCli };
138
+ export { TOKEN_ENV, TRACE_DIR_ENV, runCli };