@skaile/transform-proxy 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +74 -5
- package/dist/cli.d.mts +8 -1
- package/dist/cli.mjs +41 -28
- package/dist/index.d.mts +34 -10
- package/dist/index.mjs +1 -1
- package/dist/omp/index.mjs +81 -8
- package/dist/{config-Ceb5AF_a.mjs → server-BR1dDA8S.mjs} +580 -117
- package/dist/{server-CicvlQ7q.d.mts → server-gwJGHF82.d.mts} +57 -2
- package/package.json +9 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 21bab3c: The bin installs its SIGINT, SIGTERM and `--exit-with-stdin` handlers before it prints `listening`. A parent that stopped the proxy right after that line could kill it with the default signal action (exit 143) instead of a clean shutdown (exit 0). `runCli` takes an optional `onStart` callback, called after the proxy listens and before the `listening` line.
|
|
8
|
+
|
|
9
|
+
## 0.5.0
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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`.
|
|
15
|
+
- Library: trace lines name the route: `anthropic.messages`, `anthropic.count_tokens`, `openai.chat` or `openai.responses`.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
3
24
|
## 0.4.0
|
|
4
25
|
|
|
5
26
|
- 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?()
|
|
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,7 @@ 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
|
-
-
|
|
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.
|
|
217
|
+
- omp writes the request of every HTTP 400 answer to `~/.omp/logs/http-400-requests/<id>.json` (mode `0644`). That copy is what omp sent to the proxy, before anonymization, so it holds the personal data in clear text. It stays on the machine; delete it when anon is in use.
|
package/dist/cli.d.mts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { i as TransformProxy } from "./server-
|
|
1
|
+
import { i as TransformProxy } from "./server-gwJGHF82.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;
|
|
7
9
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
8
10
|
readonly stdout: (line: string) => void;
|
|
9
11
|
readonly stderr: (line: string) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Called once the proxy listens, before the `listening` line: a parent reacts to that line (with SIGTERM, or by
|
|
14
|
+
* closing stdin), so the caller's shutdown handlers must be in place by then.
|
|
15
|
+
*/
|
|
16
|
+
readonly onStart?: (run: Required<CliRun>) => void;
|
|
10
17
|
}
|
|
11
18
|
/** `proxy` is set only when the server started; the caller owns its shutdown. */
|
|
12
19
|
export interface CliRun {
|
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { a as createTransformProxy, s as createFileTraceSink, x as errorMessage, y as resolveProxyConfig } from "./server-BR1dDA8S.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
|
-
|
|
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
|
-
...
|
|
64
|
+
...proxyOptions,
|
|
58
65
|
host: values.host,
|
|
59
66
|
port,
|
|
60
67
|
token: values.token ?? io.env["SKAILE_TRANSFORM_PROXY_TOKEN"],
|
|
@@ -63,14 +70,21 @@ 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
|
});
|
|
68
|
-
io.
|
|
69
|
-
|
|
79
|
+
if (traceDir !== void 0) io.stderr(format("info", `trace: ${traceDir}`, []));
|
|
80
|
+
const run = {
|
|
70
81
|
code: 0,
|
|
71
82
|
proxy,
|
|
72
83
|
exitWithStdin: values["exit-with-stdin"] === true
|
|
73
84
|
};
|
|
85
|
+
io.onStart?.(run);
|
|
86
|
+
io.stdout(`listening ${proxy.url}`);
|
|
87
|
+
return run;
|
|
74
88
|
} catch (err) {
|
|
75
89
|
io.stderr(errorMessage(err));
|
|
76
90
|
return { code: 1 };
|
|
@@ -82,7 +96,7 @@ function parsePort(value) {
|
|
|
82
96
|
return port <= 65535 ? port : void 0;
|
|
83
97
|
}
|
|
84
98
|
function format(level, msg, args) {
|
|
85
|
-
return [`[transform-proxy ${level}] ${msg}`, ...args.map(
|
|
99
|
+
return [`[transform-proxy ${level}] ${msg}`, ...args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg))].join(" ");
|
|
86
100
|
}
|
|
87
101
|
/** True when this module is the executed entry point (the bin, or `bun src/cli.ts`). */
|
|
88
102
|
function isEntryPoint() {
|
|
@@ -100,27 +114,26 @@ if (isEntryPoint()) {
|
|
|
100
114
|
home: homedir(),
|
|
101
115
|
env: process.env,
|
|
102
116
|
stdout: (line) => console.log(line),
|
|
103
|
-
stderr: (line) => console.error(line)
|
|
117
|
+
stderr: (line) => console.error(line),
|
|
118
|
+
onStart: ({ proxy, exitWithStdin }) => {
|
|
119
|
+
let stopping = false;
|
|
120
|
+
const stop = () => {
|
|
121
|
+
if (stopping) return;
|
|
122
|
+
stopping = true;
|
|
123
|
+
proxy.close().then(() => process.exit(0), (err) => {
|
|
124
|
+
console.error(errorMessage(err));
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
process.once("SIGINT", stop);
|
|
129
|
+
process.once("SIGTERM", stop);
|
|
130
|
+
if (exitWithStdin) {
|
|
131
|
+
process.stdin.once("end", stop);
|
|
132
|
+
process.stdin.once("close", stop);
|
|
133
|
+
process.stdin.resume();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
104
136
|
});
|
|
105
137
|
if (run.proxy === void 0) process.exitCode = run.code;
|
|
106
|
-
else {
|
|
107
|
-
const proxy = run.proxy;
|
|
108
|
-
let stopping = false;
|
|
109
|
-
const stop = () => {
|
|
110
|
-
if (stopping) return;
|
|
111
|
-
stopping = true;
|
|
112
|
-
proxy.close().then(() => process.exit(0), (err) => {
|
|
113
|
-
console.error(errorMessage(err));
|
|
114
|
-
process.exit(1);
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
process.once("SIGINT", stop);
|
|
118
|
-
process.once("SIGTERM", stop);
|
|
119
|
-
if (run.exitWithStdin === true) {
|
|
120
|
-
process.stdin.once("end", stop);
|
|
121
|
-
process.stdin.once("close", stop);
|
|
122
|
-
process.stdin.resume();
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
138
|
}
|
|
126
|
-
export { TOKEN_ENV, runCli };
|
|
139
|
+
export { TOKEN_ENV, TRACE_DIR_ENV, runCli };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
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-gwJGHF82.mjs";
|
|
2
2
|
/** One stage's streamed inbound transformer, as returned by `Transform.inboundStream()`. */
|
|
3
3
|
interface InboundStream {
|
|
4
4
|
push(chunk: string): string;
|
|
@@ -10,14 +10,6 @@ interface InboundStream {
|
|
|
10
10
|
* The empty list is the identity.
|
|
11
11
|
*/
|
|
12
12
|
export declare function composeTransforms(stages: readonly Transform[], log?: Logger): Transform;
|
|
13
|
-
/** Default logger: drops everything. Hosts pass their own `Logger`. */
|
|
14
|
-
export declare const silentLogger: Logger;
|
|
15
|
-
/** One session's facts: the read side for transforms and the per-source publishers for connectors. */
|
|
16
|
-
interface SessionFactStore {
|
|
17
|
-
readonly facts: SessionFacts;
|
|
18
|
-
publisher(source: string): SessionFactPublisher;
|
|
19
|
-
}
|
|
20
|
-
export declare function createSessionFacts(): SessionFactStore;
|
|
21
13
|
export declare const CONFIG_FILE_NAME = "transform-proxy.yaml";
|
|
22
14
|
/** The YAML-backed subset of the proxy options. `baseDir` is the config file's directory. */
|
|
23
15
|
interface ProxyConfig {
|
|
@@ -26,6 +18,11 @@ interface ProxyConfig {
|
|
|
26
18
|
readonly pipelines: Record<string, readonly string[]>;
|
|
27
19
|
readonly defaultPipeline?: string;
|
|
28
20
|
readonly allowUpstreams?: readonly string[];
|
|
21
|
+
/** Trace file sink; `dir` is absolute (resolved against `baseDir`). */
|
|
22
|
+
readonly trace?: {
|
|
23
|
+
readonly dir: string;
|
|
24
|
+
readonly maxChars?: number;
|
|
25
|
+
};
|
|
29
26
|
}
|
|
30
27
|
interface ResolvedProxyConfig {
|
|
31
28
|
readonly path: string;
|
|
@@ -50,6 +47,26 @@ export declare function resolveProxyConfig(lookup: ConfigLookup): ResolvedProxyC
|
|
|
50
47
|
export declare function loadProxyConfig(path: string): ProxyConfig;
|
|
51
48
|
/** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
|
|
52
49
|
export declare function parseProxyConfig(source: unknown, baseDir: string): ProxyConfig;
|
|
50
|
+
/** One session's facts: the read side for transforms and the per-source publishers for connectors. */
|
|
51
|
+
interface SessionFactStore {
|
|
52
|
+
readonly facts: SessionFacts;
|
|
53
|
+
publisher(source: string): SessionFactPublisher;
|
|
54
|
+
}
|
|
55
|
+
export declare function createSessionFacts(): SessionFactStore;
|
|
56
|
+
/** Default logger: drops everything. Hosts pass their own `Logger`. */
|
|
57
|
+
export declare const silentLogger: Logger;
|
|
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 {
|
|
1
|
+
export { m as CONFIG_FILE_NAME, o as DEFAULT_TRACE_MAX_CHARS, t as PIPELINE_HEADER, n as SESSION_HEADER, r as TOKEN_HEADER, i as UPSTREAM_HEADER, f as UnsupportedContentError, d as anthropicWalker, b as composeTransforms, h as configCandidates, s as createFileTraceSink, p as createSessionFacts, a as createTransformProxy, g as findProxyConfig, _ as loadProxyConfig, u as openaiChatWalker, l as openaiResponsesWalker, v as parseProxyConfig, y as resolveProxyConfig, c as serializeTraceEvent, S as silentLogger } from "./server-BR1dDA8S.mjs";
|
package/dist/omp/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { _ as loadProxyConfig, g as findProxyConfig, h as configCandidates, i as UPSTREAM_HEADER, n as SESSION_HEADER, r as TOKEN_HEADER, x as errorMessage } from "../server-BR1dDA8S.mjs";
|
|
3
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
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
|
-
|
|
518
|
-
|
|
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;
|