@linxiraos/pi-utils 1.4.0 → 1.4.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 CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.1] - 2026-10-23
4
+
5
+ - Version alignment with the 1.4.1 release line (no behavioral changes).
6
+
3
7
  ## [1.4.0] - 2026-10-21
4
8
 
5
9
  - Version line moves to 1.4.0 with the rest of the suite; no user-visible change in this package.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Shared utilities for [omp](https://github.com/can1357/oh-my-pi) packages. Zero c
6
6
 
7
7
  | Module | Purpose |
8
8
  | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
9
- | `logger` | Centralized logger writing to `~/.zeta/logs/` with rotation (TUI-safe — never stdout) |
9
+ | `logger` | Centralized logger writing to `~/.zeta/logs/` (`bun test`: temp dir), rotating, never stdout |
10
10
  | `prompt` | Handlebars-based prompt templating and formatting helpers |
11
11
  | `dirs` | Path helpers for zetacode config directories (`~/.zeta`, XDG-aware on Linux) |
12
12
  | `stream` | `readStream` / `readLines` helpers over `ReadableStream` |
@@ -7,7 +7,15 @@ export interface CreateTerminalResponse {
7
7
  }
8
8
  /** Client callbacks handled by a client-side connection. */
9
9
  export interface Client {
10
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
10
+ /**
11
+ * Serve a permission elicit. `options.signal` aborts when the agent
12
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
13
+ * the dialog — so a held handler can end instead of stranding the
14
+ * connection's clean-EOF drain.
15
+ */
16
+ requestPermission(params: RequestPermissionRequest, options?: {
17
+ signal?: AbortSignal;
18
+ }): MaybePromise<RequestPermissionResponse>;
11
19
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
12
20
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
13
21
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -45,6 +53,15 @@ export declare class AgentSideConnection {
45
53
  sessionUpdate(params: SessionNotification): Promise<void>;
46
54
  /** Requests permission from the client. */
47
55
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
56
+ /**
57
+ * Requests permission from the client, returning a handle that cancels the
58
+ * pending wire request (`$/cancel_request`) — used when user steering
59
+ * supersedes an open permission dialog instead of waiting behind it.
60
+ */
61
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
62
+ promise: Promise<RequestPermissionResponse>;
63
+ cancel(): void;
64
+ };
48
65
  /** Reads a client-side text file. */
49
66
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
50
67
  /** Writes a client-side text file. */
@@ -69,17 +69,41 @@ export declare class RequestError extends Error {
69
69
  /** Converts this error into a JSON-RPC error payload. */
70
70
  toErrorResponse(): ErrorResponse;
71
71
  }
72
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
72
+ type Dispatcher = (method: string, params: unknown, notification: boolean,
73
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
74
+ signal?: AbortSignal) => MaybePromise<unknown>;
75
+ /** Options for {@link RpcConnection}. */
76
+ export interface RpcConnectionOptions {
77
+ /**
78
+ * The dispatcher honors the abort signal it receives for inbound requests.
79
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
80
+ * request `-32800` immediately; otherwise the handler runs to completion and
81
+ * its real result is the response, since answering "cancelled" while the
82
+ * work keeps going would hide its outcome from the peer.
83
+ */
84
+ honorsInboundCancellation?: boolean;
85
+ }
73
86
  /** Correlated bidirectional JSON-RPC connection. */
74
87
  export declare class RpcConnection {
75
88
  #private;
76
- constructor(stream: Stream, dispatcher: Dispatcher);
89
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions);
77
90
  /** Signal aborted when the stream closes. */
78
91
  get signal(): AbortSignal;
79
92
  /** Promise resolved when the stream closes. */
80
93
  get closed(): Promise<void>;
81
94
  /** Sends a correlated JSON-RPC request. */
82
95
  request<Response>(method: string, params?: unknown): Promise<Response>;
96
+ /**
97
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
98
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
99
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
100
+ * response is ignored. Used for agent→client requests a mid-turn event can
101
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
102
+ */
103
+ requestCancellable<Response>(method: string, params?: unknown): {
104
+ promise: Promise<Response>;
105
+ cancel(): void;
106
+ };
83
107
  /** Sends a JSON-RPC notification. */
84
108
  notify(method: string, params?: unknown): Promise<void>;
85
109
  /** Closes the connection and rejects outstanding requests. */
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** gzip magic: 0x1f 0x8b. */
2
3
  export declare function isGzip(bytes: Uint8Array): boolean;
3
4
  /** Decompress one gzip stream, bounded so a gzip bomb cannot inflate without limit. */
4
5
  export declare function gzipDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one gzip stream (tar.gz writing). */
6
9
  export declare function gzipCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** zstd frame magic: 28 b5 2f fd. */
2
3
  export declare function isZstd(bytes: Uint8Array): boolean;
3
4
  /** Decompress one zstd frame, bounded to `maxOutput` bytes. */
4
5
  export declare function zstdDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one zstd frame (tar.zst writing). */
6
9
  export declare function zstdCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -9,6 +9,8 @@ export interface ArchiveLimits {
9
9
  maxEntries: number;
10
10
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
11
11
  maxInMemorySize: number;
12
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
13
+ maxStreamSize: number;
12
14
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
13
15
  maxIndexSize: number;
14
16
  /** Max declared bytes for a single extracted member. */
@@ -1,3 +1,4 @@
1
+ import { type Transform } from "node:stream";
1
2
  /**
2
3
  * A byte window into an archive — file-backed (lazy, ranged reads) or
3
4
  * in-memory. Format readers index through this so ZIP/ASAR/RAR payloads are
@@ -17,6 +18,14 @@ export declare function memoryByteSource(buffer: Uint8Array): ByteSource;
17
18
  export declare function fileByteSource(filePath: string): ByteSource;
18
19
  /** Materialize an entire {@link ByteSource}; use only under a limits check. */
19
20
  export declare function readAllBytes(source: ByteSource): Promise<Uint8Array>;
21
+ /** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
22
+ export declare const STREAM_CHUNK_SIZE: number;
23
+ /**
24
+ * Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
25
+ * the compressed nor the decompressed stream is ever held whole. Output past
26
+ * `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
27
+ */
28
+ export declare function decodeByteSource(source: ByteSource, decoder: Transform, maxOutput: number): AsyncGenerator<Uint8Array>;
20
29
  /** Options for {@link httpByteSource}. */
21
30
  export interface HttpByteSourceOptions {
22
31
  /** Extra request headers (e.g. authorization). */
@@ -1,6 +1,11 @@
1
1
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types.js";
2
- /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
3
- export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[];
2
+ /** Index an already-decompressed tar buffer; members are views into it. */
3
+ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
+ /**
5
+ * Index a decompressed tar stream that is too large to hold in memory: one
6
+ * pass over `open()` walks the headers, and member reads open it again.
7
+ */
8
+ export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
9
  /** Read and index a tar source after one bounded whole-stream read. */
5
10
  export declare const readTar: FormatReader;
6
11
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
@@ -49,7 +49,14 @@ export declare function normalizeProfileName(profile: string | undefined): strin
49
49
  export declare function resolveProfileEnv(zeta: string | undefined): string | undefined;
50
50
  /** Profile-independent config root (~/.zeta), shared by every zeta profile. */
51
51
  export declare function getBaseConfigRoot(): string;
52
- export declare function resolveEquivalentPath(inputPath: string): string;
52
+ export interface EquivalentPathOptions {
53
+ /**
54
+ * Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
55
+ * missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
56
+ */
57
+ throughMissing?: boolean;
58
+ }
59
+ export declare function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string;
53
60
  export declare function normalizePathForComparison(inputPath: string): string;
54
61
  /**
55
62
  * Compare paths already normalized by {@link normalizePathForComparison}.
@@ -1,3 +1,4 @@
1
+ export * from "./test-runtime.js";
1
2
  export * from "./worker-host.js";
2
3
  /**
3
4
  * Strict shell-identifier shape. Used for dotenv keys we accept into
@@ -81,8 +82,6 @@ export declare function $envExact(name: string, env?: Record<string, string | un
81
82
  * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
82
83
  */
83
84
  export declare function $envpos(name: string, defaultValue: number): number;
84
- /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
85
- export declare function isBunTestRuntime(): boolean;
86
85
  /**
87
86
  * True when real-terminal side effects must be suppressed: stdout escape/frame
88
87
  * writes, stdin raw-mode + resume, CSI/OSC capability probes, SIGWINCH, window
@@ -2,7 +2,8 @@
2
2
  * `fs.openSync` with close-on-exec, matching Node's default.
3
3
  *
4
4
  * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
5
- * dies at `exec`. Bun's `fs.open*` does not, and descriptors it returns survive
5
+ * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
6
+ * `bun@>=1.4` pin), and descriptors it returns survive
6
7
  * into any child spawned through a path that does not close strays itself — the
7
8
  * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
8
9
  * this for every descriptor kept open past such a spawn (session transcripts,
@@ -0,0 +1,2 @@
1
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
2
+ export declare function isBunTestRuntime(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,7 +54,7 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.4.0"
57
+ "@linxiraos/pi-natives": "1.4.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "1.4.3"
@@ -47,7 +47,16 @@ export interface CreateTerminalResponse {
47
47
  }
48
48
  /** Client callbacks handled by a client-side connection. */
49
49
  export interface Client {
50
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
50
+ /**
51
+ * Serve a permission elicit. `options.signal` aborts when the agent
52
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
53
+ * the dialog — so a held handler can end instead of stranding the
54
+ * connection's clean-EOF drain.
55
+ */
56
+ requestPermission(
57
+ params: RequestPermissionRequest,
58
+ options?: { signal?: AbortSignal },
59
+ ): MaybePromise<RequestPermissionResponse>;
51
60
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
52
61
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
53
62
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -90,6 +99,17 @@ export class AgentSideConnection {
90
99
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
91
100
  return this.#connection.request("session/request_permission", params);
92
101
  }
102
+ /**
103
+ * Requests permission from the client, returning a handle that cancels the
104
+ * pending wire request (`$/cancel_request`) — used when user steering
105
+ * supersedes an open permission dialog instead of waiting behind it.
106
+ */
107
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
108
+ promise: Promise<RequestPermissionResponse>;
109
+ cancel(): void;
110
+ } {
111
+ return this.#connection.requestCancellable<RequestPermissionResponse>("session/request_permission", params);
112
+ }
93
113
  /** Reads a client-side text file. */
94
114
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
95
115
  return this.#connection.request("fs/read_text_file", params);
@@ -168,10 +188,18 @@ export class ClientSideConnection {
168
188
  #connection: RpcConnection;
169
189
 
170
190
  constructor(toClient: (connection: ClientSideConnection) => Client, stream: Stream) {
171
- this.#connection = new RpcConnection(stream, async (method, params, notification) => {
172
- if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
173
- return dispatchClient(client, method, params, notification);
174
- });
191
+ // Client handlers receive the cancel signal (`requestPermission`'s
192
+ // `options.signal`), so a peer `$/cancel_request` may answer `-32800`
193
+ // at once. The agent side keeps the default: its handlers ignore the
194
+ // signal, and `session/cancel` is ACP's cancellation path for prompts.
195
+ this.#connection = new RpcConnection(
196
+ stream,
197
+ async (method, params, notification, signal) => {
198
+ if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
199
+ return dispatchClient(client, method, params, notification, signal);
200
+ },
201
+ { honorsInboundCancellation: true },
202
+ );
175
203
  const client = toClient(this);
176
204
  }
177
205
  /** Signal aborted when the transport closes. */
@@ -272,10 +300,11 @@ async function dispatchClient(
272
300
  method: string,
273
301
  params: unknown,
274
302
  notification: boolean,
303
+ signal?: AbortSignal,
275
304
  ): Promise<unknown> {
276
305
  switch (method) {
277
306
  case "session/request_permission":
278
- return client.requestPermission(params as RequestPermissionRequest);
307
+ return client.requestPermission(params as RequestPermissionRequest, { signal });
279
308
  case "session/update":
280
309
  return client.sessionUpdate(params as SessionNotification);
281
310
  case "fs/read_text_file":
@@ -322,6 +351,9 @@ async function dispatchClient(
322
351
  params as CompleteElicitationNotification,
323
352
  );
324
353
  default:
354
+ // The transport already aborted the matching inbound handler;
355
+ // like the agent side, the notification itself carries no work.
356
+ if (method === "$/cancel_request") return;
325
357
  if (notification && client.extNotification)
326
358
  return client.extNotification(method, params as Record<string, unknown>);
327
359
  if (!notification && client.extMethod) return client.extMethod(method, params as Record<string, unknown>);
@@ -105,9 +105,27 @@ function createStandardError(
105
105
  return new RequestError(code, additionalMessage ? `${message}: ${additionalMessage}` : message, data);
106
106
  }
107
107
 
108
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
108
+ type Dispatcher = (
109
+ method: string,
110
+ params: unknown,
111
+ notification: boolean,
112
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
113
+ signal?: AbortSignal,
114
+ ) => MaybePromise<unknown>;
109
115
  type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
110
116
 
117
+ /** Options for {@link RpcConnection}. */
118
+ export interface RpcConnectionOptions {
119
+ /**
120
+ * The dispatcher honors the abort signal it receives for inbound requests.
121
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
122
+ * request `-32800` immediately; otherwise the handler runs to completion and
123
+ * its real result is the response, since answering "cancelled" while the
124
+ * work keeps going would hide its outcome from the peer.
125
+ */
126
+ honorsInboundCancellation?: boolean;
127
+ }
128
+
111
129
  /** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
112
130
  const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
113
131
 
@@ -117,15 +135,18 @@ export class RpcConnection {
117
135
  #pending = new Map<JsonRpcId, Pending>();
118
136
  #inbound = new Set<Promise<void>>();
119
137
  #openInboundIds = new Set<JsonRpcId>();
138
+ /** Abort controller per inbound request, fired by a peer `$/cancel_request` (opt-in). */
139
+ #inboundCancellers: Map<JsonRpcId, AbortController> | undefined;
120
140
  #writable: WritableStream<AnyMessage>;
121
141
  #writeTail: Promise<void> = Promise.resolve();
122
142
  #abort = new AbortController();
123
143
  #closed = Promise.withResolvers<void>();
124
144
  #dispatcher: Dispatcher;
125
145
 
126
- constructor(stream: Stream, dispatcher: Dispatcher) {
146
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions) {
127
147
  this.#writable = stream.writable;
128
148
  this.#dispatcher = dispatcher;
149
+ this.#inboundCancellers = options?.honorsInboundCancellation ? new Map() : undefined;
129
150
  void this.#read(stream.readable);
130
151
  }
131
152
 
@@ -141,6 +162,48 @@ export class RpcConnection {
141
162
  /** Sends a correlated JSON-RPC request. */
142
163
  request<Response>(method: string, params?: unknown): Promise<Response> {
143
164
  if (this.signal.aborted) return Promise.reject(new Error("Connection closed"));
165
+ return this.#beginRequest(method, params).deferred.promise as Promise<Response>;
166
+ }
167
+
168
+ /**
169
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
170
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
171
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
172
+ * response is ignored. Used for agent→client requests a mid-turn event can
173
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
174
+ */
175
+ requestCancellable<Response>(
176
+ method: string,
177
+ params?: unknown,
178
+ ): {
179
+ promise: Promise<Response>;
180
+ cancel(): void;
181
+ } {
182
+ if (this.signal.aborted) {
183
+ return {
184
+ promise: Promise.reject(new Error("Connection closed")),
185
+ cancel: () => {},
186
+ };
187
+ }
188
+ const { id, deferred } = this.#beginRequest(method, params);
189
+ return {
190
+ promise: deferred.promise as Promise<Response>,
191
+ cancel: () => {
192
+ if (!this.#pending.delete(id)) return;
193
+ deferred.reject(RequestError.requestCancelled({ requestId: id, method }));
194
+ void this.notify("$/cancel_request", { requestId: id }).catch(() => undefined);
195
+ },
196
+ };
197
+ }
198
+
199
+ /** Shared request plumbing: allocate an id, register the pending entry, write the frame. */
200
+ #beginRequest(
201
+ method: string,
202
+ params: unknown,
203
+ ): {
204
+ id: number;
205
+ deferred: { promise: Promise<unknown>; resolve: (value?: unknown) => void; reject: (reason?: unknown) => void };
206
+ } {
144
207
  const id = this.#nextId++;
145
208
  const deferred = Promise.withResolvers<unknown>();
146
209
  this.#pending.set(id, deferred);
@@ -148,7 +211,7 @@ export class RpcConnection {
148
211
  this.#pending.delete(id);
149
212
  deferred.reject(error);
150
213
  });
151
- return deferred.promise as Promise<Response>;
214
+ return { id, deferred };
152
215
  }
153
216
 
154
217
  /** Sends a JSON-RPC notification. */
@@ -235,6 +298,18 @@ export class RpcConnection {
235
298
  }
236
299
  if (!("method" in message)) return;
237
300
  if (!("id" in message)) {
301
+ // ACP v1 names the cancelled request `requestId`. A cancelled
302
+ // inbound request must still be answered (spec: result or
303
+ // `-32800`): the handler is aborted and the error response is
304
+ // written here, so a late-settling handler lands in `#respond`'s
305
+ // closed-id no-op instead of answering a request the peer gave
306
+ // up on.
307
+ if (message.method === "$/cancel_request" && this.#inboundCancellers) {
308
+ this.#cancelInbound(
309
+ this.#inboundCancellers,
310
+ (message.params as { requestId?: JsonRpcId } | undefined)?.requestId,
311
+ );
312
+ }
238
313
  try {
239
314
  await this.#dispatcher(message.method, message.params, true);
240
315
  } catch {
@@ -242,8 +317,11 @@ export class RpcConnection {
242
317
  }
243
318
  return;
244
319
  }
320
+ const cancellers = this.#inboundCancellers;
321
+ const canceller = cancellers ? new AbortController() : undefined;
322
+ if (canceller) cancellers?.set(message.id, canceller);
245
323
  try {
246
- const result = await this.#dispatcher(message.method, message.params, false);
324
+ const result = await this.#dispatcher(message.method, message.params, false, canceller?.signal);
247
325
  await this.#respond(message.id, { result: result ?? {} });
248
326
  } catch (error) {
249
327
  const protocolError =
@@ -251,6 +329,25 @@ export class RpcConnection {
251
329
  ? error
252
330
  : RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
253
331
  await this.#respond(message.id, { error: protocolError.toErrorResponse() });
332
+ } finally {
333
+ cancellers?.delete(message.id);
254
334
  }
255
335
  }
336
+
337
+ #cancelInbound(cancellers: Map<JsonRpcId, AbortController>, id: JsonRpcId | undefined): void {
338
+ if (id === undefined) return;
339
+ const canceller = cancellers.get(id);
340
+ if (canceller) {
341
+ cancellers.delete(id);
342
+ canceller.abort(RequestError.requestCancelled({ requestId: id }));
343
+ }
344
+ // Spec: a cancelled request still gets a response. Answer `-32800`
345
+ // here — synchronously with the notification — so the peer is never
346
+ // left waiting on a handler that may never settle. `#respond` closes
347
+ // the id, so a late handler result (a handler that ignored its abort
348
+ // signal) lands in the closed-id no-op instead of answering twice.
349
+ void this.#respond(id, { error: RequestError.requestCancelled({ requestId: id }).toErrorResponse() }).catch(
350
+ () => undefined,
351
+ );
352
+ }
256
353
  }
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const gzipAsync = promisify(zlib.gzip);
6
7
  const gunzipAsync = promisify(zlib.gunzip);
@@ -19,6 +20,11 @@ export async function gzipDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createGunzip({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one gzip stream (tar.gz writing). */
23
29
  export function gzipCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return gzipAsync(bytes);
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const zstdCompressAsync = promisify(zlib.zstdCompress);
6
7
  const zstdDecompressAsync = promisify(zlib.zstdDecompress);
@@ -19,6 +20,11 @@ export async function zstdDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createZstdDecompress({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one zstd frame (tar.zst writing). */
23
29
  export function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return zstdCompressAsync(bytes);
package/src/ar/deb.ts CHANGED
@@ -135,7 +135,7 @@ async function readDebImpl(source: ByteSource, options: FormatReadOptions): Prom
135
135
  }
136
136
  const compressed = await readOuterMember(outerEntry);
137
137
  const tarBytes = await decompressDebTar(compressed, tar.compression, options);
138
- const innerEntries = readTarEntriesFromBuffer(tarBytes, options);
138
+ const innerEntries = await readTarEntriesFromBuffer(tarBytes, options);
139
139
  for (const innerEntry of innerEntries) {
140
140
  upsertArchiveEntry(result, tar.kind === "control" ? prefixControlEntry(innerEntry) : innerEntry);
141
141
  }
package/src/ar/limits.ts CHANGED
@@ -13,6 +13,8 @@ export interface ArchiveLimits {
13
13
  maxEntries: number;
14
14
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
15
15
  maxInMemorySize: number;
16
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
17
+ maxStreamSize: number;
16
18
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
17
19
  maxIndexSize: number;
18
20
  /** Max declared bytes for a single extracted member. */
@@ -26,6 +28,7 @@ export interface ArchiveLimits {
26
28
  export const DEFAULT_ARCHIVE_LIMITS: ArchiveLimits = {
27
29
  maxEntries: 1_000_000,
28
30
  maxInMemorySize: 256 * 1024 * 1024,
31
+ maxStreamSize: 4 * 1024 * 1024 * 1024,
29
32
  maxIndexSize: 64 * 1024 * 1024,
30
33
  maxMemberSize: 64 * 1024 * 1024,
31
34
  maxPathBytes: 4096,
@@ -1,25 +1,27 @@
1
1
  import * as path from "node:path";
2
+ import { formatBytes } from "../format";
2
3
  import { readArj, sniffArj } from "./arj";
3
4
  import { readAsar, sniffAsar } from "./asar";
4
5
  import { readUInt32LE } from "./bytes";
5
6
  import { readCab, sniffCab } from "./cab";
6
7
  import { bzip2Decompress, isBzip2 } from "./codecs/bzip2";
7
- import { gzipDecompress, isGzip } from "./codecs/gzip";
8
+ import { gzipDecompressStream, isGzip } from "./codecs/gzip";
8
9
  import { lzmaAloneDecompress } from "./codecs/lzma";
9
10
  import { isCompressZ, lzwDecompress } from "./codecs/lzw";
10
11
  import { isXz, xzDecompress } from "./codecs/xz";
11
- import { isZstd, zstdDecompress } from "./codecs/zstd";
12
+ import { isZstd, zstdDecompressStream } from "./codecs/zstd";
12
13
  import { readCpio, sniffCpio } from "./cpio";
13
14
  import { readDeb, sniffDeb } from "./deb";
15
+ import { ArchiveError } from "./error";
14
16
  import { readIso, sniffIso } from "./iso";
15
17
  import { assertInMemorySize } from "./limits";
16
18
  import { readLzh, sniffLzh } from "./lzh";
17
19
  import { readRar, sniffRar } from "./rar";
18
20
  import { readRpm, sniffRpm } from "./rpm";
19
21
  import { readSevenZip, sniffSevenZip } from "./sevenzip";
20
- import { readAllBytes } from "./source";
21
- import { readTar, readTarEntriesFromBuffer, sniffTar } from "./tar";
22
- import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, MemberSource } from "./types";
22
+ import { type ByteSource, readAllBytes } from "./source";
23
+ import { readTar, readTarEntriesFromBuffer, readTarEntriesFromStream, sniffTar } from "./tar";
24
+ import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
23
25
  import { readUnixAr, sniffUnixAr } from "./unix-ar";
24
26
  import { readZip, sniffZip } from "./zip";
25
27
 
@@ -100,6 +102,21 @@ class BufferMember implements MemberSource {
100
102
  }
101
103
  }
102
104
 
105
+ /** Index decompressed bytes as tar when they are one, else as a single stem-named member. */
106
+ async function readDecompressed(inner: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
107
+ assertInMemorySize(inner.byteLength, options.limits);
108
+ if (sniffTar(inner)) {
109
+ return readTarEntriesFromBuffer(inner, options);
110
+ }
111
+ const entry: ArchiveIndexEntry = {
112
+ path: stemMemberName(options.archivePath),
113
+ isDirectory: false,
114
+ size: inner.byteLength,
115
+ storage: { type: "member", source: new BufferMember(inner) },
116
+ };
117
+ return [entry];
118
+ }
119
+
103
120
  /**
104
121
  * Reader for one compressed stream: decompress bounded, then index the inner
105
122
  * bytes as tar when they are one, else surface a single stem-named member.
@@ -109,28 +126,50 @@ class BufferMember implements MemberSource {
109
126
  function compressedReader(decompress: (bytes: Uint8Array, maxOutput: number) => Promise<Uint8Array>): FormatReader {
110
127
  return async (source, options) => {
111
128
  assertInMemorySize(source.size, options.limits);
112
- const inner = await decompress(await readAllBytes(source), options.limits.maxInMemorySize);
113
- assertInMemorySize(inner.byteLength, options.limits);
114
- if (sniffTar(inner)) {
115
- return readTarEntriesFromBuffer(inner, options);
129
+ return readDecompressed(await decompress(await readAllBytes(source), options.limits.maxInMemorySize), options);
130
+ };
131
+ }
132
+
133
+ /**
134
+ * {@link compressedReader} for codecs that decompress as a stream. Output that
135
+ * fits `maxInMemorySize` is indexed the same way; a tar that inflates past it
136
+ * is indexed by a streaming header walk instead, its members decompressed
137
+ * again when read, so memory stays bounded by the limits either way. Each pass
138
+ * stops at `maxStreamSize`.
139
+ */
140
+ function streamedReader(
141
+ decompress: (source: ByteSource, maxOutput: number) => AsyncIterable<Uint8Array>,
142
+ ): FormatReader {
143
+ return async (source, options) => {
144
+ const { maxInMemorySize, maxStreamSize } = options.limits;
145
+ const chunks: Uint8Array[] = [];
146
+ let size = 0;
147
+ for await (const chunk of decompress(source, maxStreamSize)) {
148
+ chunks.push(chunk);
149
+ size += chunk.byteLength;
150
+ if (size > maxInMemorySize) break;
151
+ }
152
+ // Copy out what is needed, then drop the chunks so they are not held alongside it.
153
+ const kept = Buffer.concat(chunks, size <= maxInMemorySize ? size : Math.min(size, 512));
154
+ chunks.length = 0;
155
+ if (size <= maxInMemorySize) return readDecompressed(kept, options);
156
+ // Past the limit only a tar has headers to walk; its first 512-byte block tells.
157
+ if (!sniffTar(kept)) {
158
+ throw new ArchiveError(
159
+ `Archive is too large to read in memory (decompressed > ${formatBytes(maxInMemorySize)} limit)`,
160
+ );
116
161
  }
117
- const entry: ArchiveIndexEntry = {
118
- path: stemMemberName(options.archivePath),
119
- isDirectory: false,
120
- size: inner.byteLength,
121
- storage: { type: "member", source: new BufferMember(inner) },
122
- };
123
- return [entry];
162
+ return readTarEntriesFromStream(() => decompress(source, maxStreamSize), options);
124
163
  };
125
164
  }
126
165
 
127
166
  const READERS: Record<ArchiveFormat, FormatReader> = {
128
167
  zip: readZip,
129
168
  tar: readTar,
130
- "tar.gz": compressedReader(gzipDecompress),
169
+ "tar.gz": streamedReader(gzipDecompressStream),
131
170
  "tar.bz2": compressedReader(bzip2Decompress),
132
171
  "tar.xz": compressedReader(xzDecompress),
133
- "tar.zst": compressedReader(zstdDecompress),
172
+ "tar.zst": streamedReader(zstdDecompressStream),
134
173
  "tar.Z": compressedReader(lzwDecompress),
135
174
  asar: readAsar,
136
175
  rar: readRar,
@@ -143,10 +182,10 @@ const READERS: Record<ArchiveFormat, FormatReader> = {
143
182
  deb: readDeb,
144
183
  lzh: readLzh,
145
184
  arj: readArj,
146
- gz: compressedReader(gzipDecompress),
185
+ gz: streamedReader(gzipDecompressStream),
147
186
  bz2: compressedReader(bzip2Decompress),
148
187
  xz: compressedReader(xzDecompress),
149
- zst: compressedReader(zstdDecompress),
188
+ zst: streamedReader(zstdDecompressStream),
150
189
  Z: compressedReader(lzwDecompress),
151
190
  lzma: compressedReader(lzmaAloneDecompress),
152
191
  };
package/src/ar/source.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Readable, type Transform } from "node:stream";
2
+ import { formatBytes } from "../format";
1
3
  import { LRUCache } from "../lru";
2
4
  import { ArchiveError } from "./error";
3
5
 
@@ -62,6 +64,44 @@ export async function readAllBytes(source: ByteSource): Promise<Uint8Array> {
62
64
  return source.read(0, source.size);
63
65
  }
64
66
 
67
+ /** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
68
+ export const STREAM_CHUNK_SIZE = 1024 * 1024;
69
+
70
+ async function* readChunks(source: ByteSource): AsyncGenerator<Uint8Array> {
71
+ for (let start = 0; start < source.size; start += STREAM_CHUNK_SIZE) {
72
+ yield await source.read(start, Math.min(source.size, start + STREAM_CHUNK_SIZE));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
78
+ * the compressed nor the decompressed stream is ever held whole. Output past
79
+ * `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
80
+ */
81
+ export async function* decodeByteSource(
82
+ source: ByteSource,
83
+ decoder: Transform,
84
+ maxOutput: number,
85
+ ): AsyncGenerator<Uint8Array> {
86
+ const input = Readable.from(readChunks(source));
87
+ input.once("error", error => decoder.destroy(error));
88
+ let output = 0;
89
+ try {
90
+ for await (const chunk of input.pipe(decoder)) {
91
+ output += chunk.byteLength;
92
+ if (output > maxOutput) {
93
+ throw new ArchiveError(`Archive decompresses past the ${formatBytes(maxOutput)} stream limit`);
94
+ }
95
+ yield chunk;
96
+ }
97
+ } catch (error) {
98
+ if (error instanceof ArchiveError) throw error;
99
+ throw new ArchiveError(error instanceof Error ? error.message : String(error));
100
+ } finally {
101
+ input.destroy();
102
+ }
103
+ }
104
+
65
105
  /** Options for {@link httpByteSource}. */
66
106
  export interface HttpByteSourceOptions {
67
107
  /** Extra request headers (e.g. authorization). */
package/src/ar/tar.ts CHANGED
@@ -53,13 +53,141 @@ interface PendingTarLink {
53
53
  targetPath: string;
54
54
  }
55
55
 
56
- class TarMemberSource implements MemberSource {
56
+ /** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
57
+ class TarByteStream {
58
+ readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
59
+ #chunk: Uint8Array = new Uint8Array(0);
60
+ #position = 0;
61
+
62
+ constructor(chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) {
63
+ this.#chunks = Symbol.asyncIterator in chunks ? chunks[Symbol.asyncIterator]() : chunks[Symbol.iterator]();
64
+ }
65
+
66
+ /** Bytes consumed so far. */
67
+ get position(): number {
68
+ return this.#position;
69
+ }
70
+
71
+ /** The next `length` bytes; shorter only when the stream ends first. */
72
+ async read(length: number): Promise<Uint8Array> {
73
+ if (this.#chunk.byteLength >= length) {
74
+ const bytes = this.#chunk.subarray(0, length);
75
+ this.#chunk = this.#chunk.subarray(length);
76
+ this.#position += length;
77
+ return bytes;
78
+ }
79
+ const bytes = new Uint8Array(length);
80
+ let filled = 0;
81
+ while (filled < length && (this.#chunk.byteLength > 0 || (await this.#next()))) {
82
+ const take = Math.min(length - filled, this.#chunk.byteLength);
83
+ bytes.set(this.#chunk.subarray(0, take), filled);
84
+ this.#chunk = this.#chunk.subarray(take);
85
+ filled += take;
86
+ }
87
+ this.#position += filled;
88
+ return filled === length ? bytes : bytes.subarray(0, filled);
89
+ }
90
+
91
+ /** Step over `length` bytes; false when the stream ends first. */
92
+ async skip(length: number): Promise<boolean> {
93
+ let remaining = length;
94
+ while (remaining > 0) {
95
+ if (this.#chunk.byteLength === 0 && !(await this.#next())) return false;
96
+ const take = Math.min(remaining, this.#chunk.byteLength);
97
+ this.#chunk = this.#chunk.subarray(take);
98
+ this.#position += take;
99
+ remaining -= take;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /** Stop the underlying stream without draining it. */
105
+ async close(): Promise<void> {
106
+ await this.#chunks.return?.();
107
+ }
108
+
109
+ async #next(): Promise<boolean> {
110
+ const result = await this.#chunks.next();
111
+ if (result.done) return false;
112
+ this.#chunk = result.value;
113
+ return true;
114
+ }
115
+ }
116
+
117
+ /** Where an indexed tar's member bytes come from, by offset into the tar stream. */
118
+ interface TarPayload {
119
+ read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
120
+ }
121
+
122
+ class TarBufferPayload implements TarPayload {
57
123
  readonly #buffer: Uint8Array;
124
+
125
+ constructor(buffer: Uint8Array) {
126
+ this.#buffer = buffer;
127
+ }
128
+
129
+ async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
130
+ if (size > this.#buffer.byteLength - offset) {
131
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
132
+ }
133
+ const bytes = this.#buffer.subarray(offset, offset + size);
134
+ if (bytes.byteLength !== size) {
135
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
136
+ }
137
+ return bytes;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Member bytes of a tar too large to hold decompressed: each read decompresses
143
+ * the stream again up to the member. Reads are serialized, and a read ahead of
144
+ * the previous one continues that pass, so reading every member in archive
145
+ * order decompresses the stream once.
146
+ */
147
+ class TarStreamPayload implements TarPayload {
148
+ readonly #open: () => AsyncIterable<Uint8Array>;
149
+ #stream: TarByteStream | undefined;
150
+ #pending: Promise<unknown> = Promise.resolve();
151
+
152
+ constructor(open: () => AsyncIterable<Uint8Array>) {
153
+ this.#open = open;
154
+ }
155
+
156
+ read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
157
+ const bytes = this.#pending.then(() => this.#readAt(offset, size, memberPath));
158
+ this.#pending = bytes.catch(() => undefined);
159
+ return bytes;
160
+ }
161
+
162
+ async #readAt(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
163
+ let stream = this.#stream;
164
+ this.#stream = undefined;
165
+ if (!stream || stream.position > offset) {
166
+ await stream?.close();
167
+ stream = new TarByteStream(this.#open());
168
+ }
169
+ try {
170
+ const bytes = (await stream.skip(offset - stream.position)) ? await stream.read(size) : undefined;
171
+ if (bytes?.byteLength !== size) {
172
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
173
+ }
174
+ this.#stream = stream;
175
+ // A view into a decoder chunk would keep the whole chunk alive with the member.
176
+ return bytes.byteLength === bytes.buffer.byteLength ? bytes : new Uint8Array(bytes);
177
+ } catch (error) {
178
+ await stream.close();
179
+ throw error;
180
+ }
181
+ }
182
+ }
183
+
184
+ class TarMemberSource implements MemberSource {
185
+ readonly #payload: TarPayload;
58
186
  readonly #dataOffset: number;
59
187
  readonly #sparse: boolean;
60
188
 
61
- constructor(buffer: Uint8Array, dataOffset: number, sparse: boolean) {
62
- this.#buffer = buffer;
189
+ constructor(payload: TarPayload, dataOffset: number, sparse: boolean) {
190
+ this.#payload = payload;
63
191
  this.#dataOffset = dataOffset;
64
192
  this.#sparse = sparse;
65
193
  }
@@ -70,14 +198,7 @@ class TarMemberSource implements MemberSource {
70
198
  `Archive member '${formatArchivePathForError(memberPath)}' is a sparse file and cannot be read`,
71
199
  );
72
200
  }
73
- if (size > this.#buffer.byteLength - this.#dataOffset) {
74
- throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
75
- }
76
- const bytes = this.#buffer.subarray(this.#dataOffset, this.#dataOffset + size);
77
- if (bytes.byteLength !== size) {
78
- throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
79
- }
80
- return bytes;
201
+ return this.#payload.read(this.#dataOffset, size, memberPath);
81
202
  }
82
203
  }
83
204
 
@@ -172,7 +293,6 @@ function checksumMatches(buffer: Uint8Array, offset: number): boolean {
172
293
  }
173
294
 
174
295
  function parsePaxRecords(data: Uint8Array, limits: ArchiveLimits): Map<string, string> {
175
- assertIndexSize(data.byteLength, limits, "tar PAX metadata");
176
296
  const attrs = new Map<string, string>();
177
297
  let pos = 0;
178
298
  while (pos < data.byteLength) {
@@ -295,7 +415,6 @@ function applyOldGnuNameRecords(
295
415
  pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
296
416
  limits: ArchiveLimits,
297
417
  ): void {
298
- assertIndexSize(data.byteLength, limits, "old-GNU name metadata");
299
418
  const terminator = data.indexOf(0);
300
419
  const end = terminator === -1 ? data.byteLength : terminator;
301
420
  let start = 0;
@@ -412,10 +531,31 @@ function resolvePendingLinks(
412
531
  if (unresolved.size > 0) throw new ArchiveError("Archive contains cyclic or unsupported links");
413
532
  }
414
533
 
415
- /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
416
- export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[] {
534
+ /** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
535
+ async function readTarMetadata(
536
+ stream: TarByteStream,
537
+ size: number,
538
+ limits: ArchiveLimits,
539
+ what: string,
540
+ ): Promise<Uint8Array> {
541
+ assertIndexSize(size, limits, what);
542
+ const data = await stream.read(size);
543
+ if (data.byteLength !== size || !(await stream.skip(paddedSize(size) - size))) {
544
+ throw new ArchiveError("Archive member data is truncated");
545
+ }
546
+ return data;
547
+ }
548
+
549
+ /**
550
+ * Walk tar headers with bounded GNU, ustar, and PAX handling. Member data is
551
+ * stepped over, never held: entries point into `payload` by stream offset.
552
+ */
553
+ async function readTarEntries(
554
+ stream: TarByteStream,
555
+ payload: TarPayload,
556
+ options: FormatReadOptions,
557
+ ): Promise<ArchiveIndexEntry[]> {
417
558
  const { limits } = options;
418
- assertInMemorySize(buffer.byteLength, limits);
419
559
  const entries = new Map<string, ArchiveIndexEntry>();
420
560
  const pendingLinks = new Map<ArchiveIndexEntry, PendingTarLink>();
421
561
  const addEntry = (entry: ArchiveIndexEntry, pendingLink?: PendingTarLink): void => {
@@ -426,61 +566,58 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
426
566
  if (pendingLink) pendingLinks.set(indexed, pendingLink);
427
567
  assertEntryCount(entries.size, limits);
428
568
  };
429
- let offset = 0;
430
569
  let longName: string | undefined;
431
570
  let longLink: string | undefined;
432
571
  let localPax: Map<string, string> | undefined;
433
572
  const globalPax = new Map<string, string>();
434
573
  let sawTerminator = false;
435
574
 
436
- while (offset + BLOCK_SIZE <= buffer.byteLength) {
437
- if (isZeroBlock(buffer, offset)) {
575
+ for (
576
+ let header = await stream.read(BLOCK_SIZE);
577
+ header.byteLength === BLOCK_SIZE;
578
+ header = await stream.read(BLOCK_SIZE)
579
+ ) {
580
+ if (isZeroBlock(header, 0)) {
438
581
  sawTerminator = true;
439
582
  break;
440
583
  }
441
- if (!checksumMatches(buffer, offset)) throw new ArchiveError("Invalid or corrupt tar archive header");
442
- const headerOffset = offset;
443
- const typeFlag = String.fromCharCode(buffer[headerOffset + TYPEFLAG_OFFSET] || 0x30);
444
- let size = readTarSize(buffer, headerOffset + SIZE_OFFSET);
445
- let name = readTarString(buffer, headerOffset + NAME_OFFSET, NAME_LENGTH);
446
- if (isUstarHeader(buffer, headerOffset)) {
447
- const prefix = readTarString(buffer, headerOffset + PREFIX_OFFSET, PREFIX_LENGTH);
584
+ if (!checksumMatches(header, 0)) throw new ArchiveError("Invalid or corrupt tar archive header");
585
+ const typeFlag = String.fromCharCode(header[TYPEFLAG_OFFSET] || 0x30);
586
+ let size = readTarSize(header, SIZE_OFFSET);
587
+ let name = readTarString(header, NAME_OFFSET, NAME_LENGTH);
588
+ if (isUstarHeader(header, 0)) {
589
+ const prefix = readTarString(header, PREFIX_OFFSET, PREFIX_LENGTH);
448
590
  if (prefix) name = `${prefix}/${name}`;
449
591
  }
450
- let linkName = readTarString(buffer, headerOffset + LINKNAME_OFFSET, LINKNAME_LENGTH);
451
- const mtime = readTarNumeric(buffer, headerOffset + MTIME_OFFSET, MTIME_LENGTH);
452
- const rawMode = readTarNumeric(buffer, headerOffset + MODE_OFFSET, MODE_LENGTH);
592
+ let linkName = readTarString(header, LINKNAME_OFFSET, LINKNAME_LENGTH);
593
+ const mtime = readTarNumeric(header, MTIME_OFFSET, MTIME_LENGTH);
594
+ const rawMode = readTarNumeric(header, MODE_OFFSET, MODE_LENGTH);
453
595
  const mode = Number.isSafeInteger(rawMode) && rawMode >= 0 ? rawMode : undefined;
454
- offset += BLOCK_SIZE;
455
- const dataBlocks = paddedSize(size);
456
- if (dataBlocks > buffer.byteLength - offset) throw new ArchiveError("Archive member data is truncated");
457
- const data = buffer.subarray(offset, offset + size);
458
596
 
459
597
  if (typeFlag === "L") {
460
- assertIndexSize(data.byteLength, limits, "GNU long-name metadata");
598
+ const data = await readTarMetadata(stream, size, limits, "GNU long-name metadata");
461
599
  longName = readMetadataPath(data, "GNU long path", limits);
462
- offset += dataBlocks;
463
600
  continue;
464
601
  }
465
602
  if (typeFlag === "K") {
466
- assertIndexSize(data.byteLength, limits, "GNU long-link metadata");
603
+ const data = await readTarMetadata(stream, size, limits, "GNU long-link metadata");
467
604
  longLink = readMetadataPath(data, "GNU long link target", limits);
468
- offset += dataBlocks;
469
605
  continue;
470
606
  }
471
607
  if (typeFlag === "N") {
608
+ const data = await readTarMetadata(stream, size, limits, "old-GNU name metadata");
472
609
  applyOldGnuNameRecords(data, entries, pendingLinks, limits);
473
- offset += dataBlocks;
474
610
  continue;
475
611
  }
476
612
  if (typeFlag === "x" || typeFlag === "X") {
477
- localPax = parsePaxRecords(data, limits);
478
- offset += dataBlocks;
613
+ localPax = parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits);
479
614
  continue;
480
615
  }
481
616
  if (typeFlag === "g") {
482
- applyGlobalPax(globalPax, parsePaxRecords(data, limits));
483
- offset += dataBlocks;
617
+ applyGlobalPax(
618
+ globalPax,
619
+ parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits),
620
+ );
484
621
  continue;
485
622
  }
486
623
  if (typeFlag === "M") throw new ArchiveError("GNU multi-volume tar members are not supported");
@@ -499,22 +636,18 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
499
636
  const paxSparseRealSize = paxAttribute(globalPax, localPax, "GNU.sparse.realsize");
500
637
  if (paxSparseRealSize !== undefined) displaySize = parsePaxSize(paxSparseRealSize, "sparse real size");
501
638
  const sparse = typeFlag === "S" || paxDeclaresSparse(globalPax, localPax);
502
- if (typeFlag === "S" && buffer[headerOffset + GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
639
+ if (typeFlag === "S" && header[GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
503
640
  let extended = true;
504
641
  while (extended) {
505
- if (offset + BLOCK_SIZE > buffer.byteLength) {
506
- throw new ArchiveError("Archive sparse metadata is truncated");
507
- }
508
- extended = buffer[offset + GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
509
- offset += BLOCK_SIZE;
642
+ const block = await stream.read(BLOCK_SIZE);
643
+ if (block.byteLength < BLOCK_SIZE) throw new ArchiveError("Archive sparse metadata is truncated");
644
+ extended = block[GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
510
645
  }
511
646
  }
512
- const dataOffset = offset;
513
- const memberDataBlocks = paddedSize(size);
514
- if (memberDataBlocks > buffer.byteLength - dataOffset) {
647
+ const dataOffset = stream.position;
648
+ if (!(await stream.skip(paddedSize(size)))) {
515
649
  throw new ArchiveError(`Archive member '${formatArchivePathForError(name)}' is truncated`);
516
650
  }
517
- offset += memberDataBlocks;
518
651
  longName = undefined;
519
652
  longLink = undefined;
520
653
  localPax = undefined;
@@ -567,7 +700,7 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
567
700
  size: displaySize,
568
701
  mtimeMs,
569
702
  mode,
570
- storage: { type: "member", source: new TarMemberSource(buffer, dataOffset, sparse) },
703
+ storage: { type: "member", source: new TarMemberSource(payload, dataOffset, sparse) },
571
704
  });
572
705
  }
573
706
  if (!sawTerminator) throw new ArchiveError("Not a valid tar archive: missing terminating zero block");
@@ -575,6 +708,35 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
575
708
  return [...entries.values()];
576
709
  }
577
710
 
711
+ /** Index an already-decompressed tar buffer; members are views into it. */
712
+ export async function readTarEntriesFromBuffer(
713
+ buffer: Uint8Array,
714
+ options: FormatReadOptions,
715
+ ): Promise<ArchiveIndexEntry[]> {
716
+ assertInMemorySize(buffer.byteLength, options.limits);
717
+ return readTarEntries(new TarByteStream([buffer]), new TarBufferPayload(buffer), options);
718
+ }
719
+
720
+ /**
721
+ * Index a decompressed tar stream that is too large to hold in memory: one
722
+ * pass over `open()` walks the headers, and member reads open it again.
723
+ */
724
+ export async function readTarEntriesFromStream(
725
+ open: () => AsyncIterable<Uint8Array>,
726
+ options: FormatReadOptions,
727
+ ): Promise<ArchiveIndexEntry[]> {
728
+ const stream = new TarByteStream(open());
729
+ try {
730
+ const entries = await readTarEntries(stream, new TarStreamPayload(open), options);
731
+ // Decompress past the terminator to the end, so the codec still checks the
732
+ // stream's trailer (gzip CRC and length) as a whole-stream decompression does.
733
+ await stream.skip(Number.POSITIVE_INFINITY);
734
+ return entries;
735
+ } finally {
736
+ await stream.close();
737
+ }
738
+ }
739
+
578
740
  /** Read and index a tar source after one bounded whole-stream read. */
579
741
  export const readTar: FormatReader = async (source, options) => {
580
742
  assertInMemorySize(source.size, options.limits);
package/src/dirs.ts CHANGED
@@ -18,6 +18,7 @@ import * as path from "node:path";
18
18
  import { expandWindowsLongPath } from "@linxiraos/pi-natives/path";
19
19
  import { engines, version } from "../package.json" with { type: "json" };
20
20
  import { isEnoent, isEnotdir } from "./fs-error";
21
+ import { isBunTestRuntime } from "./test-runtime";
21
22
 
22
23
  /** App name (e.g. "zeta") — product-level identity: config-dir root, log file
23
24
  * names, provider attribution headers, splash wordmark. NOT the CLI command. */
@@ -166,12 +167,33 @@ function standardizeProjectPath(p: string): string {
166
167
  return process.platform === "win32" ? expandWindowsLongPath(p) : standardizeMacOSPath(p);
167
168
  }
168
169
 
169
- export function resolveEquivalentPath(inputPath: string): string {
170
+ export interface EquivalentPathOptions {
171
+ /**
172
+ * Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
173
+ * missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
174
+ */
175
+ throughMissing?: boolean;
176
+ }
177
+
178
+ export function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string {
170
179
  const resolvedPath = path.resolve(inputPath);
171
180
  try {
172
181
  return fs.realpathSync(resolvedPath);
173
182
  } catch {
174
- return resolvedPath;
183
+ if (!options?.throughMissing) return resolvedPath;
184
+ }
185
+ const missing: string[] = [];
186
+ let current = resolvedPath;
187
+ for (;;) {
188
+ const parent = path.dirname(current);
189
+ if (parent === current) return resolvedPath;
190
+ missing.unshift(path.basename(current));
191
+ current = parent;
192
+ try {
193
+ return path.join(fs.realpathSync(current), ...missing);
194
+ } catch {
195
+ // Keep walking: a higher ancestor may exist.
196
+ }
175
197
  }
176
198
  }
177
199
 
@@ -654,6 +676,10 @@ export function getReportsDir(): string {
654
676
 
655
677
  /** Get the logs directory (~/.zeta/logs). */
656
678
  export function getLogsDir(): string {
679
+ if (isBunTestRuntime()) {
680
+ const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username;
681
+ return path.join(os.tmpdir(), `omp-test-logs-${uid}`);
682
+ }
657
683
  return dirs.rootSubdir("logs", "state");
658
684
  }
659
685
 
package/src/env.ts CHANGED
@@ -3,7 +3,9 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { parseEnv } from "node:util";
5
5
  import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
6
+ import { isBunTestRuntime } from "./test-runtime";
6
7
 
8
+ export * from "./test-runtime";
7
9
  export * from "./worker-host";
8
10
 
9
11
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -370,15 +372,6 @@ export function $envpos(name: string, defaultValue: number): number {
370
372
  return parsed;
371
373
  }
372
374
 
373
- const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
374
-
375
- /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
376
- export function isBunTestRuntime(): boolean {
377
- if (Bun.env.PI_TEST_RUNTIME === "1") return true;
378
- const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
379
- return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
380
- }
381
-
382
375
  let terminalHeadless = isBunTestRuntime();
383
376
 
384
377
  /**
@@ -2,8 +2,13 @@ import { YAML } from "bun";
2
2
  import { truncate } from "./format";
3
3
  import * as logger from "./logger";
4
4
 
5
- function stripHtmlComments(content: string): string {
6
- return content.replace(/<!--[\s\S]*?-->/g, "");
5
+ function stripLeadingHtmlComments(content: string): string {
6
+ const leading = /^(?:(?:\s*<!--[\s\S]*?-->)+)/.exec(content);
7
+ if (!leading) return content;
8
+ const rest = content.slice(leading[0].length);
9
+ // Only repair a comment that precedes frontmatter; leave a plain document
10
+ // (and every body) byte-identical so HTML comments inside it survive.
11
+ return /^\s*---/.test(rest) ? rest.replace(/^\s+/, "") : content;
7
12
  }
8
13
 
9
14
  /** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
@@ -142,7 +147,8 @@ export function parseFrontmatter(
142
147
  const frontmatter: Record<string, unknown> = { ...fallback };
143
148
 
144
149
  const newlineNormalized = normalize ? content.replace(/\r\n?/g, "\n") : content;
145
- const normalized = normalize && repair ? stripHtmlComments(newlineNormalized) : newlineNormalized;
150
+ const repairHtmlComments = normalize && repair;
151
+ const normalized = repairHtmlComments ? stripLeadingHtmlComments(newlineNormalized) : newlineNormalized;
146
152
  if (!normalized.startsWith("---")) {
147
153
  return { frontmatter, body: normalized };
148
154
  }
@@ -152,7 +158,8 @@ export function parseFrontmatter(
152
158
  return { frontmatter, body: normalized };
153
159
  }
154
160
 
155
- const metadata = normalized.slice(4, endIndex);
161
+ const rawMetadata = normalized.slice(4, endIndex);
162
+ const metadata = repairHtmlComments ? rawMetadata.replace(/<!--[\s\S]*?-->/g, "") : rawMetadata;
156
163
  const body = normalized.slice(endIndex + 4).trim();
157
164
 
158
165
  try {
package/src/fs-open.ts CHANGED
@@ -15,7 +15,8 @@ const O_CLOEXEC = process.platform === "linux" ? 0o2000000 : process.platform ==
15
15
  * `fs.openSync` with close-on-exec, matching Node's default.
16
16
  *
17
17
  * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
18
- * dies at `exec`. Bun's `fs.open*` does not, and descriptors it returns survive
18
+ * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
19
+ * `bun@>=1.4` pin), and descriptors it returns survive
19
20
  * into any child spawned through a path that does not close strays itself — the
20
21
  * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
21
22
  * this for every descriptor kept open past such a spawn (session transcripts,
@@ -0,0 +1,8 @@
1
+ const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
2
+
3
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
4
+ export function isBunTestRuntime(): boolean {
5
+ if (Bun.env.PI_TEST_RUNTIME === "1") return true;
6
+ const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
7
+ return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
8
+ }