@skaile/transform-proxy 0.4.0 → 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,20 @@
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
+
3
18
  ## 0.4.0
4
19
 
5
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.
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
 
@@ -87,6 +92,65 @@ await proxy.close()
87
92
 
88
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.
89
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
+
90
154
  ## omp extension
91
155
 
92
156
  The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform-proxy/omp`, declared in the package manifest under `omp.extensions`):
@@ -117,7 +181,8 @@ statusLine:
117
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.
118
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.
119
183
  - `/anon off` restores the original base URLs and headers.
120
- - `/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.
121
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.
122
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.
123
188
  - `SKAILE_TRANSFORM_PROXY_MODE=required` (set by a skaile session in required mode) turns routing on at session start and refuses `/anon off`.
@@ -130,7 +195,8 @@ A module default-exports a `TransformModule`:
130
195
 
131
196
  - `open(config, { baseDir, log })` runs once per proxy. It validates the config and builds shared state. Throwing rejects startup.
132
197
  - It returns a `TransformFactory`, which runs once per `(session, pipeline)`: `(ctx: { sessionId, facts, log }) => Transform`.
133
- - A `Transform` has `instruction?`, `outbound(text, leaf)`, `inbound(text)`, `inboundStream()`, `dispose?()` and `inspect?()` (host-only state, read through `proxy.inspect`; the walkers never call it).
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.
134
200
 
135
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.
136
202
 
@@ -145,4 +211,6 @@ Connectors publish facts through `proxy.session(id).publisher(source)`. Facts ar
145
211
  - The default host is `127.0.0.1`. Set a token before binding elsewhere.
146
212
  - Use `allowUpstreams` to limit where requests can go.
147
213
  - Clear text never leaves the proxy in a transformed field. Auth headers are relayed, not logged.
148
- - 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-CicvlQ7q.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-Ceb5AF_a.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 };
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
- import { timingSafeEqual } from "node:crypto";
2
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { parse } from "yaml";
@@ -46,9 +46,32 @@ function composeTransforms(stages, log = silentLogger) {
46
46
  } catch (err) {
47
47
  log.error(`transform dispose failed: ${errorMessage(err)}`);
48
48
  }
49
- }
49
+ },
50
+ ...stages.some((stage) => stage.knownOriginals !== void 0) ? { knownOriginals: () => unionKnownOriginals(stages, log) } : {}
50
51
  };
51
52
  }
53
+ /** The originals of every stage, deduped by kind and value; a stage that throws is logged and skipped. */
54
+ function unionKnownOriginals(stages, log) {
55
+ const out = [];
56
+ const seen = /* @__PURE__ */ new Set();
57
+ for (const stage of stages) {
58
+ if (stage.knownOriginals === void 0) continue;
59
+ try {
60
+ for (const { kind, value } of stage.knownOriginals()) {
61
+ const key = JSON.stringify([kind, value]);
62
+ if (seen.has(key)) continue;
63
+ seen.add(key);
64
+ out.push({
65
+ kind,
66
+ value
67
+ });
68
+ }
69
+ } catch (err) {
70
+ log.error(`transform knownOriginals failed: ${errorMessage(err)}`);
71
+ }
72
+ }
73
+ return out;
74
+ }
52
75
  /**
53
76
  * Streams stage by stage. `streams` is in pipeline order (stage 1 first).
54
77
  * push feeds stage n first and passes each result down to stage 1.
@@ -102,6 +125,74 @@ function createSessionFacts() {
102
125
  }
103
126
  };
104
127
  }
128
+ // src/leaks.ts
129
+ /** Shorter values match by chance too often to be worth a warning. */
130
+ const MIN_LEAK_LENGTH = 3;
131
+ const PLAIN_KEY = /^[A-Za-z_$][\w$]*$/;
132
+ /**
133
+ * The values the leak check compares against: the session's facts, each with its publisher as source, plus what the
134
+ * pipeline instance reports via `knownOriginals()` as source `pipeline`. If that throws, only the facts are returned.
135
+ */
136
+ function knownOriginals(facts, instance) {
137
+ const out = facts.list().map(({ kind, source, value }) => ({
138
+ kind,
139
+ source,
140
+ value
141
+ }));
142
+ if (instance?.knownOriginals === void 0) return out;
143
+ try {
144
+ const fromPipeline = [];
145
+ for (const { kind, value } of instance.knownOriginals()) fromPipeline.push({
146
+ kind,
147
+ source: "pipeline",
148
+ value
149
+ });
150
+ out.push(...fromPipeline);
151
+ } catch {}
152
+ return out;
153
+ }
154
+ /**
155
+ * Every place in `body` (the rewritten request) where a string value contains a known original, case-sensitively.
156
+ * Only string values are scanned, not object keys. The result holds kind, source and JSON path, never the value.
157
+ */
158
+ function findLeaks(body, originals) {
159
+ const candidates = [];
160
+ for (const original of originals) if (original.value.length >= MIN_LEAK_LENGTH) candidates.push(original);
161
+ if (candidates.length === 0) return [];
162
+ const leaks = [];
163
+ const seen = /* @__PURE__ */ new Set();
164
+ const visit = (node, path) => {
165
+ if (typeof node === "string") {
166
+ for (const { kind, source, value } of candidates) {
167
+ if (!node.includes(value)) continue;
168
+ const key = JSON.stringify([
169
+ kind,
170
+ source,
171
+ path
172
+ ]);
173
+ if (seen.has(key)) continue;
174
+ seen.add(key);
175
+ leaks.push({
176
+ kind,
177
+ source,
178
+ at: path
179
+ });
180
+ }
181
+ return;
182
+ }
183
+ if (Array.isArray(node)) {
184
+ node.forEach((item, i) => visit(item, `${path}[${i}]`));
185
+ return;
186
+ }
187
+ if (node !== null && typeof node === "object") for (const [key, value] of Object.entries(node)) visit(value, joinKey(path, key));
188
+ };
189
+ visit(body, "");
190
+ return leaks;
191
+ }
192
+ function joinKey(path, key) {
193
+ if (!PLAIN_KEY.test(key)) return `${path}[${JSON.stringify(key)}]`;
194
+ return path === "" ? key : `${path}.${key}`;
195
+ }
105
196
  // src/modules.ts
106
197
  /** Loads and opens every configured transform once. Any failure rejects with the transform id. */
107
198
  async function openTransforms(specs, baseDir, log) {
@@ -497,8 +588,81 @@ function tailDelta(kind, tail) {
497
588
  partial_json: tail
498
589
  };
499
590
  }
591
+ /**
592
+ * Rebuilds the Anthropic message from the raw upstream events (before restore) for `ctx.observe`.
593
+ * Errors are swallowed: recording the reply must never disturb the stream.
594
+ */
595
+ function createStreamObserver(observe) {
596
+ let message;
597
+ const content = [];
598
+ const toolJson = /* @__PURE__ */ new Map();
599
+ const settleTool = (index) => {
600
+ const json = toolJson.get(index);
601
+ const block = content[index];
602
+ if (json === void 0 || block === void 0) return;
603
+ toolJson.delete(index);
604
+ if (json === "") return;
605
+ try {
606
+ block.input = JSON.parse(json);
607
+ } catch {
608
+ block.input = json;
609
+ }
610
+ };
611
+ return {
612
+ event(payload) {
613
+ try {
614
+ const index = typeof payload.index === "number" ? payload.index : void 0;
615
+ switch (payload.type) {
616
+ case "message_start":
617
+ if (isObj$2(payload.message)) message = { ...payload.message };
618
+ return;
619
+ case "content_block_start":
620
+ if (index === void 0 || !isObj$2(payload.content_block)) return;
621
+ content[index] = { ...payload.content_block };
622
+ if (payload.content_block.type === "tool_use") toolJson.set(index, "");
623
+ return;
624
+ case "content_block_delta": {
625
+ const block = index === void 0 ? void 0 : content[index];
626
+ const delta = payload.delta;
627
+ if (block === void 0 || index === void 0 || !isObj$2(delta)) return;
628
+ if (delta.type === "text_delta" && typeof delta.text === "string") block.text = String(block.text ?? "") + delta.text;
629
+ else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") block.thinking = String(block.thinking ?? "") + delta.thinking;
630
+ else if (delta.type === "signature_delta" && typeof delta.signature === "string") block.signature = String(block.signature ?? "") + delta.signature;
631
+ else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") toolJson.set(index, (toolJson.get(index) ?? "") + delta.partial_json);
632
+ return;
633
+ }
634
+ case "content_block_stop":
635
+ if (index !== void 0) settleTool(index);
636
+ return;
637
+ case "message_delta":
638
+ if (message === void 0) return;
639
+ if (isObj$2(payload.delta)) {
640
+ if ("stop_reason" in payload.delta) message.stop_reason = payload.delta.stop_reason;
641
+ if ("stop_sequence" in payload.delta) message.stop_sequence = payload.delta.stop_sequence;
642
+ }
643
+ if (isObj$2(payload.usage)) message.usage = {
644
+ ...isObj$2(message.usage) ? message.usage : {},
645
+ ...payload.usage
646
+ };
647
+ return;
648
+ }
649
+ } catch {}
650
+ },
651
+ finish() {
652
+ try {
653
+ for (const index of [...toolJson.keys()]) settleTool(index);
654
+ if (message !== void 0) observe({
655
+ ...message,
656
+ content: content.filter((block) => block !== void 0)
657
+ });
658
+ } catch {}
659
+ message = void 0;
660
+ }
661
+ };
662
+ }
500
663
  function createStreamTransformer$2(ctx) {
501
664
  const blocks = /* @__PURE__ */ new Map();
665
+ const observer = ctx.observe === void 0 ? void 0 : createStreamObserver(ctx.observe);
502
666
  const handle = (block) => {
503
667
  let payload;
504
668
  try {
@@ -507,6 +671,7 @@ function createStreamTransformer$2(ctx) {
507
671
  return block.raw;
508
672
  }
509
673
  if (!isObj$2(payload)) return block.raw;
674
+ observer?.event(payload);
510
675
  const index = typeof payload.index === "number" ? payload.index : void 0;
511
676
  switch (payload.type) {
512
677
  case "content_block_start": {
@@ -568,6 +733,7 @@ function createStreamTransformer$2(ctx) {
568
733
  }
569
734
  };
570
735
  const finish = () => {
736
+ observer?.finish();
571
737
  let out = "";
572
738
  for (const [index, state] of blocks) {
573
739
  const tail = state.restore.flush();
@@ -807,10 +973,93 @@ function flushChoice(state) {
807
973
  state.tools.clear();
808
974
  return Object.keys(delta).length === 0 ? void 0 : delta;
809
975
  }
976
+ /** Rebuilds the non-streamed `chat.completion` from raw (pre-restore) chunks for `ctx.observe`. */
977
+ function createChatAssembler() {
978
+ let seen = false;
979
+ let envelope = {};
980
+ let usage;
981
+ const choices = /* @__PURE__ */ new Map();
982
+ return {
983
+ add(chunk) {
984
+ seen = true;
985
+ envelope = chunk;
986
+ if (chunk.usage !== void 0 && chunk.usage !== null) usage = chunk.usage;
987
+ for (const choice of chunk.choices) {
988
+ if (!isObj$1(choice)) continue;
989
+ const index = typeof choice.index === "number" ? choice.index : 0;
990
+ let state = choices.get(index);
991
+ if (state === void 0) {
992
+ state = {
993
+ content: null,
994
+ tools: /* @__PURE__ */ new Map(),
995
+ finish: null
996
+ };
997
+ choices.set(index, state);
998
+ }
999
+ if (choice.finish_reason !== null && choice.finish_reason !== void 0) state.finish = choice.finish_reason;
1000
+ const delta = choice.delta;
1001
+ if (!isObj$1(delta)) continue;
1002
+ if (typeof delta.content === "string") state.content = (state.content ?? "") + delta.content;
1003
+ if (typeof delta.reasoning_content === "string") state.reasoning = (state.reasoning ?? "") + delta.reasoning_content;
1004
+ if (!Array.isArray(delta.tool_calls)) continue;
1005
+ for (const call of delta.tool_calls) {
1006
+ if (!isObj$1(call)) continue;
1007
+ const callIndex = typeof call.index === "number" ? call.index : 0;
1008
+ let tool = state.tools.get(callIndex);
1009
+ if (tool === void 0) {
1010
+ tool = {
1011
+ name: "",
1012
+ arguments: ""
1013
+ };
1014
+ state.tools.set(callIndex, tool);
1015
+ }
1016
+ if (call.id !== void 0 && call.id !== null) tool.id = call.id;
1017
+ const fn = call.function;
1018
+ if (isObj$1(fn)) {
1019
+ if (typeof fn.name === "string") tool.name += fn.name;
1020
+ if (typeof fn.arguments === "string") tool.arguments += fn.arguments;
1021
+ }
1022
+ }
1023
+ }
1024
+ },
1025
+ take() {
1026
+ if (!seen) return void 0;
1027
+ seen = false;
1028
+ const out = {
1029
+ id: envelope.id,
1030
+ object: "chat.completion",
1031
+ model: envelope.model,
1032
+ choices: [...choices].sort(([a], [b]) => a - b).map(([index, state]) => {
1033
+ const message = {
1034
+ role: "assistant",
1035
+ content: state.content
1036
+ };
1037
+ if (state.reasoning !== void 0) message.reasoning_content = state.reasoning;
1038
+ if (state.tools.size > 0) message.tool_calls = [...state.tools].sort(([a], [b]) => a - b).map(([, t]) => ({
1039
+ id: t.id,
1040
+ type: "function",
1041
+ function: {
1042
+ name: t.name,
1043
+ arguments: t.arguments
1044
+ }
1045
+ }));
1046
+ return {
1047
+ index,
1048
+ message,
1049
+ finish_reason: state.finish
1050
+ };
1051
+ })
1052
+ };
1053
+ if (usage !== void 0) out.usage = usage;
1054
+ return out;
1055
+ }
1056
+ };
1057
+ }
810
1058
  function createStreamTransformer$1(ctx) {
811
1059
  const choices = /* @__PURE__ */ new Map();
812
1060
  /** The latest chunk's envelope (id, model, ...), reused for tail chunks. */
813
1061
  let envelope = { object: "chat.completion.chunk" };
1062
+ const assembler = ctx.observe === void 0 ? void 0 : createChatAssembler();
814
1063
  const choiceState = (index) => {
815
1064
  let state = choices.get(index);
816
1065
  if (state === void 0) {
@@ -889,6 +1138,7 @@ function createStreamTransformer$1(ctx) {
889
1138
  return block.raw;
890
1139
  }
891
1140
  if (!isObj$1(payload) || !Array.isArray(payload.choices)) return block.raw;
1141
+ assembler?.add(payload);
892
1142
  const { choices: _choices, ...rest } = payload;
893
1143
  envelope = rest;
894
1144
  let changed = false;
@@ -920,7 +1170,20 @@ function createStreamTransformer$1(ctx) {
920
1170
  choices: rewritten
921
1171
  }) : block.raw);
922
1172
  };
923
- return createSseFramer(handle, () => flushAll(void 0));
1173
+ const framer = createSseFramer(handle, () => flushAll(void 0));
1174
+ if (assembler === void 0) return framer;
1175
+ const observe = ctx.observe;
1176
+ return {
1177
+ push: (chunk) => framer.push(chunk),
1178
+ flush() {
1179
+ const out = framer.flush();
1180
+ const message = assembler.take();
1181
+ if (message !== void 0) try {
1182
+ observe(message);
1183
+ } catch {}
1184
+ return out;
1185
+ }
1186
+ };
924
1187
  }
925
1188
  const openaiChatWalker = {
926
1189
  async request(body, ctx) {
@@ -1165,6 +1428,8 @@ function restoreResponse(body, ctx) {
1165
1428
  }
1166
1429
  function createStreamTransformer(ctx) {
1167
1430
  const streams = /* @__PURE__ */ new Map();
1431
+ /** Raw (pre-restore) terminal `response` object, kept only for `ctx.observe`. */
1432
+ let final;
1168
1433
  const open = (key, factory) => {
1169
1434
  let state = streams.get(key);
1170
1435
  if (state === void 0) {
@@ -1256,10 +1521,16 @@ function createStreamTransformer(ctx) {
1256
1521
  item: restoreItem(payload.item, ctx)
1257
1522
  }) : block.raw;
1258
1523
  case "response.completed":
1259
- case "response.incomplete": return isObj(payload.response) ? formatSseEvent(block.event, {
1260
- ...payload,
1261
- response: restoreResponse(payload.response, ctx)
1262
- }) : block.raw;
1524
+ case "response.incomplete":
1525
+ if (!isObj(payload.response)) return block.raw;
1526
+ if (ctx.observe !== void 0) final = payload.response;
1527
+ return formatSseEvent(block.event, {
1528
+ ...payload,
1529
+ response: restoreResponse(payload.response, ctx)
1530
+ });
1531
+ case "response.failed":
1532
+ if (ctx.observe !== void 0 && isObj(payload.response)) final = payload.response;
1533
+ return block.raw;
1263
1534
  default: return block.raw;
1264
1535
  }
1265
1536
  };
@@ -1279,7 +1550,21 @@ function createStreamTransformer(ctx) {
1279
1550
  streams.clear();
1280
1551
  return out;
1281
1552
  };
1282
- return createSseFramer(handle, finish);
1553
+ const framer = createSseFramer(handle, finish);
1554
+ const observe = ctx.observe;
1555
+ if (observe === void 0) return framer;
1556
+ return {
1557
+ push: (chunk) => framer.push(chunk),
1558
+ flush() {
1559
+ const out = framer.flush();
1560
+ const response = final;
1561
+ final = void 0;
1562
+ if (response !== void 0) try {
1563
+ observe(response);
1564
+ } catch {}
1565
+ return out;
1566
+ }
1567
+ };
1283
1568
  }
1284
1569
  const openaiResponsesWalker = {
1285
1570
  async request(body, ctx) {
@@ -1315,21 +1600,25 @@ const openaiResponsesWalker = {
1315
1600
  */
1316
1601
  const WIRE_ROUTES = [
1317
1602
  {
1603
+ name: "anthropic.count_tokens",
1318
1604
  suffix: "/messages/count_tokens",
1319
1605
  wire: anthropicWalker,
1320
1606
  rewriteResponse: false
1321
1607
  },
1322
1608
  {
1609
+ name: "anthropic.messages",
1323
1610
  suffix: "/messages",
1324
1611
  wire: anthropicWalker,
1325
1612
  rewriteResponse: true
1326
1613
  },
1327
1614
  {
1615
+ name: "openai.chat",
1328
1616
  suffix: "/chat/completions",
1329
1617
  wire: openaiChatWalker,
1330
1618
  rewriteResponse: true
1331
1619
  },
1332
1620
  {
1621
+ name: "openai.responses",
1333
1622
  suffix: "/responses",
1334
1623
  wire: openaiResponsesWalker,
1335
1624
  rewriteResponse: true
@@ -1350,7 +1639,8 @@ function createSessionState() {
1350
1639
  return {
1351
1640
  facts: createSessionFacts(),
1352
1641
  signatures: /* @__PURE__ */ new Map(),
1353
- instances: /* @__PURE__ */ new Map()
1642
+ instances: /* @__PURE__ */ new Map(),
1643
+ traced: /* @__PURE__ */ new Set()
1354
1644
  };
1355
1645
  }
1356
1646
  /**
@@ -1380,6 +1670,65 @@ async function disposeInstances(state, log) {
1380
1670
  }
1381
1671
  }
1382
1672
  }
1673
+ // src/trace.ts
1674
+ const DEFAULT_TRACE_MAX_CHARS = 262144;
1675
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
1676
+ /** Top-level body keys whose array elements are traced one by one: the conversation history the harness resends each round. */
1677
+ const PER_ELEMENT_KEYS = {
1678
+ messages: true,
1679
+ input: true
1680
+ };
1681
+ /**
1682
+ * One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
1683
+ * `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
1684
+ */
1685
+ function serializeTraceEvent(event, maxChars = DEFAULT_TRACE_MAX_CHARS) {
1686
+ const content = JSON.stringify(event.content);
1687
+ if (content === void 0 || content.length <= maxChars) return JSON.stringify(event);
1688
+ return JSON.stringify({
1689
+ ...event,
1690
+ content: content.slice(0, maxChars),
1691
+ truncated: true
1692
+ });
1693
+ }
1694
+ /** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
1695
+ function createFileTraceSink(options) {
1696
+ const maxChars = options.maxChars ?? 262144;
1697
+ let created = false;
1698
+ return { record(event) {
1699
+ if (!created) {
1700
+ mkdirSync(options.dir, {
1701
+ recursive: true,
1702
+ mode: 448
1703
+ });
1704
+ created = true;
1705
+ }
1706
+ const file = join(options.dir, `${safeSessionId(event.sessionId)}.jsonl`);
1707
+ appendFileSync(file, `${serializeTraceEvent(event, maxChars)}\n`, { mode: 384 });
1708
+ } };
1709
+ }
1710
+ /** The id itself when it is a safe file name, otherwise a stable hash of it. */
1711
+ function safeSessionId(id) {
1712
+ if (SAFE_SESSION_ID.test(id)) return id;
1713
+ return `sha256-${createHash("sha256").update(id).digest("hex").slice(0, 32)}`;
1714
+ }
1715
+ /**
1716
+ * The request entries not yet traced in this session, keyed by JSON path; adds their hashes to `traced`.
1717
+ * `messages`/`input` arrays count per element, every other top-level key as a whole.
1718
+ */
1719
+ function requestDelta(body, traced) {
1720
+ const delta = {};
1721
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return delta;
1722
+ const add = (key, path, value) => {
1723
+ const hash = createHash("sha256").update(`${key}\u0000${JSON.stringify(value)}`).digest("hex");
1724
+ if (traced.has(hash)) return;
1725
+ traced.add(hash);
1726
+ delta[path] = value;
1727
+ };
1728
+ for (const [key, value] of Object.entries(body)) if (Object.hasOwn(PER_ELEMENT_KEYS, key) && Array.isArray(value)) value.forEach((element, i) => add(key, `${key}[${i}]`, element));
1729
+ else add(key, key, value);
1730
+ return delta;
1731
+ }
1383
1732
  // src/server.ts
1384
1733
  const UPSTREAM_HEADER = "x-skaile-transform-upstream";
1385
1734
  const SESSION_HEADER = "x-skaile-transform-session";
@@ -1415,6 +1764,8 @@ const RESPONSE_SKIP = {
1415
1764
  "content-encoding": true,
1416
1765
  "set-cookie": true
1417
1766
  };
1767
+ /** Upstream error bodies in the trace are cut to this many characters. */
1768
+ const ERROR_TRACE_CHARS = 4096;
1418
1769
  async function createTransformProxy(options) {
1419
1770
  const log = options.log ?? silentLogger;
1420
1771
  const host = options.host ?? "127.0.0.1";
@@ -1433,6 +1784,28 @@ async function createTransformProxy(options) {
1433
1784
  sessions.set(id, created);
1434
1785
  return created;
1435
1786
  }
1787
+ /** A throwing sink is logged and otherwise ignored: the trace must never change what the client gets. */
1788
+ function recordTrace(event) {
1789
+ if (options.trace === void 0) return;
1790
+ try {
1791
+ options.trace.record(event);
1792
+ } catch (err) {
1793
+ log.error(`transform proxy: trace sink failed: ${errorMessage(err)}`);
1794
+ }
1795
+ }
1796
+ /** Records this request's single response or error line; a no-op without a trace. */
1797
+ function recordReply(trace, phase, status, content) {
1798
+ if (trace === void 0) return;
1799
+ recordTrace({
1800
+ ...trace.base,
1801
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1802
+ phase,
1803
+ status,
1804
+ durationMs: Date.now() - trace.startedAt,
1805
+ content,
1806
+ leaks: []
1807
+ });
1808
+ }
1436
1809
  async function buildInstance(state, sessionId, pipelineId) {
1437
1810
  const stages = [];
1438
1811
  const ids = pipelines[pipelineId] ?? [];
@@ -1481,7 +1854,8 @@ async function createTransformProxy(options) {
1481
1854
  const method = req.method ?? "GET";
1482
1855
  const classified = classify(method, url.pathname);
1483
1856
  if (classified === void 0) return sendError(res, 404, "unsupported_route", `${method} ${url.pathname} is not proxied`);
1484
- const forwardTo = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}${url.search}`;
1857
+ const forwardPath = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}`;
1858
+ const forwardTo = `${forwardPath}${url.search}`;
1485
1859
  const headers = forwardRequestHeaders(req.headers);
1486
1860
  if (classified.kind === "passthrough") return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, void 0), void 0);
1487
1861
  const { route } = classified;
@@ -1512,12 +1886,36 @@ async function createTransformProxy(options) {
1512
1886
  if (err instanceof UnsupportedContentError) return sendError(res, 400, "unsupported_content", err.message);
1513
1887
  return sendError(res, 502, "transform_failed", errorMessage(err));
1514
1888
  }
1515
- return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten)), route.rewriteResponse ? {
1889
+ const leaks = findLeaks(rewritten, knownOriginals(state.facts.facts, instance));
1890
+ if (leaks.length > 0) log.warn("transform proxy: known original sent upstream", {
1891
+ sessionId,
1892
+ route: route.name,
1893
+ leaks
1894
+ });
1895
+ const trace = options.trace === void 0 ? void 0 : {
1896
+ startedAt: Date.now(),
1897
+ base: {
1898
+ requestId: randomUUID(),
1899
+ sessionId,
1900
+ pipeline: pipelineId,
1901
+ route: route.name,
1902
+ upstream: forwardPath,
1903
+ ...modelOf(rewritten)
1904
+ }
1905
+ };
1906
+ if (trace !== void 0) recordTrace({
1907
+ ...trace.base,
1908
+ at: new Date(trace.startedAt).toISOString(),
1909
+ phase: "request",
1910
+ content: requestDelta(rewritten, state.traced),
1911
+ leaks
1912
+ });
1913
+ return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten), trace), route.rewriteResponse ? {
1516
1914
  wire: route.wire,
1517
1915
  ctx
1518
- } : void 0);
1916
+ } : void 0, trace);
1519
1917
  }
1520
- async function fetchUpstream(res, target, method, headers, body) {
1918
+ async function fetchUpstream(res, target, method, headers, body, trace) {
1521
1919
  try {
1522
1920
  return await fetch(target, {
1523
1921
  method,
@@ -1526,21 +1924,61 @@ async function createTransformProxy(options) {
1526
1924
  redirect: "manual"
1527
1925
  });
1528
1926
  } catch (err) {
1927
+ recordReply(trace, "error", 502, errorMessage(err));
1529
1928
  sendError(res, 502, "upstream_unreachable", errorMessage(err));
1530
1929
  return;
1531
1930
  }
1532
1931
  }
1533
- /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
1534
- async function relayUpstream(res, upstreamRes, rewrite) {
1932
+ /**
1933
+ * Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. With `trace`
1934
+ * (wire routes only), the reply before restore is recorded as exactly one response or error line.
1935
+ */
1936
+ async function relayUpstream(res, upstreamRes, rewrite, trace) {
1535
1937
  if (upstreamRes === void 0) return;
1536
1938
  const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
1537
1939
  const headers = relayHeaders(upstreamRes.headers);
1538
- if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1539
- if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
1540
- const text = await upstreamRes.text();
1940
+ if (trace !== void 0 && !upstreamRes.ok) {
1941
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
1942
+ recordReply(trace, "error", upstreamRes.status, bytes.toString("utf8").slice(0, ERROR_TRACE_CHARS));
1943
+ res.writeHead(upstreamRes.status, headers);
1944
+ res.end(bytes);
1945
+ return;
1946
+ }
1947
+ if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) {
1948
+ if (trace === void 0) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1949
+ let observed = false;
1950
+ const observe = (message) => {
1951
+ observed = true;
1952
+ recordReply(trace, "response", upstreamRes.status, message);
1953
+ };
1954
+ await pipeStream(res, upstreamRes, headers, rewrite.wire.stream({
1955
+ ...rewrite.ctx,
1956
+ observe
1957
+ }), log);
1958
+ if (!observed) recordReply(trace, "response", upstreamRes.status, void 0);
1959
+ return;
1960
+ }
1961
+ if (upstreamRes.ok && contentType.includes("application/json") && (rewrite !== void 0 || trace !== void 0)) {
1962
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
1963
+ let body;
1964
+ try {
1965
+ body = JSON.parse(new TextDecoder().decode(bytes));
1966
+ } catch (err) {
1967
+ recordReply(trace, "error", 502, errorMessage(err));
1968
+ if (rewrite !== void 0) return sendError(res, 502, "transform_failed", errorMessage(err));
1969
+ res.writeHead(upstreamRes.status, headers);
1970
+ res.end(bytes);
1971
+ return;
1972
+ }
1973
+ recordReply(trace, "response", upstreamRes.status, body);
1974
+ if (rewrite === void 0) {
1975
+ res.writeHead(upstreamRes.status, headers);
1976
+ res.end(bytes);
1977
+ return;
1978
+ }
1541
1979
  let restored;
1542
1980
  try {
1543
- restored = JSON.stringify(rewrite.wire.response(JSON.parse(text), rewrite.ctx));
1981
+ restored = JSON.stringify(rewrite.wire.response(body, rewrite.ctx));
1544
1982
  } catch (err) {
1545
1983
  return sendError(res, 502, "transform_failed", errorMessage(err));
1546
1984
  }
@@ -1548,8 +1986,10 @@ async function createTransformProxy(options) {
1548
1986
  res.end(restored);
1549
1987
  return;
1550
1988
  }
1989
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
1990
+ recordReply(trace, "response", upstreamRes.status, void 0);
1551
1991
  res.writeHead(upstreamRes.status, headers);
1552
- res.end(Buffer.from(await upstreamRes.arrayBuffer()));
1992
+ res.end(bytes);
1553
1993
  }
1554
1994
  const server = createServer((req, res) => {
1555
1995
  handle(req, res).catch((err) => {
@@ -1613,6 +2053,12 @@ function validatePipelines(options) {
1613
2053
  for (const [pipelineId, ids] of Object.entries(options.pipelines)) for (const id of ids) if (!Object.hasOwn(options.transforms, id)) throw new Error(`pipeline "${pipelineId}" names unknown transform "${id}"`);
1614
2054
  if (options.defaultPipeline !== void 0 && !Object.hasOwn(options.pipelines, options.defaultPipeline)) throw new Error(`defaultPipeline "${options.defaultPipeline}" is not a configured pipeline`);
1615
2055
  }
2056
+ /** `{ model }` when the request body names its model as a string, otherwise `{}`. */
2057
+ function modelOf(body) {
2058
+ if (typeof body !== "object" || body === null) return {};
2059
+ const model = body.model;
2060
+ return typeof model === "string" ? { model } : {};
2061
+ }
1616
2062
  function originOf(value) {
1617
2063
  const url = parseHttpUrl(value);
1618
2064
  if (url === void 0) throw new Error(`allowUpstreams entry "${value}" is not an http(s) URL`);
@@ -1748,7 +2194,12 @@ const TOP_LEVEL_KEYS = {
1748
2194
  transforms: true,
1749
2195
  pipelines: true,
1750
2196
  defaultPipeline: true,
1751
- allowUpstreams: true
2197
+ allowUpstreams: true,
2198
+ trace: true
2199
+ };
2200
+ const TRACE_KEYS = {
2201
+ dir: true,
2202
+ maxChars: true
1752
2203
  };
1753
2204
  /** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
1754
2205
  function loadProxyConfig(path) {
@@ -1768,7 +2219,8 @@ function parseProxyConfig(source, baseDir) {
1768
2219
  transforms: parseTransforms(source.transforms),
1769
2220
  pipelines: parsePipelines(source.pipelines),
1770
2221
  ...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
1771
- ...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") }
2222
+ ...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") },
2223
+ ...source.trace === void 0 ? {} : { trace: parseTrace(source.trace, baseDir) }
1772
2224
  };
1773
2225
  }
1774
2226
  function parseTransforms(value) {
@@ -1792,6 +2244,17 @@ function parsePipelines(value) {
1792
2244
  for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
1793
2245
  return out;
1794
2246
  }
2247
+ function parseTrace(value, baseDir) {
2248
+ if (!isRecord(value)) throw new Error("trace must be a mapping with a dir");
2249
+ for (const key of Object.keys(value)) if (!Object.hasOwn(TRACE_KEYS, key)) throw new Error(`unknown key "trace.${key}"`);
2250
+ const dir = resolve(baseDir, requireString(value.dir, "trace.dir"));
2251
+ if (value.maxChars === void 0) return { dir };
2252
+ if (typeof value.maxChars !== "number" || !Number.isInteger(value.maxChars) || value.maxChars <= 0) throw new Error("trace.maxChars must be a positive integer");
2253
+ return {
2254
+ dir,
2255
+ maxChars: value.maxChars
2256
+ };
2257
+ }
1795
2258
  function requireString(value, field) {
1796
2259
  if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
1797
2260
  return value;
@@ -1803,4 +2266,4 @@ function requireStringList(value, field) {
1803
2266
  function isRecord(value) {
1804
2267
  return typeof value === "object" && value !== null && !Array.isArray(value);
1805
2268
  }
1806
- export { composeTransforms as _, parseProxyConfig as a, SESSION_HEADER as c, createTransformProxy as d, openaiResponsesWalker as f, createSessionFacts as g, UnsupportedContentError as h, loadProxyConfig as i, TOKEN_HEADER as l, anthropicWalker as m, configCandidates as n, resolveProxyConfig as o, openaiChatWalker as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u, errorMessage as v, silentLogger as y };
2269
+ export { silentLogger as S, anthropicWalker as _, parseProxyConfig as a, composeTransforms as b, SESSION_HEADER as c, createTransformProxy as d, DEFAULT_TRACE_MAX_CHARS as f, openaiChatWalker as g, openaiResponsesWalker as h, loadProxyConfig as i, TOKEN_HEADER as l, serializeTraceEvent as m, configCandidates as n, resolveProxyConfig as o, createFileTraceSink as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u, UnsupportedContentError as v, errorMessage as x, createSessionFacts as y };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as TransformModule, a as TransformProxyOptions, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TransformFactory, h as Transform, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as TransformSessionContext } from "./server-CicvlQ7q.mjs";
1
+ import { _ as TraceSink, a as TransformProxyOptions, b as TransformModule, c as TransformSpec, d as SessionFactPublisher, f as SessionFacts, g as TraceLeak, h as TraceEvent, i as TransformProxy, l as Logger, m as TextLeafKind, n as SESSION_HEADER, o as UPSTREAM_HEADER, p as TextLeaf, r as TOKEN_HEADER, s as createTransformProxy, t as PIPELINE_HEADER, u as SessionFact, v as Transform, x as TransformSessionContext, y as TransformFactory } from "./server-DdXMr4yF.mjs";
2
2
  /** One stage's streamed inbound transformer, as returned by `Transform.inboundStream()`. */
3
3
  interface InboundStream {
4
4
  push(chunk: string): string;
@@ -26,6 +26,11 @@ interface ProxyConfig {
26
26
  readonly pipelines: Record<string, readonly string[]>;
27
27
  readonly defaultPipeline?: string;
28
28
  readonly allowUpstreams?: readonly string[];
29
+ /** Trace file sink; `dir` is absolute (resolved against `baseDir`). */
30
+ readonly trace?: {
31
+ readonly dir: string;
32
+ readonly maxChars?: number;
33
+ };
29
34
  }
30
35
  interface ResolvedProxyConfig {
31
36
  readonly path: string;
@@ -50,6 +55,18 @@ export declare function resolveProxyConfig(lookup: ConfigLookup): ResolvedProxyC
50
55
  export declare function loadProxyConfig(path: string): ProxyConfig;
51
56
  /** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
52
57
  export declare function parseProxyConfig(source: unknown, baseDir: string): ProxyConfig;
58
+ export declare const DEFAULT_TRACE_MAX_CHARS = 262144;
59
+ /**
60
+ * One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
61
+ * `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
62
+ */
63
+ export declare function serializeTraceEvent(event: TraceEvent, maxChars?: number): string;
64
+ interface FileTraceSinkOptions {
65
+ readonly dir: string;
66
+ readonly maxChars?: number;
67
+ }
68
+ /** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
69
+ export declare function createFileTraceSink(options: FileTraceSinkOptions): TraceSink;
53
70
  interface WireContext {
54
71
  /** The session's composed pipeline. */
55
72
  readonly transform: Transform;
@@ -58,6 +75,13 @@ interface WireContext {
58
75
  * Walkers namespace their keys (`anthropic:<signature>`, `responses:<itemId>`).
59
76
  */
60
77
  readonly signatures: Map<string, string>;
78
+ /**
79
+ * Called by a stream transformer once, on `flush`, with the model's complete reply as it came
80
+ * from the provider (placeholders, before restore), in the shape of the protocol's non-streamed
81
+ * response. A throw is the caller's problem: transformers must catch it so the stream is never
82
+ * disturbed. Absent = nobody records the reply; walkers skip the assembly work.
83
+ */
84
+ readonly observe?: (message: unknown) => void;
61
85
  }
62
86
  /** Thrown by `request` for attachments (image, document, file parts); the server answers 400 `unsupported_content`. */
63
87
  export declare class UnsupportedContentError extends Error {
@@ -85,4 +109,4 @@ interface WireWalker {
85
109
  export declare const anthropicWalker: WireWalker;
86
110
  export declare const openaiChatWalker: WireWalker;
87
111
  export declare const openaiResponsesWalker: WireWalker;
88
- export { type ConfigLookup, type InboundStream, type Logger, PIPELINE_HEADER, type ProxyConfig, type ResolvedProxyConfig, SESSION_HEADER, type SessionFact, type SessionFactPublisher, type SessionFactStore, type SessionFacts, type SseTransformer, TOKEN_HEADER, type TextLeaf, type TextLeafKind, type Transform, type TransformFactory, type TransformModule, type TransformProxy, type TransformProxyOptions, type TransformSessionContext, type TransformSpec, UPSTREAM_HEADER, type WireContext, type WireWalker, createTransformProxy };
112
+ export { type ConfigLookup, type FileTraceSinkOptions, type InboundStream, type Logger, PIPELINE_HEADER, type ProxyConfig, type ResolvedProxyConfig, SESSION_HEADER, type SessionFact, type SessionFactPublisher, type SessionFactStore, type SessionFacts, type SseTransformer, TOKEN_HEADER, type TextLeaf, type TextLeafKind, type TraceEvent, type TraceLeak, type TraceSink, type Transform, type TransformFactory, type TransformModule, type TransformProxy, type TransformProxyOptions, type TransformSessionContext, type TransformSpec, UPSTREAM_HEADER, type WireContext, type WireWalker, createTransformProxy };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { t as CONFIG_FILE_NAME, s as PIPELINE_HEADER, c as SESSION_HEADER, l as TOKEN_HEADER, u as UPSTREAM_HEADER, h as UnsupportedContentError, m as anthropicWalker, _ as composeTransforms, n as configCandidates, g as createSessionFacts, d as createTransformProxy, r as findProxyConfig, i as loadProxyConfig, p as openaiChatWalker, f as openaiResponsesWalker, a as parseProxyConfig, o as resolveProxyConfig, y as silentLogger } from "./config-Ceb5AF_a.mjs";
1
+ export { t as CONFIG_FILE_NAME, f as DEFAULT_TRACE_MAX_CHARS, s as PIPELINE_HEADER, c as SESSION_HEADER, l as TOKEN_HEADER, u as UPSTREAM_HEADER, v as UnsupportedContentError, _ as anthropicWalker, b as composeTransforms, n as configCandidates, p as createFileTraceSink, y as createSessionFacts, d as createTransformProxy, r as findProxyConfig, i as loadProxyConfig, g as openaiChatWalker, h as openaiResponsesWalker, a as parseProxyConfig, o as resolveProxyConfig, m as serializeTraceEvent, S as silentLogger } from "./config-DHxYF3Tv.mjs";
@@ -1,7 +1,7 @@
1
- import { c as SESSION_HEADER, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, v as errorMessage } from "../config-Ceb5AF_a.mjs";
1
+ import { c as SESSION_HEADER, i as loadProxyConfig, l as TOKEN_HEADER, n as configCandidates, r as findProxyConfig, u as UPSTREAM_HEADER, x as errorMessage } from "../config-DHxYF3Tv.mjs";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { homedir } from "node:os";
7
7
  import { spawn } from "node:child_process";
@@ -16,6 +16,8 @@ const NODE_ENV = "SKAILE_TRANSFORM_PROXY_NODE";
16
16
  const MODE_ENV = "SKAILE_TRANSFORM_PROXY_MODE";
17
17
  /** Overrides the path of the saved default (`/anon on|off --global`). */
18
18
  const STATE_ENV = "SKAILE_TRANSFORM_PROXY_OMP_STATE";
19
+ /** The proxy's trace directory (relative to the cwd); the own proxy child inherits it with the rest of the environment. */
20
+ const TRACE_DIR_ENV = "SKAILE_TRANSFORM_PROXY_TRACE_DIR";
19
21
  /** The footer status key. */
20
22
  const STATUS_KEY = "anon";
21
23
  /** The model-registry source the proxy overrides are registered under; `/anon off` clears it in one reload. */
@@ -100,6 +102,8 @@ function createAnonController(deps) {
100
102
  let endpoint;
101
103
  let starting;
102
104
  let sessionId;
105
+ /** The context of the latest proxy-bound hook; leak notices of the proxy child go to its UI. */
106
+ let uiCtx;
103
107
  function stateOf(registry) {
104
108
  let state = registries.get(registry);
105
109
  if (state === void 0) {
@@ -112,6 +116,7 @@ function createAnonController(deps) {
112
116
  return state;
113
117
  }
114
118
  async function resolveEndpoint(ctx) {
119
+ uiCtx = ctx;
115
120
  if (endpoint !== void 0 && endpoint.child?.exited !== true) return endpoint;
116
121
  const url = env[URL_ENV];
117
122
  if (url !== void 0 && url !== "") {
@@ -177,12 +182,28 @@ function createAnonController(deps) {
177
182
  url: await waitForListening(child, {
178
183
  node,
179
184
  token,
180
- timeoutMs: listenTimeoutMs
185
+ timeoutMs: listenTimeoutMs,
186
+ onLeak: leakNotifier()
181
187
  }),
182
188
  token,
183
189
  pipeline: options.defaultPipeline,
184
190
  source: `child process ${child.pid}, config ${path}`,
185
- child: state
191
+ child: state,
192
+ ...options.trace === void 0 ? {} : { traceDir: options.trace.dir }
193
+ };
194
+ }
195
+ /** One child's leak warnings as notices: once per session and kind for the child's life, never with the value. */
196
+ function leakNotifier() {
197
+ const noticed = /* @__PURE__ */ new Set();
198
+ return (report) => {
199
+ const firstAt = /* @__PURE__ */ new Map();
200
+ for (const leak of report.leaks) if (!firstAt.has(leak.kind)) firstAt.set(leak.kind, leak.at);
201
+ for (const [kind, at] of firstAt) {
202
+ const key = JSON.stringify([report.sessionId, kind]);
203
+ if (noticed.has(key)) continue;
204
+ noticed.add(key);
205
+ uiCtx?.ui.notify(`anon: a known ${kind} value was sent to the provider in clear text (at ${at}, route ${report.route}). Value not shown.`, "warning");
206
+ }
186
207
  };
187
208
  }
188
209
  function currentSessionId(ctx) {
@@ -335,6 +356,7 @@ function createAnonController(deps) {
335
356
  lines.push(`pipeline: ${endpoint.pipeline ?? "the proxy default"}`);
336
357
  }
337
358
  if (sessionId !== void 0) lines.push(`session: ${sessionId}`);
359
+ lines.push(`trace: ${traceTarget(ctx.cwd)}`);
338
360
  if (imagesBlocked) lines.push("images: not sent (omp images.blockImages)");
339
361
  const routed = [...state.routed.keys()].sort();
340
362
  const refused = routed.flatMap((provider) => {
@@ -347,6 +369,12 @@ function createAnonController(deps) {
347
369
  if (preRouted.length > 0) lines.push(`routed by their own config (${preRouted.length}): ${preRouted.sort().join(", ")}`);
348
370
  return lines.join("\n");
349
371
  }
372
+ /** The proxy's trace directory, or `off`. With an external proxy only the env var is known here. */
373
+ function traceTarget(cwd) {
374
+ const fromEnv = env[TRACE_DIR_ENV];
375
+ if (fromEnv !== void 0 && fromEnv !== "") return resolve(cwd, fromEnv);
376
+ return endpoint?.traceDir ?? "off";
377
+ }
350
378
  /**
351
379
  * Session entry: rotate the proxy session for a new root, then (re-)apply routing when wanted.
352
380
  * Rotation only switches the session header: the proxy keeps the old session's state in memory
@@ -433,6 +461,7 @@ function createAnonController(deps) {
433
461
  if (enabled !== was) showStatus(ctx);
434
462
  },
435
463
  providerRequest(ctx) {
464
+ uiCtx = ctx;
436
465
  if (!wanted) return void 0;
437
466
  let reason;
438
467
  try {
@@ -470,11 +499,45 @@ function spawnNode(command, args, options) {
470
499
  }
471
500
  /** The stderr lines a startup failure quotes. */
472
501
  const STDERR_TAIL = 10;
502
+ /** An unterminated stderr line longer than this is dropped, so a child that never prints a newline cannot grow omp's memory. */
503
+ const STDERR_LINE_MAX = 65536;
504
+ /** The proxy's leak warning (`log.warn` with `{ sessionId, route, leaks }`, printed as JSON after the message). */
505
+ const LEAK_MARKER = "transform proxy: known original sent upstream";
506
+ /** The leak report on one stderr line of the proxy child; `undefined` for any other or malformed line. */
507
+ function parseLeakLine(line) {
508
+ const index = line.indexOf(LEAK_MARKER);
509
+ if (index === -1) return void 0;
510
+ let parsed;
511
+ try {
512
+ parsed = JSON.parse(line.slice(index + 45));
513
+ } catch {
514
+ return;
515
+ }
516
+ if (typeof parsed !== "object" || parsed === null) return void 0;
517
+ const { sessionId, route, leaks } = parsed;
518
+ if (typeof sessionId !== "string" || typeof route !== "string" || !Array.isArray(leaks)) return void 0;
519
+ const out = [];
520
+ for (const leak of leaks) {
521
+ if (typeof leak !== "object" || leak === null) return void 0;
522
+ const { kind, at } = leak;
523
+ if (typeof kind !== "string" || typeof at !== "string") return void 0;
524
+ out.push({
525
+ kind,
526
+ at
527
+ });
528
+ }
529
+ return out.length === 0 ? void 0 : {
530
+ sessionId,
531
+ route,
532
+ leaks: out
533
+ };
534
+ }
473
535
  /**
474
536
  * Resolves with the URL of the child's `listening <url>` line. A child that exits first, fails
475
537
  * to spawn or stays silent past `timeoutMs` (it is then killed) rejects, quoting its last stderr
476
538
  * lines with the token redacted. omp exiting kills the child; once listening, the child and its
477
539
  * pipes no longer hold omp's event loop, and stdin stays open so the child sees omp go away.
540
+ * After startup every leak warning on stderr goes to `onLeak`.
478
541
  */
479
542
  function waitForListening(child, how) {
480
543
  const killOnExit = () => {
@@ -511,11 +574,21 @@ function waitForListening(child, how) {
511
574
  child.kill("SIGKILL");
512
575
  }, how.timeoutMs);
513
576
  child.stderr?.on("data", (chunk) => {
514
- if (settled) return;
515
577
  const lines = (stderrPartial + String(chunk)).split("\n");
516
578
  stderrPartial = lines.pop() ?? "";
517
- tail.push(...lines.map((line) => line.replace(/\r$/, "")));
518
- tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
579
+ if (!settled) {
580
+ tail.push(...lines.map((line) => line.replace(/\r$/, "")));
581
+ tail.splice(0, Math.max(0, tail.length - STDERR_TAIL));
582
+ return;
583
+ }
584
+ if (stderrPartial.length > STDERR_LINE_MAX) stderrPartial = "";
585
+ for (const line of lines) {
586
+ const report = parseLeakLine(line);
587
+ if (report === void 0) continue;
588
+ try {
589
+ how.onLeak(report);
590
+ } catch {}
591
+ }
519
592
  });
520
593
  child.stdout?.on("data", (chunk) => {
521
594
  if (settled) return;
@@ -27,6 +27,14 @@ interface Transform {
27
27
  dispose?(): void | Promise<void>;
28
28
  /** Host-only state of this instance (e.g. the anon mapping), read through TransformProxy.inspect. Never called by the walkers, never sent anywhere. */
29
29
  inspect?(): unknown;
30
+ /**
31
+ * Originals this instance has already replaced in this session (forge-anon: its mapping), each with its kind.
32
+ * Used only by the proxy's leak check, which reports kind and JSON path, never the value; never sent anywhere.
33
+ */
34
+ knownOriginals?(): Iterable<{
35
+ readonly kind: string;
36
+ readonly value: string;
37
+ }>;
30
38
  }
31
39
  interface SessionFact {
32
40
  readonly kind: string;
@@ -58,6 +66,51 @@ interface TransformModule {
58
66
  readonly log: Logger;
59
67
  }): Promise<TransformFactory>;
60
68
  }
69
+ /** One leak-check finding. Never carries the value itself. */
70
+ interface TraceLeak {
71
+ /** Fact/original kind, e.g. 'person'. */
72
+ readonly kind: string;
73
+ /** Fact source (publisher id), or 'pipeline' for `Transform.knownOriginals`. */
74
+ readonly source: string;
75
+ /** JSON path in the rewritten request body, e.g. 'tools[0].description'. */
76
+ readonly at: string;
77
+ }
78
+ /** One line of the anon trace: what left for the provider (request) or what it answered before restore (response/error). */
79
+ interface TraceEvent {
80
+ /** ISO timestamp. */
81
+ readonly at: string;
82
+ /** Per proxied request; links its request/response/error lines. */
83
+ readonly requestId: string;
84
+ readonly sessionId: string;
85
+ readonly pipeline: string;
86
+ /** `WireRoute.name`, e.g. 'anthropic.messages'. */
87
+ readonly route: string;
88
+ /** Forward target origin + path, no query string. */
89
+ readonly upstream: string;
90
+ /** Request body `model` when a string. */
91
+ readonly model?: string;
92
+ readonly phase: 'request' | 'response' | 'error';
93
+ /** response/error: HTTP status (502 when the upstream was unreachable). */
94
+ readonly status?: number;
95
+ /** response/error: ms since the request event. */
96
+ readonly durationMs?: number;
97
+ /**
98
+ * request: the delta, keyed by JSON path (`{ 'messages[4]': {...}, system: [...] }`), only entries not yet traced in
99
+ * this session; `{}` when nothing is new.
100
+ * response: the model reply before restore (streamed: assembled in the protocol's non-streamed shape; `undefined` when
101
+ * the stream ended before a complete reply).
102
+ * error: the upstream error body as text, cut to 4096 chars, or the fetch error message.
103
+ */
104
+ readonly content?: unknown;
105
+ /** Set only by `serializeTraceEvent`. */
106
+ readonly truncated?: boolean;
107
+ /** request: leak-check findings; response/error: []. */
108
+ readonly leaks: readonly TraceLeak[];
109
+ }
110
+ /** Synchronous. The proxy catches a throw, logs it, and never lets it reach the client. */
111
+ interface TraceSink {
112
+ record(event: TraceEvent): void;
113
+ }
61
114
  /** A configured transform: a module (object, npm specifier or relative path) and its config. */
62
115
  interface TransformSpec {
63
116
  readonly module: string | TransformModule;
@@ -79,6 +132,8 @@ interface TransformProxyOptions {
79
132
  /** Origins. Absent = any https origin plus loopback http. */
80
133
  allowUpstreams?: readonly string[];
81
134
  log?: Logger;
135
+ /** Records what goes upstream after the pipeline and what comes back before restore. Absent = no trace. */
136
+ trace?: TraceSink;
82
137
  }
83
138
  interface TransformProxy {
84
139
  readonly url: string;
@@ -101,4 +156,4 @@ declare const SESSION_HEADER = "x-skaile-transform-session";
101
156
  declare const PIPELINE_HEADER = "x-skaile-transform-pipeline";
102
157
  declare const TOKEN_HEADER = "x-skaile-transform-token";
103
158
  declare function createTransformProxy(options: TransformProxyOptions): Promise<TransformProxy>;
104
- export { TransformModule as _, TransformProxyOptions as a, TransformSpec as c, SessionFactPublisher as d, SessionFacts as f, TransformFactory as g, Transform as h, TransformProxy as i, Logger as l, TextLeafKind as m, SESSION_HEADER as n, UPSTREAM_HEADER as o, TextLeaf as p, TOKEN_HEADER as r, createTransformProxy as s, PIPELINE_HEADER as t, SessionFact as u, TransformSessionContext as v };
159
+ export { TraceSink as _, TransformProxyOptions as a, TransformModule as b, TransformSpec as c, SessionFactPublisher as d, SessionFacts as f, TraceLeak as g, TraceEvent as h, TransformProxy as i, Logger as l, TextLeafKind as m, SESSION_HEADER as n, UPSTREAM_HEADER as o, TextLeaf as p, TOKEN_HEADER as r, createTransformProxy as s, PIPELINE_HEADER as t, SessionFact as u, Transform as v, TransformSessionContext as x, TransformFactory as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/transform-proxy",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Same-protocol passthrough proxy that runs model requests and replies through a chain of text transforms (Anthropic Messages, OpenAI chat, OpenAI responses)",
6
6
  "license": "MIT",