@mono-agent/agent-runtime 0.18.0 → 0.18.2

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.
@@ -3,11 +3,49 @@
3
3
  import { parseHTML } from "linkedom";
4
4
  import { passthroughSandbox } from "../sandbox-seam.js";
5
5
  import { readToolRuntime } from "./shared/runtime-context.js";
6
+ import { createCountingSemaphore } from "./shared/semaphore.js";
6
7
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
7
8
 
8
9
  const SEARCH_TIMEOUT_MS = 15_000;
9
10
  const SEARCH_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
10
11
  const RRF_K = 60;
12
+
13
+ // Keyless engines rate-limit by source IP, and one agent can hammer them from
14
+ // many directions at once: a single WebSearch fans out to up to four queries,
15
+ // and every subagent runs its own web controller, so nothing below this module
16
+ // sees the aggregate. Measured against html.duckduckgo.com: ~8 requests in ~4s
17
+ // served normally, ~12 in ~5s tripped an `HTTP 202` anomaly page that persisted
18
+ // for over two minutes. These bounds therefore live at MODULE scope so they
19
+ // apply process-wide (one process is one agent instance), not per run.
20
+ const KEYLESS_DEFAULT_THROTTLE = {
21
+ // Simultaneous in-flight keyless requests across the whole process.
22
+ maxConcurrency: 3,
23
+ // Minimum gap between two requests to the SAME keyless backend, i.e. ~0.67/s
24
+ // against roughly 2.4/s measured to trip a ban. The asymmetry is deliberate:
25
+ // being too slow costs a few seconds on a multi-query search, while being too
26
+ // fast costs a five-minute outage that escalates from a 202 challenge to an
27
+ // outright 403. Only the fan-out pays it — a single-query search never waits.
28
+ minSpacingMs: 1_500,
29
+ // How long a backend stays skipped after it signals rate limiting. Observed
30
+ // blocks outlasted several minutes, so this is deliberately longer.
31
+ cooldownMs: 5 * 60_000,
32
+ };
33
+ // Ordered keyless fallback chain. DuckDuckGo first: it yields cleaner titles and
34
+ // snippets, but it is also the one that bans, which is precisely why Startpage
35
+ // behind it has to actually work.
36
+ const KEYLESS_BACKENDS = ["duckduckgo", "startpage"];
37
+ // Markers that identify an interstitial/bot-gate body served with a 2xx status.
38
+ const CHALLENGE_BODY_RE = /anomaly|unusual traffic|captcha|are you a robot|challenge-(?:platform|form)/iu;
39
+ // Statuses these engines use to say "you are sending too much", all of which
40
+ // must put the backend into cooldown rather than be retried next search.
41
+ const RATE_LIMIT_STATUSES = new Set([202, 403, 429]);
42
+
43
+ let keylessThrottle = { ...KEYLESS_DEFAULT_THROTTLE };
44
+ let keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
45
+ /** @type {Map<string, number>} Backend -> epoch ms until which it is skipped. */
46
+ const backendCooldownUntil = new Map();
47
+ /** @type {Map<string, number>} Backend -> epoch ms its next request may start. */
48
+ const backendNextAvailableAt = new Map();
11
49
  const TRACKING_PARAMETERS = new Set([
12
50
  "dclid",
13
51
  "fbclid",
@@ -96,11 +134,14 @@ export async function performWebSearch(
96
134
  );
97
135
  };
98
136
  const recordResult = (result) => {
137
+ // Chain failures are reported even when a later backend rescued the query,
138
+ // so a silent degradation to the fallback is still visible in the outcome.
139
+ if (result.failures?.length) providerFailures.push(...result.failures);
99
140
  if (result.ok) {
100
141
  anyProviderSucceeded = true;
101
142
  providersUsed.add(result.backend);
102
143
  rankedLists.push(filterByDomains(result.results, includeDomains, excludeDomains));
103
- } else {
144
+ } else if (!result.failures?.length) {
104
145
  providerFailures.push(result);
105
146
  }
106
147
  };
@@ -124,16 +165,22 @@ export async function performWebSearch(
124
165
  }
125
166
 
126
167
  if (!anyProviderSucceeded) {
127
- const reason = providerFailures.map((entry) => entry.message).filter(Boolean).join("; ")
168
+ // Four query variants against two backends produce the same handful of
169
+ // messages over and over; dedupe so the reason stays readable.
170
+ const reason = [...new Set(providerFailures.map((entry) => entry.message).filter(Boolean))].join("; ")
128
171
  || "No search backend was available.";
129
172
  const networkDenied = providerFailures.length > 0
130
173
  && providerFailures.every((entry) => entry.message === "Network access denied by sandbox policy.");
174
+ const throttled = providerFailures.some((entry) => entry.rateLimited || entry.cooldown);
131
175
  return failure(networkDenied
132
176
  ? "Error: Network access denied by sandbox policy."
133
- : `Error: WebSearch failed: ${reason}`, networkDenied ? "network_denied" : "backend_unavailable", startedAt, {
177
+ : `Error: WebSearch failed: ${reason}`,
178
+ networkDenied ? "network_denied" : (throttled ? "rate_limited" : "backend_unavailable"), startedAt, {
134
179
  attempts,
135
180
  backend: config.backend,
136
181
  retryable: providerFailures.some((entry) => entry.retryable),
182
+ rateLimited: throttled,
183
+ cooldownBackends: [...backendCooldownUntil.keys()],
137
184
  });
138
185
  }
139
186
 
@@ -163,48 +210,137 @@ export async function performWebSearch(
163
210
  truncated: false,
164
211
  resultCount: merged.length,
165
212
  providerFailureCount: providerFailures.length,
213
+ rateLimited: providerFailures.some((entry) => entry.rateLimited || entry.cooldown),
214
+ cooldownBackends: [...backendCooldownUntil.keys()],
166
215
  },
167
216
  error: false,
168
217
  };
169
218
  }
170
219
 
220
+ const KEYLESS_RUNNERS = {
221
+ duckduckgo: searchDuckDuckGo,
222
+ startpage: searchStartpage,
223
+ };
224
+
171
225
  async function searchOneQuery(query, options) {
172
226
  const { config } = options;
227
+ // Every failure along the chain is kept and carried out on the winning result
228
+ // too. Reporting only the last one is what made a DuckDuckGo ban surface as
229
+ // "startpage request failed: fetch failed" and sent diagnosis the wrong way.
173
230
  const failures = [];
174
- if (options.signal?.aborted) return abortedSearch(config.backend);
231
+ if (options.signal?.aborted) return abortedSearch(config.backend, failures);
175
232
  if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
176
233
  const result = await searchSearxng(query, options);
177
- if (result.ok || config.backend === "searxng") return result;
234
+ if (result.ok || config.backend === "searxng") return { ...result, failures };
178
235
  failures.push(result);
179
- if (options.signal?.aborted) return abortedSearch(result.backend);
236
+ if (options.signal?.aborted) return abortedSearch(result.backend, failures);
180
237
  }
181
238
  if (config.backend === "keyless" || config.backend === "auto") {
182
- const duck = await searchDuckDuckGo(query, options);
183
- if (duck.ok && duck.results.length > 0) return duck;
184
- if (!duck.ok) failures.push(duck);
185
- if (options.signal?.aborted) return abortedSearch(duck.backend);
186
- const startpage = await searchStartpage(query, options);
187
- if (startpage.ok) return startpage;
188
- failures.push(startpage);
189
- if (duck.ok) return duck;
239
+ // A genuinely empty 200 is a real answer, not a transport failure — but it
240
+ // is only worth returning once every backend has had its turn.
241
+ let emptySuccess = null;
242
+ for (const backend of KEYLESS_BACKENDS) {
243
+ if (options.signal?.aborted) return abortedSearch(backend, failures);
244
+ if (backendInCooldown(backend)) {
245
+ failures.push({
246
+ ok: false,
247
+ backend,
248
+ message: `${backend} skipped: cooling down after rate limiting.`,
249
+ retryable: true,
250
+ cooldown: true,
251
+ });
252
+ continue;
253
+ }
254
+ const result = await KEYLESS_RUNNERS[backend](query, options);
255
+ if (result.ok) {
256
+ if (result.results.length > 0) return { ...result, failures };
257
+ emptySuccess = result;
258
+ continue;
259
+ }
260
+ // The cooldown is already open — rateLimited() sets it at detection.
261
+ failures.push(result);
262
+ }
263
+ if (emptySuccess) return { ...emptySuccess, failures };
190
264
  }
191
- return failures[failures.length - 1] || {
192
- ok: false,
193
- backend: config.backend,
194
- message: "No configured search backend.",
195
- retryable: false,
265
+ return {
266
+ ...(failures[failures.length - 1] || {
267
+ ok: false,
268
+ backend: config.backend,
269
+ message: "No configured search backend.",
270
+ retryable: false,
271
+ }),
272
+ failures,
196
273
  };
197
274
  }
198
275
 
199
- function abortedSearch(backend) {
276
+ function abortedSearch(backend, failures = []) {
200
277
  return {
201
278
  ok: false,
202
279
  backend,
203
280
  message: "WebSearch was aborted.",
204
281
  retryable: false,
282
+ failures,
205
283
  };
206
284
  }
207
285
 
286
+ function backendInCooldown(backend) {
287
+ const until = backendCooldownUntil.get(backend);
288
+ if (until === undefined) return false;
289
+ if (Date.now() >= until) {
290
+ backendCooldownUntil.delete(backend);
291
+ return false;
292
+ }
293
+ return true;
294
+ }
295
+
296
+ /**
297
+ * Atomically claims this backend's next send slot and reports how long the
298
+ * caller must wait for it. Synchronous on purpose: concurrent callers each
299
+ * reserve a distinct slot instead of all reading the same "last sent at".
300
+ *
301
+ * @returns {number} Milliseconds to wait before sending.
302
+ */
303
+ function reserveKeylessSlot(backend) {
304
+ const now = Date.now();
305
+ const earliest = Math.max(now, backendNextAvailableAt.get(backend) ?? 0);
306
+ backendNextAvailableAt.set(backend, earliest + keylessThrottle.minSpacingMs);
307
+ return earliest - now;
308
+ }
309
+
310
+ // Deliberately NOT unref'd: this delay is part of an in-flight search the
311
+ // caller is awaiting. An unref'd timer lets the event loop drain while the
312
+ // search is still pending, and a one-shot CLI turn then exits mid-query.
313
+ // Cancellation is the signal's job, not the timer's.
314
+ function sleep(ms, signal) {
315
+ if (!ms) return Promise.resolve();
316
+ return new Promise((resolvePromise, rejectPromise) => {
317
+ const onAbort = () => {
318
+ clearTimeout(timer);
319
+ // Named so fetchFailure classifies it alongside every other abort.
320
+ rejectPromise(Object.assign(new Error("WebSearch was aborted."), { name: "AbortError" }));
321
+ };
322
+ const timer = setTimeout(() => {
323
+ signal?.removeEventListener?.("abort", onAbort);
324
+ resolvePromise();
325
+ }, ms);
326
+ if (signal?.aborted) onAbort();
327
+ else signal?.addEventListener?.("abort", onAbort, { once: true });
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Test hook: restores the shipped throttle values and clears cooldown/spacing
333
+ * state. Module-scoped state would otherwise leak between test cases.
334
+ *
335
+ * @param {{maxConcurrency?: number, minSpacingMs?: number, cooldownMs?: number}} [overrides]
336
+ */
337
+ export function __resetWebSearchThrottleForTests(overrides = {}) {
338
+ keylessThrottle = { ...KEYLESS_DEFAULT_THROTTLE, ...overrides };
339
+ keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
340
+ backendCooldownUntil.clear();
341
+ backendNextAvailableAt.clear();
342
+ }
343
+
208
344
  async function searchSearxng(query, options) {
209
345
  const endpoint = options.config.endpoint;
210
346
  if (!endpoint) {
@@ -255,66 +391,143 @@ async function searchSearxng(query, options) {
255
391
  }
256
392
  }
257
393
 
258
- async function searchDuckDuckGo(query, options) {
259
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
260
- if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
261
- return { ok: false, backend: "duckduckgo", message: "Network access denied by sandbox policy.", retryable: false };
394
+ /**
395
+ * Shared transport for the keyless HTML engines. Both are scraped the same way
396
+ * and both bot-gate the same way, so request shaping, throttling, challenge
397
+ * detection, and error classification live here exactly once.
398
+ *
399
+ * @param {{backend: string, label: string, url: string, init?: RequestInit, parse: (html: string) => any[]}} spec
400
+ */
401
+ async function keylessHtmlSearch(spec, options) {
402
+ if (!options.sandbox.networkAllowsUrl(options.policy, spec.url)) {
403
+ return { ok: false, backend: spec.backend, message: "Network access denied by sandbox policy.", retryable: false };
262
404
  }
405
+ let release;
263
406
  try {
264
- const response = await options.fetchImpl(url, {
265
- headers: {
266
- Accept: "text/html,application/xhtml+xml",
267
- "Accept-Language": options.language || "en-US,en;q=0.8",
268
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
269
- },
270
- signal: requestSignal(options.signal),
271
- redirect: "error",
272
- });
273
- const html = await readLimitedText(response);
274
- if (!response.ok) {
407
+ release = await keylessSemaphore.acquire(options.signal);
408
+ } catch {
409
+ // Queued behind the concurrency bound when the turn was cancelled.
410
+ return { ok: false, backend: spec.backend, message: "WebSearch was aborted.", retryable: false };
411
+ }
412
+ try {
413
+ const waitMs = reserveKeylessSlot(spec.backend);
414
+ if (waitMs > 0) await sleep(waitMs, options.signal);
415
+ // Query variants all clear the cooldown check together and then queue here,
416
+ // so by the time this one is admitted a sibling may already have been
417
+ // blocked. Without this second look the very first block still costs a full
418
+ // round of requests against a backend we know is refusing them.
419
+ if (backendInCooldown(spec.backend)) {
275
420
  return {
276
421
  ok: false,
277
- backend: "duckduckgo",
278
- message: `DuckDuckGo HTTP ${response.status}`,
279
- retryable: response.status === 429 || response.status >= 500,
422
+ backend: spec.backend,
423
+ message: `${spec.backend} skipped: cooling down after rate limiting.`,
424
+ retryable: true,
425
+ cooldown: true,
280
426
  };
281
427
  }
282
- return { ok: true, backend: "duckduckgo", results: parseDuckDuckGoResults(html) };
283
- } catch (error) {
284
- return fetchFailure("duckduckgo", error);
285
- }
286
- }
287
-
288
- async function searchStartpage(query, options) {
289
- const url = `https://www.startpage.com/sp/search?query=${encodeURIComponent(query)}`;
290
- if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
291
- return { ok: false, backend: "startpage", message: "Network access denied by sandbox policy.", retryable: false };
292
- }
293
- try {
294
- const response = await options.fetchImpl(url, {
428
+ const response = await options.fetchImpl(spec.url, {
429
+ // "manual", not "error": these engines answer a throttled query with a
430
+ // redirect to a captcha page, and "error" collapses that into an opaque
431
+ // `TypeError: fetch failed` with no way to tell it from a real outage.
432
+ // The redirect is still never followed, so the open-redirect guard holds.
433
+ redirect: "manual",
434
+ ...(spec.init || {}),
435
+ // Headers are merged last on purpose: spreading `spec.init` afterwards
436
+ // would replace the whole headers object with the backend's few extra
437
+ // entries, and a Startpage POST without a User-Agent gets bot-gated.
295
438
  headers: {
296
439
  Accept: "text/html,application/xhtml+xml",
297
440
  "Accept-Language": options.language || "en-US,en;q=0.8",
298
441
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
442
+ ...(spec.init?.headers || {}),
299
443
  },
300
444
  signal: requestSignal(options.signal),
301
- redirect: "error",
302
445
  });
303
446
  const html = await readLimitedText(response);
447
+ // Startpage answers a blocked source IP with `303 -> /sp/captcha-block`.
448
+ // The destination is the block itself, never results, so following it only
449
+ // costs a round trip and still parses to nothing.
450
+ if (response.status >= 300 && response.status < 400) {
451
+ const location = response.headers.get("location") || "";
452
+ return rateLimited(spec, /captcha|blocked|sorry|challenge/iu.test(location)
453
+ ? "captcha redirect"
454
+ : `HTTP ${response.status} redirect`);
455
+ }
456
+ // 202 is DuckDuckGo's soft challenge (and is `ok`, so status alone would let
457
+ // an interstitial through as an empty success); 403 is what it escalates to
458
+ // once it stops asking politely. No credentials are ever sent to these
459
+ // endpoints, so a 403 can only mean "blocked", never "unauthorized".
460
+ if (RATE_LIMIT_STATUSES.has(response.status)) {
461
+ return rateLimited(spec, `HTTP ${response.status}`);
462
+ }
304
463
  if (!response.ok) {
305
464
  return {
306
465
  ok: false,
307
- backend: "startpage",
308
- message: `Startpage HTTP ${response.status}`,
309
- retryable: response.status === 429 || response.status >= 500,
466
+ backend: spec.backend,
467
+ message: `${spec.label} HTTP ${response.status}`,
468
+ retryable: response.status >= 500,
310
469
  };
311
470
  }
312
- return { ok: true, backend: "startpage", results: parseStartpageResults(html) };
471
+ const results = spec.parse(html);
472
+ // A 200 that parses to nothing is ambiguous: either a genuinely empty
473
+ // result set or a bot gate. Only the body markers tell them apart, and
474
+ // conflating them is what made a ban look like "No results."
475
+ if (results.length === 0 && CHALLENGE_BODY_RE.test(html)) {
476
+ return rateLimited(spec, "interstitial challenge page");
477
+ }
478
+ return { ok: true, backend: spec.backend, results };
313
479
  } catch (error) {
314
- return fetchFailure("startpage", error);
480
+ return fetchFailure(spec.backend, error, spec.label);
481
+ } finally {
482
+ release();
315
483
  }
316
484
  }
317
485
 
486
+ function searchDuckDuckGo(query, options) {
487
+ return keylessHtmlSearch({
488
+ backend: "duckduckgo",
489
+ label: "DuckDuckGo",
490
+ url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
491
+ parse: parseDuckDuckGoResults,
492
+ }, options);
493
+ }
494
+
495
+ function searchStartpage(query, options) {
496
+ // Startpage serves its results from a form POST. The old query-string GET was
497
+ // answered with a 3xx, which `redirect: "error"` turned into a bare
498
+ // `TypeError: fetch failed` — so this backend never once returned a result,
499
+ // and its useless error was the only thing the operator ever saw.
500
+ const body = new URLSearchParams({ query, cat: "web" });
501
+ const withDate = { day: "d", month: "m", year: "y" }[options.timeRange];
502
+ if (withDate) body.set("with_date", withDate);
503
+ return keylessHtmlSearch({
504
+ backend: "startpage",
505
+ label: "Startpage",
506
+ url: "https://www.startpage.com/sp/search",
507
+ init: {
508
+ method: "POST",
509
+ body,
510
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
511
+ },
512
+ parse: parseStartpageResults,
513
+ }, options);
514
+ }
515
+
516
+ // Opens the cooldown at the moment of detection rather than letting the caller
517
+ // do it. The semaphore slot is released before this result reaches the caller,
518
+ // so a queued sibling would otherwise be admitted and re-check the cooldown
519
+ // while it was still closed, and every variant in flight would hit the wire.
520
+ function rateLimited(spec, detail) {
521
+ backendCooldownUntil.set(spec.backend, Date.now() + keylessThrottle.cooldownMs);
522
+ return {
523
+ ok: false,
524
+ backend: spec.backend,
525
+ message: `${spec.label} rate-limited (${detail})`,
526
+ retryable: true,
527
+ rateLimited: true,
528
+ };
529
+ }
530
+
318
531
  export function parseDuckDuckGoResults(html) {
319
532
  const { document } = parseHTML(String(html || ""));
320
533
  const rows = [...document.querySelectorAll(".result")];
@@ -334,6 +547,10 @@ export function parseDuckDuckGoResults(html) {
334
547
 
335
548
  export function parseStartpageResults(html) {
336
549
  const { document } = parseHTML(String(html || ""));
550
+ // Startpage inlines emotion CSS in <style> tags nested inside the result
551
+ // anchors, and textContent happily returns the stylesheet as part of the
552
+ // title (".css-i3irj7{line-height:18px;...}Best time to visit Japan").
553
+ for (const node of document.querySelectorAll("style, script")) node.remove();
337
554
  const selectors = [".w-gl__result", ".result", "article"];
338
555
  const rows = selectors.flatMap((selector) => [...document.querySelectorAll(selector)]);
339
556
  const seen = new Set();
@@ -541,15 +758,34 @@ async function readLimitedText(response) {
541
758
  return Buffer.concat(chunks).toString("utf8");
542
759
  }
543
760
 
544
- function fetchFailure(backend, error) {
761
+ // undici reports transport problems as a bare `TypeError: fetch failed` and
762
+ // keeps the real reason on `error.cause` — dropping it is what left an
763
+ // "unexpected redirect" looking like an unexplained network fault.
764
+ const RETRYABLE_FETCH_CODES = new Set([
765
+ "ECONNRESET",
766
+ "ECONNREFUSED",
767
+ "ETIMEDOUT",
768
+ "EAI_AGAIN",
769
+ "ENOTFOUND",
770
+ "ENETUNREACH",
771
+ "ENETDOWN",
772
+ "EPIPE",
773
+ "UND_ERR_CONNECT_TIMEOUT",
774
+ "UND_ERR_SOCKET",
775
+ ]);
776
+
777
+ function fetchFailure(backend, error, label = backend) {
545
778
  const name = error?.name;
546
779
  const retryable = name === "AbortError"
547
780
  || name === "TimeoutError"
548
- || ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code ?? error?.cause?.code);
781
+ || RETRYABLE_FETCH_CODES.has(error?.code ?? error?.cause?.code);
782
+ const message = error?.message || String(error);
783
+ const cause = error?.cause?.message;
784
+ const detail = cause && cause !== message ? `${message} (${cause})` : message;
549
785
  return {
550
786
  ok: false,
551
787
  backend,
552
- message: `${backend} request failed: ${error?.message || String(error)}`,
788
+ message: `${label} request failed: ${detail}`,
553
789
  retryable,
554
790
  };
555
791
  }
@@ -8,6 +8,7 @@ import { passthroughSandbox } from "../../agent/sandbox-seam.js";
8
8
  import {
9
9
  ACP_DEFAULT_MAX_LINE_BYTES,
10
10
  AcpTransportError,
11
+ connectWithSafeAcpSdkDiagnostics,
11
12
  createBoundedAcpStdioStream,
12
13
  normalizeAcpMaxLineBytes,
13
14
  } from "./acp-transport.js";
@@ -20,10 +21,12 @@ import {
20
21
  encodeAcpSessionCursor,
21
22
  validateAcpProfileId,
22
23
  validateAcpProviderSessionId,
24
+ validateAcpSessionTokenKey,
23
25
  } from "./acp-session-tokens.js";
24
26
 
25
27
  const OWNERS = new Set(["client", "agent"]);
26
28
  const RESUME_STRATEGIES = new Set(["auto", "load", "resume"]);
29
+ const TOKEN_FREE_OPERATIONS = new Set(["probe", "authenticate", "logout"]);
27
30
  const DEFAULT_PROCESS_POLICY = Object.freeze({
28
31
  startupTimeoutMs: 10_000,
29
32
  requestTimeoutMs: 60_000,
@@ -135,6 +138,7 @@ const DEFAULT_PROCESS_POLICY = Object.freeze({
135
138
  * @property {string} [cwd]
136
139
  * @property {AbortSignal} [signal]
137
140
  * @property {Record<string, unknown>} [context]
141
+ * @property {Uint8Array} [acpSessionTokenKey] Host-owned 32-byte key required by operations that emit or consume opaque session handles.
138
142
  */
139
143
 
140
144
  export {
@@ -527,6 +531,9 @@ export async function connectAcpProfile(profileId, options) {
527
531
  validateAcpProfileId(profileId);
528
532
  throwIfAborted(options?.signal);
529
533
  const operation = options?.operation || "connect";
534
+ const sessionTokenKey = TOKEN_FREE_OPERATIONS.has(operation)
535
+ ? undefined
536
+ : validateAcpSessionTokenKey(options?.acpSessionTokenKey);
530
537
  const descriptor = await resolveProfile(profileId, { ...options, operation });
531
538
  throwIfAborted(options?.signal);
532
539
  const capabilities = clientCapabilities(descriptor);
@@ -575,7 +582,13 @@ export async function connectAcpProfile(profileId, options) {
575
582
  const safeCallbackContext = (context, rawSessionId) => ({
576
583
  ...context,
577
584
  ...(typeof rawSessionId === "string"
578
- ? { providerSessionId: encodeAcpProviderSessionId(profileId, rawSessionId) }
585
+ ? {
586
+ providerSessionId: encodeAcpProviderSessionId(
587
+ profileId,
588
+ rawSessionId,
589
+ /** @type {Uint8Array} */ (sessionTokenKey),
590
+ ),
591
+ }
579
592
  : {}),
580
593
  ...(context.requestId === undefined
581
594
  ? {}
@@ -696,9 +709,11 @@ export async function connectAcpProfile(profileId, options) {
696
709
 
697
710
  let connection;
698
711
  try {
699
- connection = app.connect(createBoundedAcpStdioStream(child, {
700
- maxLineBytes: descriptor.process.maxLineBytes,
701
- }));
712
+ connection = connectWithSafeAcpSdkDiagnostics(() => app.connect(
713
+ createBoundedAcpStdioStream(child, {
714
+ maxLineBytes: descriptor.process.maxLineBytes,
715
+ }),
716
+ ));
702
717
  } catch (error) {
703
718
  child.kill("SIGTERM");
704
719
  const exited = await waitForExit(exitPromise, descriptor.process.killGraceMs);
@@ -1010,15 +1025,15 @@ function validateMcpServers(servers, descriptor, initializeResult) {
1010
1025
  });
1011
1026
  }
1012
1027
 
1013
- /** @param {string} profileId @param {any} request */
1014
- function protocolSessionListRequest(profileId, request) {
1028
+ /** @param {string} profileId @param {any} request @param {Uint8Array} key */
1029
+ function protocolSessionListRequest(profileId, request, key) {
1015
1030
  if (!request || typeof request !== "object" || Array.isArray(request)) {
1016
1031
  throw new AcpClientError("invalid_request", "ACP session/list request must be an object.");
1017
1032
  }
1018
1033
  const { cursor, _meta: _meta, ...rest } = request;
1019
1034
  return {
1020
1035
  ...rest,
1021
- ...(cursor == null ? {} : { cursor: decodeAcpSessionCursor(profileId, cursor) }),
1036
+ ...(cursor == null ? {} : { cursor: decodeAcpSessionCursor(profileId, cursor, key) }),
1022
1037
  };
1023
1038
  }
1024
1039
 
@@ -1090,18 +1105,23 @@ export async function logoutAcpProfile(profileId, options) {
1090
1105
  * @returns {Promise<AcpSessionListResult>}
1091
1106
  */
1092
1107
  export async function listAcpSessions(profileId, request = {}, options = /** @type {any} */ ({})) {
1093
- const protocolRequest = protocolSessionListRequest(profileId, request);
1094
- const connection = await connectAcpProfile(profileId, { ...options, operation: "list_sessions" });
1108
+ const key = validateAcpSessionTokenKey(options?.acpSessionTokenKey);
1109
+ const protocolRequest = protocolSessionListRequest(profileId, request, key);
1110
+ const connection = await connectAcpProfile(profileId, {
1111
+ ...options,
1112
+ operation: "list_sessions",
1113
+ acpSessionTokenKey: key,
1114
+ });
1095
1115
  try {
1096
1116
  const result = await connection.listSessions(protocolRequest);
1097
1117
  return {
1098
1118
  profileId,
1099
1119
  sessions: (result.sessions || []).map((session) => ({
1100
1120
  ...sanitizeAcpHostValue(session, [session.sessionId, result.nextCursor]),
1101
- providerSessionId: encodeAcpProviderSessionId(profileId, session.sessionId),
1121
+ providerSessionId: encodeAcpProviderSessionId(profileId, session.sessionId, key),
1102
1122
  })),
1103
1123
  nextCursor: typeof result.nextCursor === "string"
1104
- ? encodeAcpSessionCursor(profileId, result.nextCursor)
1124
+ ? encodeAcpSessionCursor(profileId, result.nextCursor, key)
1105
1125
  : null,
1106
1126
  };
1107
1127
  } finally {
@@ -1111,8 +1131,13 @@ export async function listAcpSessions(profileId, request = {}, options = /** @ty
1111
1131
 
1112
1132
  /** @param {string} providerSessionId @param {AcpClientHostOptions} options */
1113
1133
  export async function deleteAcpSession(providerSessionId, options) {
1114
- const { profileId, sessionId } = decodeAcpProviderSessionId(providerSessionId);
1115
- const connection = await connectAcpProfile(profileId, { ...options, operation: "delete_session" });
1134
+ const key = validateAcpSessionTokenKey(options?.acpSessionTokenKey);
1135
+ const { profileId, sessionId } = decodeAcpProviderSessionId(providerSessionId, key);
1136
+ const connection = await connectAcpProfile(profileId, {
1137
+ ...options,
1138
+ operation: "delete_session",
1139
+ acpSessionTokenKey: key,
1140
+ });
1116
1141
  try {
1117
1142
  await connection.deleteSession(sessionId);
1118
1143
  return { profileId, providerSessionId, deleted: true };