@ordewell/core 0.9.0 → 0.9.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/dist/index.mjs CHANGED
@@ -97,7 +97,7 @@ import {
97
97
  unsendLatest,
98
98
  usageLine,
99
99
  validateTaskEdit
100
- } from "./chunk-QUARDKVT.mjs";
100
+ } from "./chunk-OOZWSUIE.mjs";
101
101
  import {
102
102
  PLAN_ENVELOPE_KEY,
103
103
  PlanParseError,
@@ -247,7 +247,7 @@ function toTaskLogEvent(event) {
247
247
  }
248
248
  }
249
249
  function taskLogForSurface(events) {
250
- return events.map((event) => event.type === "task_skills" ? { type: "task_skills", skills: event.skills.map(({ name, source, path: path26 }) => ({ name, source, path: path26 })) } : event);
250
+ return events.map((event) => event.type === "task_skills" ? { type: "task_skills", skills: event.skills.map(({ name, source, path: path31 }) => ({ name, source, path: path31 })) } : event);
251
251
  }
252
252
  function coalesceTaskLog(events) {
253
253
  const out = [];
@@ -502,6 +502,7 @@ function lex(command, nested2, dialect) {
502
502
  let followable = true;
503
503
  let bareSegment = false;
504
504
  let tokens = [];
505
+ let wordEnds = [];
505
506
  let expandable = false;
506
507
  let globbed = false;
507
508
  let current = "";
@@ -538,7 +539,10 @@ function lex(command, nested2, dialect) {
538
539
  started = false;
539
540
  return;
540
541
  }
541
- if (started) tokens.push(current);
542
+ if (started) {
543
+ tokens.push(current);
544
+ wordEnds.push(i);
545
+ }
542
546
  current = "";
543
547
  started = false;
544
548
  };
@@ -567,12 +571,13 @@ function lex(command, nested2, dialect) {
567
571
  followable = false;
568
572
  bareSegment = true;
569
573
  }
570
- segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy });
574
+ segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy, wordEnds });
571
575
  } else if (!last || segments.length > 0 || joinedBy !== void 0) {
572
576
  followable = false;
573
577
  bareSegment = true;
574
578
  }
575
579
  tokens = [];
580
+ wordEnds = [];
576
581
  inputs = [];
577
582
  expandable = false;
578
583
  globbed = false;
@@ -596,6 +601,10 @@ function lex(command, nested2, dialect) {
596
601
  }
597
602
  if (c === dialect.escape && (quote === "" || dialect.escapeInQuotes)) {
598
603
  const next = command[i + 1];
604
+ if (dialect.escape === "\\" && next === "\n") {
605
+ i += 2;
606
+ continue;
607
+ }
599
608
  if (redirectTarget === "heredoc") hereDocQuoted = true;
600
609
  if (next !== void 0 && !(quote === "" && nameDelimiter(next))) {
601
610
  current += next;
@@ -837,7 +846,7 @@ function lexAll(command, dialect, cdpathSet = false) {
837
846
  if (top.followable && !top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
838
847
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
839
848
  const inner = lex(queue.shift(), queue, dialect);
840
- all.segments.push(...inner.segments.filter((s) => s.binary));
849
+ all.segments.push(...inner.segments.filter((s) => s.binary).map((s) => ({ ...s, nested: true })));
841
850
  all.unsafeRedirect ??= inner.unsafeRedirect;
842
851
  all.processSubstitution ||= inner.processSubstitution;
843
852
  all.unbalanced ||= inner.unbalanced;
@@ -3012,7 +3021,7 @@ function exclusionWord(pattern) {
3012
3021
  return { matches: (word) => re.test(word), wildcard: pattern.includes("*") };
3013
3022
  }
3014
3023
  function isScopePattern(entry) {
3015
- return /^(\/|~|https?:\/\/|mcp__|[A-Za-z]:[\\/])/.test(entry);
3024
+ return /^(\/|~|https?:\/\/|web_search:|mcp__|mcp-server:|[A-Za-z]:[\\/])/.test(entry);
3016
3025
  }
3017
3026
  function parseAllowlist(entries) {
3018
3027
  const list = { commands: [], commandExclusions: [], tools: [], toolExclusions: [], scopes: [], scopeExclusions: [] };
@@ -3046,6 +3055,7 @@ function ruleCovers(rule, command) {
3046
3055
  return rule.words.every((word, i) => word.matches(lead[i]) && !(word.wildcard && MUTATING_VERB.test(lead[i])));
3047
3056
  }
3048
3057
  function exclusionCovers(rule, command) {
3058
+ if (/^!\* \*https?:\/\/\*$/.test(rule.text) && /^(curl|wget)$/.test(command.binary) && command.args.length === 1 && /^https?:\/\/\S+\/\*$/.test(command.args[0])) return false;
3049
3059
  if (rule.binary !== "*" && rule.binary !== command.binary) return false;
3050
3060
  let next = 0;
3051
3061
  for (const arg of command.args) {
@@ -3061,8 +3071,14 @@ function toolAllowed(list, tool2) {
3061
3071
  if (list.toolExclusions.some((re) => re.test(tool2))) return false;
3062
3072
  return list.tools.some((re) => re.test(tool2));
3063
3073
  }
3074
+ function scopeExcluded(list, scope) {
3075
+ const origin = /^(?:curl|wget) (https?:\/\/\S+\/\*)$/.exec(scope)?.[1];
3076
+ return list.scopeExclusions.some((re) => re.test(scope) || origin !== void 0 && re.test(origin));
3077
+ }
3064
3078
  function scopeAllowed(list, scope) {
3065
- if (list.scopeExclusions.some((re) => re.test(scope))) return false;
3079
+ if (scopeExcluded(list, scope)) return false;
3080
+ const web = /^(curl|wget) (https?:\/\/\S+\/\*)$/.exec(scope);
3081
+ if (web) return commandAllowedBy(list, { binary: web[1], args: [web[2]], scopeWords: 1 }) !== void 0;
3066
3082
  return list.scopes.some((re) => re.test(scope)) || list.commands.some((rule) => rule.text === scope);
3067
3083
  }
3068
3084
  function withArgument(binary, verbs) {
@@ -3330,6 +3346,405 @@ var DEFAULT_PLANNER_ALLOWLIST = [
3330
3346
  ...["*secret*", "*token*", "*password*", "*credential*", "*key*", "*url*", "*fetch*", "*http*"].map((w) => `!mcp:${w}`)
3331
3347
  ];
3332
3348
 
3349
+ // src/services/webCommands.ts
3350
+ import { isIP as isIP2 } from "node:net";
3351
+
3352
+ // src/services/egressGuard.ts
3353
+ import { lookup } from "node:dns/promises";
3354
+ import { isIP } from "node:net";
3355
+ var resolveAll = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
3356
+ var ALLOWED_PORTS = /* @__PURE__ */ new Set(["", "80", "443"]);
3357
+ var LOCAL_NAMES = /* @__PURE__ */ new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
3358
+ function ipv4ToInt(ip) {
3359
+ return ip.split(".").reduce((acc, part) => acc * 256 + Number(part), 0);
3360
+ }
3361
+ var BLOCKED_V4 = [
3362
+ ["0.0.0.0", 8, '"this network"'],
3363
+ ["10.0.0.0", 8, "private"],
3364
+ ["100.64.0.0", 10, "shared (CGNAT)"],
3365
+ ["127.0.0.0", 8, "loopback"],
3366
+ ["169.254.0.0", 16, "link-local"],
3367
+ ["172.16.0.0", 12, "private"],
3368
+ ["192.168.0.0", 16, "private"],
3369
+ ["198.18.0.0", 15, "benchmarking"],
3370
+ ["224.0.0.0", 4, "multicast"],
3371
+ // Includes the 255.255.255.255 limited broadcast.
3372
+ ["240.0.0.0", 4, "reserved"]
3373
+ ].map(([base, bits, label]) => [ipv4ToInt(base), bits, label]);
3374
+ function blockedV4Reason(ip) {
3375
+ const n = ipv4ToInt(ip);
3376
+ for (const [base, bits, label] of BLOCKED_V4) {
3377
+ const size = 2 ** (32 - bits);
3378
+ if (n >= base && n < base + size) return `${label} address ${ip}`;
3379
+ }
3380
+ return void 0;
3381
+ }
3382
+ function ipv6Bytes(ip) {
3383
+ let text = ip;
3384
+ const tail = [];
3385
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
3386
+ if (dotted) {
3387
+ tail.push(...dotted[1].split(".").map(Number));
3388
+ text = text.slice(0, -dotted[1].length) + "0:0";
3389
+ }
3390
+ const [head, rest] = text.includes("::") ? text.split("::") : [text, void 0];
3391
+ const left = head ? head.split(":") : [];
3392
+ const right = rest ? rest.split(":") : [];
3393
+ const groups = rest === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
3394
+ const bytes = groups.flatMap((g) => {
3395
+ const v = parseInt(g, 16);
3396
+ return [v >> 8, v & 255];
3397
+ });
3398
+ if (tail.length) bytes.splice(12, 4, ...tail);
3399
+ return bytes;
3400
+ }
3401
+ function inPrefix(bytes, prefix, bits) {
3402
+ for (let i = 0; i < bits; i += 8) {
3403
+ const mask = 255 << 8 - Math.min(8, bits - i) & 255;
3404
+ if ((bytes[i / 8] & mask) !== ((prefix[i / 8] ?? 0) & mask)) return false;
3405
+ }
3406
+ return true;
3407
+ }
3408
+ var BLOCKED_V6 = [
3409
+ [[], 127, "unspecified/loopback"],
3410
+ [[252], 7, "unique-local"],
3411
+ [[254, 128], 10, "link-local"],
3412
+ [[254, 192], 10, "site-local"],
3413
+ [[255], 8, "multicast"]
3414
+ ];
3415
+ var MAPPED_V4 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255];
3416
+ var NAT64_V4 = [0, 100, 255, 155];
3417
+ function blockedV6Reason(ip) {
3418
+ const bytes = ipv6Bytes(ip);
3419
+ const v4 = bytes.slice(12).join(".");
3420
+ if (inPrefix(bytes, MAPPED_V4, 96) || inPrefix(bytes, NAT64_V4, 96)) return blockedV4Reason(v4);
3421
+ for (const [prefix, bits, label] of BLOCKED_V6) {
3422
+ if (inPrefix(bytes, prefix, bits)) return `${label} address ${ip}`;
3423
+ }
3424
+ return void 0;
3425
+ }
3426
+ function blockedAddressReason(ip) {
3427
+ if (typeof ip !== "string") return "unparseable address";
3428
+ const bare = ip.replace(/%.*$/, "");
3429
+ switch (isIP(bare)) {
3430
+ case 4:
3431
+ return blockedV4Reason(bare);
3432
+ case 6:
3433
+ return blockedV6Reason(bare.toLowerCase());
3434
+ default:
3435
+ return "unparseable address";
3436
+ }
3437
+ }
3438
+ function stripBrackets(host) {
3439
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
3440
+ }
3441
+ function vetHostUnresolved(host) {
3442
+ const bare = stripBrackets(host);
3443
+ if (isIP(bare)) {
3444
+ const reason = blockedAddressReason(bare);
3445
+ return reason ? { ok: false, reason } : { ok: true, addresses: [bare] };
3446
+ }
3447
+ if (LOCAL_NAMES.has(bare.toLowerCase())) return { ok: false, reason: `local host name ${bare}` };
3448
+ return void 0;
3449
+ }
3450
+ async function vetHost(host, resolve9) {
3451
+ const settled = vetHostUnresolved(host);
3452
+ if (settled) return settled;
3453
+ const bare = stripBrackets(host);
3454
+ let addresses;
3455
+ try {
3456
+ addresses = await resolve9(bare);
3457
+ } catch {
3458
+ return { ok: false, reason: `${bare} did not resolve` };
3459
+ }
3460
+ if (addresses.length === 0) return { ok: false, reason: `${bare} did not resolve` };
3461
+ for (const address of addresses) {
3462
+ const reason = blockedAddressReason(address);
3463
+ if (reason) return { ok: false, reason: `${bare} resolves to a ${reason}` };
3464
+ }
3465
+ return { ok: true, addresses };
3466
+ }
3467
+ async function vetEgress(host, port, resolve9 = resolveAll) {
3468
+ return vetEgressUnresolved(host, port) ?? vetHost(host, resolve9);
3469
+ }
3470
+ function vetEgressUnresolved(host, port) {
3471
+ if (!ALLOWED_PORTS.has(port)) return { ok: false, reason: `port ${port} is not 80 or 443` };
3472
+ return vetHostUnresolved(host);
3473
+ }
3474
+ function guardedLookup(resolve9 = resolveAll) {
3475
+ return (hostname, options, callback) => {
3476
+ void vetHost(hostname, resolve9).then((check2) => {
3477
+ if (!check2.ok) {
3478
+ callback(Object.assign(new Error(`blocked host: ${check2.reason}`), { code: "EBLOCKEDHOST" }), []);
3479
+ return;
3480
+ }
3481
+ const family = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family;
3482
+ const addresses = check2.addresses.map((address) => ({ address, family: isIP(address) })).filter((a) => !family || a.family === family);
3483
+ if (addresses.length === 0) {
3484
+ callback(Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND" }), []);
3485
+ } else if (options.all) {
3486
+ callback(null, addresses);
3487
+ } else {
3488
+ callback(null, addresses[0].address, addresses[0].family);
3489
+ }
3490
+ });
3491
+ };
3492
+ }
3493
+
3494
+ // src/services/webCommands.ts
3495
+ var READ_REFUSAL_HINT = "Fetch the page with your web fetch tool instead.";
3496
+ var EFFECT_REFUSAL_HINT = "This sends data or changes something, so the planner should not do it itself: add it to the plan as an ops task (and, if it writes files, a change task).";
3497
+ var CURL_EFFECT = /* @__PURE__ */ new Set([
3498
+ "d",
3499
+ "F",
3500
+ "T",
3501
+ "O",
3502
+ "D",
3503
+ "c",
3504
+ "J",
3505
+ "--data",
3506
+ "--data-raw",
3507
+ "--data-binary",
3508
+ "--data-ascii",
3509
+ "--data-urlencode",
3510
+ "--form",
3511
+ "--form-string",
3512
+ "--json",
3513
+ "--upload-file",
3514
+ "--remote-name",
3515
+ "--remote-name-all",
3516
+ "--dump-header",
3517
+ "--cookie-jar",
3518
+ "--create-dirs",
3519
+ "--output-dir",
3520
+ "--remote-header-name"
3521
+ ]);
3522
+ var WGET_EFFECT = /* @__PURE__ */ new Set([
3523
+ "P",
3524
+ "r",
3525
+ "m",
3526
+ "N",
3527
+ "c",
3528
+ "a",
3529
+ "o",
3530
+ "--post-data",
3531
+ "--post-file",
3532
+ "--body-data",
3533
+ "--body-file",
3534
+ "--method",
3535
+ "--output-file",
3536
+ "--append-output",
3537
+ "--directory-prefix",
3538
+ "--recursive",
3539
+ "--mirror",
3540
+ "--timestamping",
3541
+ "--continue",
3542
+ "--save-headers",
3543
+ "--content-disposition",
3544
+ "--input-file",
3545
+ "--warc-file",
3546
+ "--save-cookies"
3547
+ ]);
3548
+ var CURL_SWITCHES = /* @__PURE__ */ new Set([
3549
+ "s",
3550
+ "S",
3551
+ "f",
3552
+ "i",
3553
+ "I",
3554
+ "v",
3555
+ "L",
3556
+ "q",
3557
+ "G",
3558
+ "--silent",
3559
+ "--show-error",
3560
+ "--fail",
3561
+ "--include",
3562
+ "--head",
3563
+ "--verbose",
3564
+ "--location",
3565
+ "--disable",
3566
+ "--get",
3567
+ "--compressed",
3568
+ "--version"
3569
+ ]);
3570
+ var CURL_VALUES = /* @__PURE__ */ new Set(["A", "m", "o", "w", "--user-agent", "--max-time", "--connect-timeout", "--output", "--write-out", "--noproxy"]);
3571
+ var CURL_SENSITIVE = /* @__PURE__ */ new Set([
3572
+ "d",
3573
+ "F",
3574
+ "u",
3575
+ "H",
3576
+ "b",
3577
+ "e",
3578
+ "X",
3579
+ "--data",
3580
+ "--data-raw",
3581
+ "--data-binary",
3582
+ "--data-ascii",
3583
+ "--data-urlencode",
3584
+ "--form",
3585
+ "--form-string",
3586
+ "--user",
3587
+ "--header",
3588
+ "--cookie",
3589
+ "--referer",
3590
+ "--request"
3591
+ ]);
3592
+ var WGET_SWITCHES = /* @__PURE__ */ new Set(["q", "S", "v", "--quiet", "--server-response", "--verbose", "--no-verbose", "--spider", "--no-config", "--no-hsts", "--no-proxy", "--no-netrc", "--version"]);
3593
+ var WGET_VALUES = /* @__PURE__ */ new Set(["O", "U", "T", "--output-document", "--user-agent", "--timeout", "--connect-timeout", "--read-timeout", "--max-redirect"]);
3594
+ function webCommand(seg, trustedResolves) {
3595
+ const binary = seg.binary.toLowerCase().replace(/\.exe$/, "");
3596
+ if (binary === "wget2") return { urls: [], reason: `wget2 can write persistent databases. ${READ_REFUSAL_HINT}` };
3597
+ if (binary !== "curl" && binary !== "wget") return void 0;
3598
+ if (seg.binaryPath !== void 0) return { urls: [], reason: `Call ${binary} by name so the research shell resolves it from PATH. ${READ_REFUSAL_HINT}` };
3599
+ const urls = [];
3600
+ const outboundTexts = [];
3601
+ const switches = binary === "curl" ? CURL_SWITCHES : WGET_SWITCHES;
3602
+ const values = binary === "curl" ? CURL_VALUES : WGET_VALUES;
3603
+ let stdout = false;
3604
+ let spider = false;
3605
+ let perInvocation = false;
3606
+ let redirects = binary === "wget";
3607
+ let getQuery = false;
3608
+ let queryData = false;
3609
+ let effect = false;
3610
+ const effectFlags = binary === "curl" ? CURL_EFFECT : WGET_EFFECT;
3611
+ const refused3 = (flag, sends = false) => sends ? { urls, effect: true, reason: `"${binary} ${flag}" is not a read-only web command. ${EFFECT_REFUSAL_HINT}` } : { urls, reason: `"${binary} ${flag}" is not known to be a read-only web command. ${READ_REFUSAL_HINT}` };
3612
+ if (seg.expandable || seg.globbed) return refused3("with shell expansions or globbing");
3613
+ for (let i = 0; i < seg.args.length; i++) {
3614
+ const arg = seg.args[i];
3615
+ if (arg === "-") return refused3(arg);
3616
+ if (!arg.startsWith("-")) {
3617
+ let url;
3618
+ try {
3619
+ url = new URL(arg);
3620
+ } catch {
3621
+ return refused3(arg);
3622
+ }
3623
+ if (!/^https?:\/\//i.test(arg) || /[\\\s{}]/.test(arg)) return refused3(arg);
3624
+ const host = url.hostname.replace(/^\[|\]$/g, "");
3625
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(arg)?.[1] ?? "";
3626
+ const rawHost = authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, "");
3627
+ if (binary === "curl" && !/^[A-Za-z0-9.-]+$/.test(rawHost) && !(/^\[[0-9A-Fa-f:.]+\]$/.test(rawHost) && isIP2(host) === 6)) return refused3("with a non-literal ASCII hostname");
3628
+ const withoutHost = arg.replace(url.hostname, "");
3629
+ if (/[[\]]/.test(withoutHost)) return refused3(arg);
3630
+ if (isIP2(host)) {
3631
+ const reason = blockedAddressReason(host);
3632
+ if (reason) return { urls, reason: `Blocked host: ${reason}. ${READ_REFUSAL_HINT}` };
3633
+ }
3634
+ if (url.username || url.password) perInvocation = true;
3635
+ urls.push(url);
3636
+ outboundTexts.push(arg);
3637
+ continue;
3638
+ }
3639
+ const long = arg.startsWith("--");
3640
+ const flags = long ? [arg.split("=")[0]] : [...arg.slice(1)];
3641
+ for (let j = 0; j < flags.length; j++) {
3642
+ const flag = flags[j];
3643
+ if (binary === "curl" && (flag === "n" || flag === "--netrc" || flag === "--netrc-optional")) {
3644
+ if (arg.includes("=")) return refused3(arg);
3645
+ perInvocation = true;
3646
+ continue;
3647
+ }
3648
+ if (switches.has(flag)) {
3649
+ if (long && arg.includes("=")) return refused3(arg);
3650
+ if (flag === "--spider") spider = true;
3651
+ if (flag === "L" || flag === "--location") redirects = true;
3652
+ if (flag === "G" || flag === "--get") getQuery = true;
3653
+ continue;
3654
+ }
3655
+ const sensitive = binary === "curl" && CURL_SENSITIVE.has(flag);
3656
+ const sends = effectFlags.has(flag) && !sensitive;
3657
+ if (sends && !(binary === "wget" && (flag === "O" || flag === "--output-document"))) return refused3(arg, true);
3658
+ const trustedResolve = binary === "curl" && flag === "--resolve" && trustedResolves !== void 0;
3659
+ if (!values.has(flag) && !sensitive && !trustedResolve) return refused3(arg);
3660
+ const attached2 = long ? arg.includes("=") ? arg.slice(flag.length + 1) : void 0 : flags.slice(j + 1).join("") || void 0;
3661
+ const value = attached2 ?? seg.args[++i];
3662
+ if (value === void 0) return refused3(arg);
3663
+ outboundTexts.push(value);
3664
+ if (trustedResolve && !trustedResolves?.has(value)) return refused3(arg);
3665
+ if (flag === "--noproxy" && value !== "*") return refused3(arg);
3666
+ if (binary === "wget" && flag === "--max-redirect") redirects = value !== "0";
3667
+ if (sensitive) {
3668
+ if (flag === "--data-urlencode" && !value.includes("@")) queryData = true;
3669
+ else if (!((flag === "X" || flag === "--request") && (value === "GET" || value === "HEAD"))) {
3670
+ perInvocation = true;
3671
+ effect = true;
3672
+ }
3673
+ }
3674
+ if (binary === "wget" && (flag === "O" || flag === "--output-document")) {
3675
+ if (value !== "-") return refused3(arg, true);
3676
+ stdout = true;
3677
+ }
3678
+ if ((flag === "o" || flag === "--output") && value !== "/dev/null") return refused3(arg, binary === "curl");
3679
+ if ((flag === "w" || flag === "--write-out") && (value.startsWith("@") || value.includes("%output{"))) return refused3(arg);
3680
+ break;
3681
+ }
3682
+ }
3683
+ if (urls.length === 0) return refused3("without a literal HTTP(S) URL");
3684
+ if (binary === "wget" && !stdout && !spider) return refused3("without stdout or --spider");
3685
+ return { urls, outboundTexts, ...effect ? { effect } : {}, ...perInvocation || redirects || queryData && !getQuery ? { perInvocation: true } : {} };
3686
+ }
3687
+ function disableWebCommandStartup(command, dialect) {
3688
+ const syntax = dialectFor(dialect);
3689
+ const edits = [];
3690
+ for (const original of lex(command, [], syntax).segments) {
3691
+ const { seg } = unwrap(original, syntax);
3692
+ const binary = seg.binary.toLowerCase().replace(/\.exe$/, "");
3693
+ if (binary !== "curl" && binary !== "wget") continue;
3694
+ const ends = original.wordEnds ?? [];
3695
+ const end = ends[ends.length - seg.args.length - 1];
3696
+ const noProxy = syntax.escape === "^" ? '"*"' : "'*'";
3697
+ if (end !== void 0) edits.push({ end, flags: binary === "curl" ? ` -q --noproxy ${noProxy}` : " --no-config --no-hsts --no-proxy --no-netrc" });
3698
+ }
3699
+ for (const { end, flags } of edits.sort((a, b) => b.end - a.end)) {
3700
+ command = command.slice(0, end) + flags + command.slice(end);
3701
+ }
3702
+ return command;
3703
+ }
3704
+ function pinWebCommandAddresses(command, addresses, dialect) {
3705
+ const syntax = dialectFor(dialect);
3706
+ const resolves2 = /* @__PURE__ */ new Set();
3707
+ const edits = [];
3708
+ for (const original of lex(command, [], syntax).segments) {
3709
+ const { seg } = unwrap(original, syntax);
3710
+ if (seg.binary.toLowerCase().replace(/\.exe$/, "") !== "curl") continue;
3711
+ const web = webCommand(seg);
3712
+ if (!web || web.reason) continue;
3713
+ const flags = [...new Set(web.urls.map((url) => url.origin))].flatMap((origin) => {
3714
+ const url = new URL(origin);
3715
+ if (isIP2(url.hostname.replace(/^\[|\]$/g, ""))) return [];
3716
+ const address = addresses.get(origin);
3717
+ if (!address) throw new Error("Missing vetted curl address");
3718
+ const value = `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}:${isIP2(address) === 6 ? `[${address}]` : address}`;
3719
+ resolves2.add(value);
3720
+ return [` --resolve ${syntax.escape === "^" ? `"${value}"` : `'${value}'`}`];
3721
+ }).join("");
3722
+ const ends = original.wordEnds ?? [];
3723
+ const end = ends[ends.length - seg.args.length - (seg.args[0] === "-q" ? 0 : 1)];
3724
+ if (end !== void 0 && flags) edits.push({ end, flags });
3725
+ }
3726
+ for (const { end, flags } of edits.sort((a, b) => b.end - a.end)) {
3727
+ command = command.slice(0, end) + flags + command.slice(end);
3728
+ }
3729
+ return { command, resolves: resolves2 };
3730
+ }
3731
+ function wgetVersionCommands(command, dialect) {
3732
+ const syntax = dialectFor(dialect);
3733
+ const { segments } = lexAll(command, syntax, !!process.env.CDPATH);
3734
+ const quote = (word) => syntax.escape === "^" ? `"${word}"` : `'${word.replace(/'/g, "'\\''")}'`;
3735
+ return [...new Set(segments.flatMap((original) => {
3736
+ const { seg } = unwrap(original, syntax);
3737
+ if (seg.binary.toLowerCase().replace(/\.exe$/, "") !== "wget") return [];
3738
+ if (seg.binaryPath !== void 0) return [void 0];
3739
+ const executable = seg.binary;
3740
+ if (original.cwd === void 0 && segments.some((s) => /^(cd|pushd|popd)$/i.test(s.binary))) return [void 0];
3741
+ if (syntax.escape === "^" && [...original.cwd ?? [], executable].some((word) => /["%&|<>^()!]/.test(word))) return [void 0];
3742
+ const navigation = (original.cwd ?? []).map((target) => `cd ${quote(target)} && `).join("");
3743
+ const env = syntax.escape === "^" ? 'set "XDG_DATA_HOME=/dev/null" && set "XDG_CONFIG_HOME=/dev/null" && set "LOCALAPPDATA=NUL" && ' : "XDG_DATA_HOME=/dev/null XDG_CONFIG_HOME=/dev/null LOCALAPPDATA=NUL ";
3744
+ return [`${navigation}${env}${quote(executable)} --no-config --version`];
3745
+ }))];
3746
+ }
3747
+
3333
3748
  // src/services/commandPolicy.ts
3334
3749
  var AUTO_COMMANDS = [
3335
3750
  "ls",
@@ -3683,7 +4098,7 @@ function pathRefs(command, opts = {}) {
3683
4098
  return lexAll(command.trim(), dialect, opts.cdpathSet).segments.flatMap((seg) => {
3684
4099
  const patterns = patternArgs(seg);
3685
4100
  const cwd = seg.cwd ?? [];
3686
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path26) => ({ path: path26, cwd }));
4101
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path31) => ({ path: path31, cwd }));
3687
4102
  });
3688
4103
  }
3689
4104
  function pathIn(a) {
@@ -3782,7 +4197,9 @@ function isAuto(seg, opts) {
3782
4197
  const sub = subcommandOf(seg);
3783
4198
  return sub !== void 0 && GIT_READONLY_SUBCOMMANDS.includes(sub);
3784
4199
  }
3785
- function refusalFor(seg) {
4200
+ function refusalFor(seg, trustedResolves) {
4201
+ const web = webCommand(seg, trustedResolves);
4202
+ if (web?.reason) return web.reason;
3786
4203
  if (seg.computedBinary !== void 0) {
3787
4204
  return `"${seg.computedBinary}" is a command name the shell computes as it runs, so this classifier cannot tell what would run. Name the program directly.`;
3788
4205
  }
@@ -3942,9 +4359,25 @@ function classifyCommand(command, opts = {}) {
3942
4359
  }
3943
4360
  if (segments.length === 0) return { tier: "refuse", scope: "", reason: "No command found." };
3944
4361
  const unwrapped = segments.map((seg) => unwrap(seg, dialect));
4362
+ const nestedWeb = segments.flatMap((seg, i) => seg.nested ? [webCommand(unwrapped[i].seg, opts.trustedWebResolves)] : []).find((web) => web);
4363
+ if (nestedWeb) {
4364
+ return {
4365
+ tier: "refuse",
4366
+ scope: "",
4367
+ reason: `A curl or wget command inside a substitution cannot have its startup files disabled safely. ${nestedWeb.effect ? EFFECT_REFUSAL_HINT : `Run it directly, or ${READ_REFUSAL_HINT.charAt(0).toLowerCase()}${READ_REFUSAL_HINT.slice(1)}`}`
4368
+ };
4369
+ }
3945
4370
  for (const { seg, wrappers, runner, reason: wrapperReason } of unwrapped) {
3946
4371
  if (wrapperReason) return { tier: "refuse", scope: "", reason: wrapperReason };
3947
- const reason = refusalFor({ ...seg, binary: seg.binary.toLowerCase() });
4372
+ const web = webCommand(seg, opts.trustedWebResolves);
4373
+ if (web && (opts.autonomous || opts.approvalMode === "deny")) {
4374
+ return { tier: "refuse", scope: "", reason: `curl and wget are refused in Full and allowlist mode. ${web.effect ? EFFECT_REFUSAL_HINT : READ_REFUSAL_HINT}` };
4375
+ }
4376
+ const allow = opts.allow;
4377
+ if (web && allow && web.urls.some((url) => scopeExcluded(allow, `${seg.binary.toLowerCase().replace(/\.exe$/, "")} ${url.origin}/*`))) {
4378
+ return { tier: "refuse", scope: "", reason: `The web origin is excluded by the planner allowlist. ${READ_REFUSAL_HINT}` };
4379
+ }
4380
+ const reason = refusalFor({ ...seg, binary: seg.binary.toLowerCase() }, opts.trustedWebResolves);
3948
4381
  if (reason) return { tier: "refuse", scope: "", reason: withWrapperNote(reason, wrappers) };
3949
4382
  if (runner && !XARGS_TARGETS.includes(seg.binary)) {
3950
4383
  return {
@@ -3954,22 +4387,49 @@ function classifyCommand(command, opts = {}) {
3954
4387
  };
3955
4388
  }
3956
4389
  }
4390
+ if (unwrapped.some(({ seg }) => seg.binary.toLowerCase().replace(/\.exe$/, "") === "wget") && wgetVersionCommands(command, opts.dialect).includes(void 0)) {
4391
+ return { tier: "refuse", scope: "", reason: `The wget executable cannot be checked safely in this working directory. ${READ_REFUSAL_HINT}` };
4392
+ }
3957
4393
  const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || u.wrappers.some((w) => w !== w.toLowerCase()) || !isAuto(u.seg, opts));
3958
4394
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
3959
- if (nonAuto.some((u) => {
4395
+ const lineScoped = nonAuto.some((u) => {
3960
4396
  const name = u.seg.binary.toLowerCase();
3961
4397
  return name === "cd" || name !== "command" && SHELL_STATE_COMMANDS.has(name);
3962
- })) {
4398
+ });
4399
+ const webUrls = nonAuto.flatMap((u) => webCommand(u.seg, opts.trustedWebResolves)?.urls ?? []);
4400
+ const webOutboundTexts = nonAuto.flatMap((u) => webCommand(u.seg, opts.trustedWebResolves)?.outboundTexts ?? []);
4401
+ if (lineScoped && webUrls.length === 0) {
3963
4402
  return { tier: "ask", scope: trimmed };
3964
4403
  }
3965
- const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3966
- const allowedBy = opts.allow ? allowlistRule(nonAuto, opts.allow) : void 0;
3967
- return { tier: "ask", scope, ...allowedBy ? { allowedBy } : {} };
4404
+ const scopes = [...new Set(nonAuto.flatMap((u) => {
4405
+ const web = webCommand(u.seg, opts.trustedWebResolves);
4406
+ return web ? web.urls.map((url) => `${u.seg.binary.toLowerCase().replace(/\.exe$/, "")} ${url.origin}/*`) : [lineScoped ? trimmed : u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)];
4407
+ }))].sort();
4408
+ const scope = scopes.join(" + ");
4409
+ const perInvocation = nonAuto.some((u) => webCommand(u.seg, opts.trustedWebResolves)?.perInvocation);
4410
+ const allowedBy = opts.allow && !perInvocation && !lineScoped ? allowlistRule(nonAuto, opts.allow, opts.trustedWebResolves) : void 0;
4411
+ return {
4412
+ tier: "ask",
4413
+ scope,
4414
+ ...webUrls.length ? { webUrls, webOutboundTexts } : {},
4415
+ ...scopes.length > 1 && webUrls.length ? { scopes } : {},
4416
+ ...perInvocation ? { perInvocation } : {},
4417
+ ...allowedBy ? { allowedBy } : {}
4418
+ };
3968
4419
  }
3969
- function allowlistRule(parts, allow) {
4420
+ function allowlistRule(parts, allow, trustedResolves) {
3970
4421
  const rules = [];
3971
4422
  for (const { seg, runner } of parts) {
3972
4423
  if (runner || seg.expandable || seg.globbed || seg.binaryPath !== void 0 || seg.assignments.length > 0) return void 0;
4424
+ const web = webCommand(seg, trustedResolves);
4425
+ if (web) {
4426
+ for (const url of web.urls) {
4427
+ const rule2 = commandAllowedBy(allow, { binary: seg.binary, args: [`${url.origin}/*`], scopeWords: 1 });
4428
+ if (!rule2) return void 0;
4429
+ rules.push(rule2);
4430
+ }
4431
+ continue;
4432
+ }
3973
4433
  const rule = commandAllowedBy(allow, { binary: seg.binary, args: seg.args, scopeWords: scopeFor(seg).split(" ").length - 1 });
3974
4434
  if (!rule) return void 0;
3975
4435
  rules.push(rule);
@@ -4140,9 +4600,62 @@ function referencePattern(symbol) {
4140
4600
  return `\\b${escapeRegex(symbol)}\\b`;
4141
4601
  }
4142
4602
 
4603
+ // src/services/outboundSecrets.ts
4604
+ var SENSITIVE_ENV_NAME = /(?:^|_)(?:KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:_|$)/i;
4605
+ var MIN_SECRET_LENGTH = 8;
4606
+ var MAX_DECODE_ROUNDS = 3;
4607
+ var TOKEN_SHAPES = [
4608
+ /\bgh[pousr]_[A-Za-z0-9]{36,}/,
4609
+ /\bgithub_pat_[A-Za-z0-9_]{22,}/,
4610
+ /\bAIza[A-Za-z0-9_-]{30,}/,
4611
+ /\bglpat-[A-Za-z0-9_-]{20,}/,
4612
+ /\b[sr]k_live_[A-Za-z0-9]{16,}/,
4613
+ /\b(?:hf|npm)_[A-Za-z0-9]{20,}/,
4614
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
4615
+ /\bxox[abpr]-[A-Za-z0-9-]{10,}/,
4616
+ /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{10,}/,
4617
+ /-----BEGIN [A-Z0-9 ]+-----/
4618
+ ];
4619
+ var SK_KEY = /\bsk-[A-Za-z0-9_-]{20,}/g;
4620
+ function looksRandom(tail) {
4621
+ return /[a-z]/.test(tail) && /[A-Z]/.test(tail) && /\d/.test(tail);
4622
+ }
4623
+ function percentDecode(text) {
4624
+ return text.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
4625
+ try {
4626
+ return decodeURIComponent(run);
4627
+ } catch {
4628
+ return run;
4629
+ }
4630
+ });
4631
+ }
4632
+ function forms(text) {
4633
+ const seen = [text, text.replace(/\+/g, " ")];
4634
+ let current = text;
4635
+ for (let round = 0; round < MAX_DECODE_ROUNDS; round++) {
4636
+ const next = percentDecode(current);
4637
+ if (next === current) break;
4638
+ seen.push(next, next.replace(/\+/g, " "));
4639
+ current = next;
4640
+ }
4641
+ return seen;
4642
+ }
4643
+ function sensitiveEnvValues(env) {
4644
+ return Object.entries(env).filter(([name, value]) => SENSITIVE_ENV_NAME.test(name) && !/_(?:URL|URI|PATH|FILE|DIR)$/i.test(name) && value !== void 0).map(([, value]) => (value ?? "").trim());
4645
+ }
4646
+ function carriesSecret(texts, held) {
4647
+ const secrets = [...held].map((s) => s.trim()).filter((s) => s.length >= MIN_SECRET_LENGTH);
4648
+ return texts.flatMap(forms).some((text) => secrets.some((secret) => text.includes(secret)) || TOKEN_SHAPES.some((shape) => shape.test(text)) || [...text.matchAll(SK_KEY)].some((m) => looksRandom(m[0].slice(3))));
4649
+ }
4650
+
4143
4651
  // src/services/BaseFileSystem.ts
4144
4652
  var BaseFileSystem = class {
4653
+ constructor(resolveWebHost = resolveAll) {
4654
+ this.resolveWebHost = resolveWebHost;
4655
+ }
4656
+ resolveWebHost;
4145
4657
  approval = DENY_ALL;
4658
+ outboundSecrets = () => [];
4146
4659
  /**
4147
4660
  * The interpreter `execBashImpl` will hand the command to. Owned here rather
4148
4661
  * than per-adapter because {@link classifyCommand} has to be told the same
@@ -4154,6 +4667,9 @@ var BaseFileSystem = class {
4154
4667
  setApproval(approval) {
4155
4668
  this.approval = approval;
4156
4669
  }
4670
+ setOutboundSecrets(secrets) {
4671
+ this.outboundSecrets = secrets;
4672
+ }
4157
4673
  /**
4158
4674
  * Resolve `p` and confirm the planner may touch it. In-workspace paths pass
4159
4675
  * silently; anything else needs one approval, remembered per containing
@@ -4290,19 +4806,34 @@ ${refs.output.trim()}`);
4290
4806
  * directory the next command names.
4291
4807
  */
4292
4808
  async bash(command, signal) {
4809
+ command = disableWebCommandStartup(command, this.researchShell.dialect);
4293
4810
  const allow = this.approval.allowlist?.();
4294
- const { tier, scope, reason, allowedBy } = classifyCommand(command, { ...this.policyOptions(), ...allow ? { allow } : {} });
4811
+ const { tier, scope, reason, allowedBy, perInvocation, scopes: commandScopes, webUrls, webOutboundTexts } = classifyCommand(command, {
4812
+ ...this.policyOptions(),
4813
+ ...this.approval.commandPolicy?.(),
4814
+ ...allow ? { allow } : {}
4815
+ });
4295
4816
  if (tier === "refuse") {
4296
4817
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
4297
4818
  }
4819
+ if (webUrls?.length && carriesSecret([...webOutboundTexts ?? [], ...webUrls.map((url) => url.href)], [...this.outboundSecrets(), ...sensitiveEnvValues(process.env)])) {
4820
+ return { success: false, output: "Command refused: a web command contains what looks like a secret. Fetch the page with your web fetch tool, without credentials.", truncated: false };
4821
+ }
4822
+ const vettedAddresses = /* @__PURE__ */ new Map();
4823
+ for (const url of webUrls ?? []) {
4824
+ const check2 = await vetEgress(url.hostname, url.port, this.resolveWebHost);
4825
+ if (!check2.ok) return { success: false, output: `Command refused: Blocked host: ${check2.reason}. ${READ_REFUSAL_HINT}`, truncated: false };
4826
+ vettedAddresses.set(url.origin, check2.addresses[0]);
4827
+ }
4298
4828
  const outside = this.outsidePaths(command);
4299
4829
  const asks = tier === "ask";
4300
4830
  if (asks || outside.length > 0) {
4301
4831
  const root = this.getWorkspaceRoot();
4302
- const scopes = [...asks ? [scope] : [], ...outside.map((o) => o.scope)];
4832
+ const scopes = [...asks ? commandScopes ?? [scope] : [], ...outside.map((o) => o.scope)];
4303
4833
  const touched = outside.map((o) => o.abs).join(", ");
4304
4834
  const granted = await this.approval.request({
4305
4835
  kind: asks ? "shell_command" : "external_path",
4836
+ ...perInvocation ? { perInvocation } : {},
4306
4837
  subject: asks ? command : outside[0].abs,
4307
4838
  scope: scopes[0],
4308
4839
  ...scopes.length > 1 ? { scopes } : {},
@@ -4318,6 +4849,23 @@ It is outside the auto-allowed read-only set and the planner allowlist (${scope}
4318
4849
  };
4319
4850
  }
4320
4851
  }
4852
+ if (webUrls?.length) {
4853
+ const latest = classifyCommand(command, { ...this.policyOptions(), ...this.approval.commandPolicy?.(), allow: this.approval.allowlist?.() });
4854
+ if (latest.tier === "refuse") return { success: false, output: `Command refused: ${latest.reason}`, truncated: false };
4855
+ }
4856
+ for (const probe of wgetVersionCommands(command, this.researchShell.dialect)) {
4857
+ if (!probe) return { success: false, output: `Command refused: the wget executable cannot be checked in an uncertain working directory. ${READ_REFUSAL_HINT}`, truncated: false };
4858
+ const version = await this.execBashImpl(probe, signal);
4859
+ if (!version.success || !/^GNU Wget 1\./.test(version.output)) {
4860
+ return { success: false, output: `Command refused: only GNU Wget 1.x is supported; wget2 can write persistent databases. ${READ_REFUSAL_HINT}`, truncated: false };
4861
+ }
4862
+ }
4863
+ const pinned = pinWebCommandAddresses(command, vettedAddresses, this.researchShell.dialect);
4864
+ command = pinned.command;
4865
+ if (webUrls?.length) {
4866
+ const latest = classifyCommand(command, { ...this.policyOptions(), ...this.approval.commandPolicy?.(), allow: this.approval.allowlist?.(), trustedWebResolves: pinned.resolves });
4867
+ if (latest.tier === "refuse") return { success: false, output: `Command refused: ${latest.reason}`, truncated: false };
4868
+ }
4321
4869
  const outcome = await this.execBashImpl(command, signal);
4322
4870
  if (outcome.success) return outcome;
4323
4871
  const warning = researchShellWarning(this.researchShell);
@@ -4514,10 +5062,7 @@ async function runTool(name, args, fs17, fetcher, signal) {
4514
5062
  return fs17.bash(String(args.command ?? ""), signal);
4515
5063
  case "fetch": {
4516
5064
  if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
4517
- const url = String(args.url ?? "");
4518
- const allowed = await fetcher.confirm(url);
4519
- if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
4520
- return fetcher.fetch(url);
5065
+ return fetcher.fetch(String(args.url ?? ""));
4521
5066
  }
4522
5067
  case "web_search": {
4523
5068
  if (!fetcher?.search) {
@@ -4541,9 +5086,11 @@ async function runTool(name, args, fs17, fetcher, signal) {
4541
5086
  }
4542
5087
 
4543
5088
  // src/services/ApprovalPolicy.ts
4544
- function effectiveApprovalMode(setting, autonomous) {
5089
+ var FULL_ALLOWS = /* @__PURE__ */ new Set(["url_fetch", "web_search"]);
5090
+ function effectiveApprovalMode(setting, autonomous, kind) {
4545
5091
  if (setting !== "auto") return setting;
4546
- return autonomous ? "deny" : "ask";
5092
+ if (!autonomous) return "ask";
5093
+ return kind !== void 0 && FULL_ALLOWS.has(kind) ? "allow" : "deny";
4547
5094
  }
4548
5095
  function parseApprovalModeSetting(raw) {
4549
5096
  const value = (raw ?? "").trim().toLowerCase();
@@ -4552,6 +5099,7 @@ function parseApprovalModeSetting(raw) {
4552
5099
  }
4553
5100
  var ApprovalPolicy = class {
4554
5101
  modeOf;
5102
+ autonomousOf;
4555
5103
  entriesOf;
4556
5104
  parsed = null;
4557
5105
  asker;
@@ -4567,6 +5115,7 @@ var ApprovalPolicy = class {
4567
5115
  constructor(opts = {}) {
4568
5116
  const { mode = "ask", preApproved = [] } = opts;
4569
5117
  this.modeOf = typeof mode === "function" ? mode : () => mode;
5118
+ this.autonomousOf = opts.autonomous ?? (() => false);
4570
5119
  this.entriesOf = typeof preApproved === "function" ? preApproved : () => preApproved;
4571
5120
  this.asker = opts.ask;
4572
5121
  this.onDecision = opts.onDecision;
@@ -4576,20 +5125,20 @@ var ApprovalPolicy = class {
4576
5125
  this.onDecision?.(req, granted, source);
4577
5126
  return granted;
4578
5127
  };
4579
- const scopes = approvalScopes(req);
4580
- const key = scopes.join("\n");
4581
5128
  const list = this.allowlist();
4582
- const preApproved = (scope, i) => scopeAllowed(list, scope) || i === 0 && (req.kind === "shell_command" && req.allowedBy !== void 0 || req.kind === "mcp_tool" && req.tool !== void 0 && toolAllowed(list, req.tool));
4583
- const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
5129
+ if (approvalScopes(req).some((scope) => scopeExcluded(list, scope))) return decide(false, "excluded");
5130
+ if (req.perInvocation) {
5131
+ if (this.modeOf(req.kind) === "deny" || this.autonomousOf()) return decide(false, "mode");
5132
+ if (!this.asker) return decide(false, "no-channel");
5133
+ return decide(await this.asker(req).catch(() => false), "asked");
5134
+ }
5135
+ const { scopes, key, open } = this.standing(req);
4584
5136
  if (open.length === 0) {
4585
5137
  return decide(true, scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved");
4586
5138
  }
4587
5139
  if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
4588
- const mode = this.modeOf();
4589
- if (mode === "allow") {
4590
- open.forEach((s) => this.granted.add(s));
4591
- return decide(true, "mode");
4592
- }
5140
+ const mode = this.modeOf(req.kind);
5141
+ if (mode === "allow") return decide(true, "mode");
4593
5142
  if (mode === "deny") return decide(false, "mode");
4594
5143
  if (!this.asker) return decide(false, "no-channel");
4595
5144
  const openKey = open.join("\n");
@@ -4608,6 +5157,44 @@ var ApprovalPolicy = class {
4608
5157
  }
4609
5158
  return decide(answer2, "asked");
4610
5159
  }
5160
+ commandPolicy() {
5161
+ return { autonomous: this.autonomousOf(), approvalMode: this.modeOf("shell_command") };
5162
+ }
5163
+ /**
5164
+ * What `request` would answer without a person, for a caller that may never
5165
+ * prompt (a research subagent): the same standing grants, remembered
5166
+ * refusals and mode the interactive path uses, stopping at `ask`.
5167
+ */
5168
+ peek(req) {
5169
+ const list = this.allowlist();
5170
+ if (approvalScopes(req).some((scope) => scopeExcluded(list, scope))) return { decision: "deny", source: "excluded" };
5171
+ const { scopes, key, open } = this.standing(req);
5172
+ if (open.length === 0) return { decision: "allow", source: scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved" };
5173
+ if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return { decision: "deny", source: "remembered" };
5174
+ const mode = this.modeOf(req.kind);
5175
+ if (mode === "allow") return { decision: "allow", source: "mode" };
5176
+ if (mode === "deny") return { decision: "deny", source: "mode" };
5177
+ return { decision: "ask", source: "no-channel" };
5178
+ }
5179
+ standing(req) {
5180
+ const scopes = approvalScopes(req);
5181
+ const key = scopes.join("\n");
5182
+ const list = this.allowlist();
5183
+ const preApproved = (scope, i) => scopeAllowed(list, scope) || i === 0 && (req.kind === "shell_command" && req.allowedBy !== void 0 || req.kind === "mcp_tool" && req.tool !== void 0 && toolAllowed(list, req.tool));
5184
+ const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
5185
+ return { scopes, key, open };
5186
+ }
5187
+ /** Report a decision a guard reached before this policy was asked. */
5188
+ record(req, granted, source) {
5189
+ this.onDecision?.(req, granted, source);
5190
+ }
5191
+ grantsUnasked({ kind, scope }) {
5192
+ const list = this.allowlist();
5193
+ if (scopeExcluded(list, scope)) return false;
5194
+ if (this.granted.has(scope) || scopeAllowed(list, scope)) return true;
5195
+ if (this.refused.has(scope)) return false;
5196
+ return this.modeOf(kind) === "allow";
5197
+ }
4611
5198
  /** The standing approvals, parsed from the entries in force now. */
4612
5199
  allowlist() {
4613
5200
  const entries = this.entriesOf();
@@ -4628,8 +5215,582 @@ var ApprovalPolicy = class {
4628
5215
  }
4629
5216
  };
4630
5217
 
5218
+ // src/services/webReadPolicy.ts
5219
+ var DEFAULT_WEB_READ_BUDGET = 40;
5220
+ var ALLOWED = { allowed: true };
5221
+ var WebReadPolicy = class {
5222
+ approval;
5223
+ resolveHost;
5224
+ secrets;
5225
+ env;
5226
+ budget;
5227
+ turn;
5228
+ spent = { turn: void 0, reads: 0 };
5229
+ constructor(opts) {
5230
+ this.approval = opts.approval;
5231
+ this.secrets = opts.secrets ?? (() => []);
5232
+ this.env = opts.env ?? (() => process.env);
5233
+ this.budget = opts.budget ?? (() => DEFAULT_WEB_READ_BUDGET);
5234
+ this.turn = opts.turn ?? (() => void 0);
5235
+ this.resolveHost = opts.resolveHost ?? resolveAll;
5236
+ }
5237
+ /**
5238
+ * Every check that needs no network runs first, then the budget and the
5239
+ * approval, and only then is a fetch's host resolved: a lookup is itself a
5240
+ * request, so `<data>.attacker.example` would leave through DNS even for a
5241
+ * read that is then excluded, over budget or declined.
5242
+ */
5243
+ async decide(read2, opts = {}) {
5244
+ const target = read2.kind === "fetch" ? this.vetFetch(read2.url) : this.vetSearch(read2);
5245
+ const { request, label } = target;
5246
+ if (target.refused) return this.refuse(request, target.refused.source, target.refused.reason);
5247
+ const list = this.approval.allowlist?.();
5248
+ if (list && scopeExcluded(list, request.scope)) {
5249
+ return this.refuse(request, "excluded", `${label} refused: ${request.scope} is excluded by the planner allowlist. Continue without it.`);
5250
+ }
5251
+ const spent = this.spend();
5252
+ if (!spent) {
5253
+ return this.refuse(request, "budget", `${label} refused: this planner turn has used its web-read budget (${this.budget()}). Continue with what you have, and tell the user that more reading would need a higher budget.`);
5254
+ }
5255
+ if (opts.prompting === false) {
5256
+ const standing = this.approval.peek?.(request);
5257
+ if (standing?.decision === "allow") {
5258
+ this.approval.record?.(request, true, standing.source);
5259
+ return this.resolved(target, spent.turn);
5260
+ }
5261
+ this.refund(spent.turn);
5262
+ return this.subagentRefusal(read2, request, standing?.source ?? "no-channel");
5263
+ }
5264
+ if (await this.approval.request(request)) return this.resolved(target, spent.turn);
5265
+ this.refund(spent.turn);
5266
+ return { allowed: false, reason: read2.kind === "fetch" ? `Fetch not approved: ${request.scope}. Continue without it, or ask the user to approve this origin or add it to the planner allowlist.` : "Web search not approved. Continue without it, or fetch a documentation URL you already know." };
5267
+ }
5268
+ subagentRefusal(read2, request, source) {
5269
+ const label = read2.kind === "fetch" ? "Fetch" : "Web search";
5270
+ if (source === "mode") {
5271
+ return this.refuse(request, source, `${label} refused: ${request.scope} is not covered by the planner allowlist in allowlist mode. Note the gap in your digest; the user must add a standing approval or change the approval mode.`);
5272
+ }
5273
+ if (source === "remembered") {
5274
+ return this.refuse(request, source, `${label} refused: ${request.scope} was previously refused in this session. Note the gap in your digest; the user must add a standing approval or start a new session.`);
5275
+ }
5276
+ this.approval.record?.(request, false, source);
5277
+ return { allowed: false, reason: read2.kind === "fetch" ? `Fetch refused: ${request.scope} is not approved in this session, and research agents run in the background where nothing can be approved. Note the gap in your digest; the planner can fetch it directly.` : "Web search refused: research agents run in the background where nothing can be approved. Note the gap in your digest; the planner can search directly." };
5278
+ }
5279
+ /**
5280
+ * Whether a search through `provider` would run without asking anyone, for
5281
+ * a runner whose own search raises no request and is set on or off when
5282
+ * its session starts (ADR-0027). A zero budget disables it; no query is
5283
+ * known yet, so secrets and individual reads cannot be checked or counted.
5284
+ */
5285
+ searchesUnasked(provider) {
5286
+ if (this.budget() === 0) return false;
5287
+ const scope = searchScope(provider);
5288
+ const list = this.approval.allowlist?.();
5289
+ if (list && scopeExcluded(list, scope)) return false;
5290
+ return this.approval.grantsUnasked?.({ kind: "web_search", scope }) ?? false;
5291
+ }
5292
+ /** The last check, the one that leaves the machine: every DNS answer for the host must be public. */
5293
+ async resolved(target, turn) {
5294
+ if (!target.resolve) return ALLOWED;
5295
+ const egress = await vetEgress(target.resolve.hostname, target.resolve.port, this.resolveHost);
5296
+ if (egress.ok) return ALLOWED;
5297
+ this.refund(turn);
5298
+ return this.refuse(target.request, "guard", guardRefusal(egress.reason));
5299
+ }
5300
+ refuse(request, source, reason) {
5301
+ this.approval.record?.(request, false, source);
5302
+ return { allowed: false, reason };
5303
+ }
5304
+ vetFetch(raw) {
5305
+ const label = "Fetch";
5306
+ const request = (subject, scope, origin = scope) => ({ kind: "url_fetch", subject, scope, detail: `Planner research wants to fetch ${origin}` });
5307
+ if (this.carriesSecret([raw])) {
5308
+ return { label, request: request(WITHHELD, WITHHELD), refused: { source: "secret", reason: secretRefusal(label, "URL") } };
5309
+ }
5310
+ let url;
5311
+ try {
5312
+ url = new URL(raw);
5313
+ } catch {
5314
+ return { label, request: request(raw, raw), refused: { source: "guard", reason: "Fetch refused: not a valid URL." } };
5315
+ }
5316
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
5317
+ return { label, request: request(raw, raw), refused: { source: "guard", reason: "Fetch refused: only http and https URLs can be fetched." } };
5318
+ }
5319
+ const fetchRequest = request(raw, `${url.origin}/*`, url.origin);
5320
+ const egress = vetEgressUnresolved(url.hostname, url.port);
5321
+ if (egress && !egress.ok) {
5322
+ return { label, request: fetchRequest, refused: { source: "guard", reason: guardRefusal(egress.reason) } };
5323
+ }
5324
+ return { label, request: fetchRequest, ...egress ? {} : { resolve: { hostname: url.hostname, port: url.port } } };
5325
+ }
5326
+ vetSearch({ query, provider, providerLabel, note }) {
5327
+ const label = "Web search";
5328
+ const detail = `Planner research wants to search the web with ${providerLabel ?? provider}${providerLabel ? "" : ", a third party that receives the query"}${note ? `. ${note}` : ""}`;
5329
+ const request = (subject) => ({ kind: "web_search", subject, scope: searchScope(provider), detail });
5330
+ if (this.carriesSecret([query])) {
5331
+ return { label, request: request(WITHHELD), refused: { source: "secret", reason: secretRefusal(label, "query") } };
5332
+ }
5333
+ return { label, request: request(query) };
5334
+ }
5335
+ spend() {
5336
+ const turn = this.turn();
5337
+ if (this.spent.turn !== turn) this.spent = { turn, reads: 0 };
5338
+ if (this.spent.reads >= this.budget()) return void 0;
5339
+ this.spent.reads += 1;
5340
+ return { turn };
5341
+ }
5342
+ refund(turn) {
5343
+ if (this.spent.turn === turn && this.spent.reads > 0) this.spent.reads -= 1;
5344
+ }
5345
+ carriesSecret(texts) {
5346
+ return carriesSecret(texts, [...this.secrets(), ...sensitiveEnvValues(this.env())]);
5347
+ }
5348
+ };
5349
+ var WITHHELD = "(withheld: it contains a secret)";
5350
+ function searchScope(provider) {
5351
+ return `web_search:${provider}`;
5352
+ }
5353
+ function guardRefusal(reason) {
5354
+ return `Fetch refused: ${reason}. Private, local and non-web addresses are never fetched.`;
5355
+ }
5356
+ function secretRefusal(label, what) {
5357
+ return `${label} refused: the ${what} contains what looks like a secret (an API key, token or credential). Never put credentials in a ${what}; continue without it.`;
5358
+ }
5359
+
5360
+ // src/services/DuckDuckGoSearchProvider.ts
5361
+ var SEARCH_ENDPOINT = "https://lite.duckduckgo.com/lite/?q=";
5362
+ var COOLDOWN_BASE_MS = 3e4;
5363
+ var COOLDOWN_MAX_MS = 5 * 6e4;
5364
+ var CHALLENGE_MARKERS = [
5365
+ /<form\b[^>]*\bid\s*=\s*["']challenge-form["']/i,
5366
+ /<form\b[^>]*\baction\s*=\s*["'][^"']*\/anomaly\.js\b/i,
5367
+ /<div\b[^>]*\bclass\s*=\s*["'][^"']*\banomaly-modal/i
5368
+ ];
5369
+ function looksLikeChallenge(html) {
5370
+ return CHALLENGE_MARKERS.some((marker2) => marker2.test(html));
5371
+ }
5372
+ function decodeEntities(text) {
5373
+ return text.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))).replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5374
+ }
5375
+ function plainText(html) {
5376
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
5377
+ }
5378
+ function attributesOf(tag) {
5379
+ const attrs = /* @__PURE__ */ new Map();
5380
+ const pattern = /([a-z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
5381
+ for (let m = pattern.exec(tag); m; m = pattern.exec(tag)) {
5382
+ attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
5383
+ }
5384
+ return attrs;
5385
+ }
5386
+ var RESULT_LINK_CLASS = /(?:^|\s)result(?:-link|__a)(?:\s|$)/;
5387
+ var SNIPPET_ELEMENT = /<(\w+)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bresult(?:-snippet|__snippet)\b)[^>]*>([\s\S]*?)<\/\1>/i;
5388
+ function resolveResultUrl(href) {
5389
+ let url;
5390
+ try {
5391
+ url = new URL(href, "https://duckduckgo.com/");
5392
+ } catch {
5393
+ return void 0;
5394
+ }
5395
+ if (url.hostname === "duckduckgo.com" || url.hostname.endsWith(".duckduckgo.com")) {
5396
+ const target = url.pathname === "/l/" ? url.searchParams.get("uddg") : null;
5397
+ if (!target) return void 0;
5398
+ try {
5399
+ url = new URL(target);
5400
+ } catch {
5401
+ return void 0;
5402
+ }
5403
+ }
5404
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : void 0;
5405
+ }
5406
+ function parseSearchHits(html) {
5407
+ const links = [];
5408
+ const anchor = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
5409
+ for (let m = anchor.exec(html); m; m = anchor.exec(html)) {
5410
+ const attrs = attributesOf(m[1]);
5411
+ const href = attrs.get("href");
5412
+ if (!href || !RESULT_LINK_CLASS.test(attrs.get("class") ?? "")) continue;
5413
+ links.push({ start: m.index, end: m.index + m[0].length, href: decodeEntities(href), title: plainText(m[2]) });
5414
+ }
5415
+ const hits = [];
5416
+ links.forEach((link, i) => {
5417
+ const url = resolveResultUrl(link.href);
5418
+ if (!url || !link.title) return;
5419
+ const region = html.slice(link.end, links[i + 1]?.start ?? html.length);
5420
+ hits.push({ title: link.title, url, snippet: snippetIn(region) });
5421
+ });
5422
+ return hits;
5423
+ }
5424
+ function snippetIn(region) {
5425
+ const match = SNIPPET_ELEMENT.exec(region);
5426
+ return match ? plainText(match[2]) || void 0 : void 0;
5427
+ }
5428
+ var DuckDuckGoSearchProvider = class {
5429
+ id = "duckduckgo";
5430
+ name = "DuckDuckGo";
5431
+ failures = 0;
5432
+ cooldownUntil = 0;
5433
+ now;
5434
+ constructor(now = Date.now) {
5435
+ this.now = now;
5436
+ }
5437
+ coolingDown() {
5438
+ return this.now() < this.cooldownUntil;
5439
+ }
5440
+ async search(query, get) {
5441
+ const page = await get(SEARCH_ENDPOINT + encodeURIComponent(query));
5442
+ if (!page.ok) {
5443
+ if (page.status === 429 || page.status === 403) return this.block(`HTTP ${page.status}`);
5444
+ return { kind: "error", error: page.error };
5445
+ }
5446
+ if (page.status === 202) return this.block("HTTP 202");
5447
+ const hits = parseSearchHits(page.body).slice(0, 10);
5448
+ if (hits.length === 0) return looksLikeChallenge(page.body) ? this.block("a challenge page") : { kind: "none" };
5449
+ this.failures = 0;
5450
+ this.cooldownUntil = 0;
5451
+ return { kind: "hits", hits };
5452
+ }
5453
+ block(reason) {
5454
+ if (!this.coolingDown()) {
5455
+ this.failures += 1;
5456
+ const backoff = Math.min(COOLDOWN_BASE_MS * 2 ** (this.failures - 1), COOLDOWN_MAX_MS);
5457
+ this.cooldownUntil = this.now() + backoff;
5458
+ }
5459
+ return { kind: "blocked", reason };
5460
+ }
5461
+ };
5462
+ var sharedSearchProvider = new DuckDuckGoSearchProvider();
5463
+
5464
+ // src/services/WebResponseCache.ts
5465
+ var DEFAULT_TTL_MS = 5 * 6e4;
5466
+ var DEFAULT_MAX_ENTRIES = 100;
5467
+ var DEFAULT_MAX_BYTES = 2e7;
5468
+ var WebResponseCache = class {
5469
+ entries = /* @__PURE__ */ new Map();
5470
+ ttlMs;
5471
+ maxEntries;
5472
+ maxBytes;
5473
+ now;
5474
+ bytes = 0;
5475
+ constructor(opts = {}) {
5476
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
5477
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
5478
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
5479
+ this.now = opts.now ?? Date.now;
5480
+ }
5481
+ getFetch(url) {
5482
+ return this.get(`get\0${url}`);
5483
+ }
5484
+ /** `maxAgeMs` is the response's own freshness; it shortens the TTL, never extends it. */
5485
+ setFetch(url, page, maxAgeMs = Infinity) {
5486
+ this.set(`get\0${url}`, page, Buffer.byteLength(page.body), maxAgeMs);
5487
+ }
5488
+ getSearch(providerAndQuery) {
5489
+ return this.get(`search\0${providerAndQuery}`);
5490
+ }
5491
+ setSearch(providerAndQuery, hits) {
5492
+ const bytes = hits.reduce((sum, h) => sum + Buffer.byteLength(h.title) + Buffer.byteLength(h.url) + Buffer.byteLength(h.snippet ?? ""), 0);
5493
+ this.set(`search\0${providerAndQuery}`, hits, bytes, Infinity);
5494
+ }
5495
+ get(key) {
5496
+ const entry = this.entries.get(key);
5497
+ if (!entry) return void 0;
5498
+ if (entry.expiresAt <= this.now()) {
5499
+ this.delete(key);
5500
+ return void 0;
5501
+ }
5502
+ this.entries.delete(key);
5503
+ this.entries.set(key, entry);
5504
+ return entry.value;
5505
+ }
5506
+ set(key, value, bytes, maxAgeMs) {
5507
+ this.delete(key);
5508
+ if (bytes > this.maxBytes) return;
5509
+ this.entries.set(key, { value, bytes, expiresAt: this.now() + Math.min(this.ttlMs, maxAgeMs) });
5510
+ this.bytes += bytes;
5511
+ while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
5512
+ const oldest = this.entries.keys().next().value;
5513
+ if (oldest === void 0) break;
5514
+ this.delete(oldest);
5515
+ }
5516
+ }
5517
+ delete(key) {
5518
+ const entry = this.entries.get(key);
5519
+ if (!entry) return;
5520
+ this.entries.delete(key);
5521
+ this.bytes -= entry.bytes;
5522
+ }
5523
+ };
5524
+ var sharedWebResponseCache = new WebResponseCache();
5525
+
5526
+ // src/services/HttpWebFetcher.ts
5527
+ import * as http from "node:http";
5528
+ import * as https from "node:https";
5529
+ import { pipeline } from "node:stream";
5530
+ import * as zlib from "node:zlib";
5531
+ var DEFAULT_MAX_CHARS = 4e4;
5532
+ var DEFAULT_TIMEOUT_MS = 2e4;
5533
+ var MAX_BODY_BYTES = 2e6;
5534
+ var MAX_REDIRECTS = 5;
5535
+ var USER_AGENT = "Mozilla/5.0 (compatible; Ordewell-Planner/1.0; +https://github.com/ordewell)";
5536
+ var SEARCH_COOLDOWN_OUTPUT = "Web search is temporarily unavailable: the search provider backed off after pushing back on automated queries. Do not retry now; fetch a documentation URL you already know instead.";
5537
+ function decodeEntities2(text) {
5538
+ return text.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'");
5539
+ }
5540
+ function htmlToText(html) {
5541
+ return decodeEntities2(
5542
+ html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
5543
+ ).replace(/[ \t\f\v]+/g, " ").replace(/\n\s*\n\s*\n+/g, "\n\n").split("\n").map((line) => line.trim()).join("\n").trim();
5544
+ }
5545
+ function formatHits(hits) {
5546
+ const output = hits.map((hit, i) => `${i + 1}. ${hit.title}
5547
+ ${hit.url}${hit.snippet ? `
5548
+ ${hit.snippet}` : ""}`).join("\n\n");
5549
+ return { success: true, output, truncated: false };
5550
+ }
5551
+ function sanitizeError(err) {
5552
+ const msg = err instanceof Error ? err.message : String(err);
5553
+ if (/abort/i.test(msg)) return "timed out";
5554
+ if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return "host not reachable";
5555
+ if (/ENOTFOUND|getaddrinfo/i.test(msg)) return "host not found";
5556
+ return msg.replace(/https?:\/\/\S+/g, "<url>");
5557
+ }
5558
+ function sharedLifetime(cacheControl, setCookie) {
5559
+ if (setCookie || /(?:^|,)\s*(?:no-store|private|no-cache)\b/i.test(cacheControl)) return 0;
5560
+ const ages = [...cacheControl.matchAll(/(?:^|,)\s*(?:s-)?max-age\s*=\s*"?(\d+)/gi)].map((m) => Number(m[1]) * 1e3);
5561
+ return ages.length ? Math.min(...ages) : Infinity;
5562
+ }
5563
+ var HttpWebFetcher = class _HttpWebFetcher {
5564
+ policy;
5565
+ fetchImpl;
5566
+ maxChars;
5567
+ timeoutMs;
5568
+ resolveHost;
5569
+ cache;
5570
+ searchProvider;
5571
+ prompting;
5572
+ constructor(opts) {
5573
+ this.policy = opts.policy;
5574
+ this.maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
5575
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5576
+ this.resolveHost = opts.resolveHost ?? resolveAll;
5577
+ this.fetchImpl = opts.fetchImpl ?? pinnedTransport(guardedLookup(this.resolveHost));
5578
+ this.cache = opts.cache ?? new WebResponseCache();
5579
+ this.searchProvider = opts.searchProvider ?? new DuckDuckGoSearchProvider();
5580
+ this.prompting = opts.prompting ?? true;
5581
+ }
5582
+ decide(read2) {
5583
+ return this.policy.decide(read2, this.decideOptions);
5584
+ }
5585
+ get decideOptions() {
5586
+ return { prompting: this.prompting };
5587
+ }
5588
+ forSubagent() {
5589
+ return new _HttpWebFetcher({
5590
+ policy: this.policy,
5591
+ fetchImpl: this.fetchImpl,
5592
+ maxChars: this.maxChars,
5593
+ timeoutMs: this.timeoutMs,
5594
+ resolveHost: this.resolveHost,
5595
+ cache: this.cache,
5596
+ searchProvider: this.searchProvider,
5597
+ prompting: false
5598
+ });
5599
+ }
5600
+ searchesUnasked(provider) {
5601
+ return this.policy.searchesUnasked?.(provider) ?? false;
5602
+ }
5603
+ async fetch(url) {
5604
+ const decision = await this.policy.decide({ kind: "fetch", url }, this.decideOptions);
5605
+ if (!decision.allowed) return { success: false, output: decision.reason, truncated: false };
5606
+ return this.get(url, "Fetch");
5607
+ }
5608
+ async search(query) {
5609
+ const provider = this.searchProvider;
5610
+ if (provider.coolingDown()) return { success: false, output: SEARCH_COOLDOWN_OUTPUT, truncated: false };
5611
+ const decision = await this.policy.decide({ kind: "search", query, provider: provider.id }, this.decideOptions);
5612
+ if (!decision.allowed) return { success: false, output: decision.reason, truncated: false };
5613
+ const cacheKey = `${provider.id}\0${query}`;
5614
+ const cached2 = this.cache.getSearch(cacheKey);
5615
+ if (cached2) return formatHits(cached2);
5616
+ const outcome = await provider.search(query, (url) => this.getSearchPage(url));
5617
+ switch (outcome.kind) {
5618
+ case "hits":
5619
+ this.cache.setSearch(cacheKey, outcome.hits);
5620
+ return formatHits(outcome.hits);
5621
+ case "none":
5622
+ return {
5623
+ success: false,
5624
+ output: `No results could be parsed for "${query}". Search is best-effort; if you already know a documentation URL, use the fetch tool directly.`,
5625
+ truncated: false
5626
+ };
5627
+ case "blocked":
5628
+ return { success: false, output: SEARCH_COOLDOWN_OUTPUT, truncated: false };
5629
+ case "error":
5630
+ return { success: false, output: `Web search failed: ${outcome.error}`, truncated: false };
5631
+ }
5632
+ }
5633
+ async get(url, label) {
5634
+ const cached2 = this.cache.getFetch(url);
5635
+ if (cached2) return this.render(label, cached2.body, cached2.isHtml, false);
5636
+ const page = await this.request(url);
5637
+ if (!page.ok) return { success: false, output: `${label} failed: ${page.error}`, truncated: false };
5638
+ if (page.cacheFor > 0 && !page.truncated && page.origin === new URL(url).origin) {
5639
+ this.cache.setFetch(url, { body: page.body, isHtml: page.isHtml }, page.cacheFor);
5640
+ }
5641
+ return this.render(label, page.body, page.isHtml, page.truncated);
5642
+ }
5643
+ render(label, body, isHtml, byteTruncated) {
5644
+ const text = isHtml ? htmlToText(body) : body;
5645
+ if (byteTruncated || text.length > this.maxChars) {
5646
+ const sliced = text.slice(0, this.maxChars);
5647
+ return {
5648
+ success: true,
5649
+ output: `${sliced}
5650
+
5651
+ [... truncated to ${this.maxChars} chars, total ${text.length}${byteTruncated ? "+, body was byte-capped at the source" : ""}]`,
5652
+ truncated: true
5653
+ };
5654
+ }
5655
+ return { success: true, output: text, truncated: false };
5656
+ }
5657
+ async getSearchPage(url) {
5658
+ const page = await this.request(url);
5659
+ return page.ok ? { ok: true, status: page.status, body: page.body } : { ok: false, status: page.status, error: page.error };
5660
+ }
5661
+ async request(url) {
5662
+ let current = new URL(url);
5663
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
5664
+ const result = await this.hop(current);
5665
+ if (!("redirect" in result)) return result;
5666
+ const refused3 = await this.refuseRedirect(current, result.redirect);
5667
+ if (refused3) return { ok: false, status: result.status, error: refused3 };
5668
+ current = result.redirect;
5669
+ }
5670
+ return { ok: false, error: `too many redirects (>${MAX_REDIRECTS})` };
5671
+ }
5672
+ /**
5673
+ * Why a redirect must not be followed, or `undefined` if it may. The policy
5674
+ * decided only the first URL, so a hop to another origin is a new read with
5675
+ * the same decision; otherwise a redirect would carry the fetch past an
5676
+ * exclusion, a Guarded grant or the budget.
5677
+ */
5678
+ async refuseRedirect(from, to) {
5679
+ if (to.protocol !== "https:" && to.protocol !== "http:") return "redirect to a non-HTTP scheme";
5680
+ if (from.protocol === "https:" && to.protocol === "http:") return "redirect from https to plain http";
5681
+ if (to.origin === from.origin) return void 0;
5682
+ const decision = await this.policy.decide({ kind: "fetch", url: to.href }, this.decideOptions);
5683
+ return decision.allowed ? void 0 : `the page redirected to another origin, which was refused. ${decision.reason}`;
5684
+ }
5685
+ async hop(url) {
5686
+ const controller = new AbortController();
5687
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
5688
+ try {
5689
+ const res = await this.fetchImpl(url.href, {
5690
+ signal: controller.signal,
5691
+ headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8" },
5692
+ redirect: "manual"
5693
+ });
5694
+ if (res.status >= 300 && res.status < 400) {
5695
+ const location = res.headers.get("location");
5696
+ if (!location) return { ok: false, status: res.status, error: `redirect (${res.status}) with no Location` };
5697
+ return { redirect: new URL(location, url), status: res.status };
5698
+ }
5699
+ if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` };
5700
+ const contentType = res.headers.get("content-type") ?? "";
5701
+ const cacheFor = sharedLifetime(res.headers.get("cache-control") ?? "", res.headers.get("set-cookie"));
5702
+ const { body, truncated } = await readBounded(res, MAX_BODY_BYTES);
5703
+ return { ok: true, status: res.status, origin: url.origin, body, isHtml: /html|xml/i.test(contentType), truncated, cacheFor };
5704
+ } catch (err) {
5705
+ return { ok: false, error: sanitizeError(err) };
5706
+ } finally {
5707
+ clearTimeout(timer);
5708
+ controller.abort();
5709
+ }
5710
+ }
5711
+ };
5712
+ function isAsyncIterable(value) {
5713
+ return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
5714
+ }
5715
+ async function readBounded(res, maxBytes) {
5716
+ return isAsyncIterable(res.body) ? readFromIterable(res.body, maxBytes) : readFromText(res, maxBytes);
5717
+ }
5718
+ async function readFromIterable(source, maxBytes) {
5719
+ const chunks = [];
5720
+ let total = 0;
5721
+ let truncated = false;
5722
+ for await (const chunk of source) {
5723
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5724
+ if (total + buf.length > maxBytes) {
5725
+ chunks.push(buf.subarray(0, maxBytes - total));
5726
+ total = maxBytes;
5727
+ truncated = true;
5728
+ break;
5729
+ }
5730
+ chunks.push(buf);
5731
+ total += buf.length;
5732
+ }
5733
+ return { body: Buffer.concat(chunks).toString("utf8"), truncated };
5734
+ }
5735
+ async function readFromText(res, maxBytes) {
5736
+ const text = await res.text();
5737
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return { body: text, truncated: false };
5738
+ return { body: Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8"), truncated: true };
5739
+ }
5740
+ function headerGetter(res) {
5741
+ return {
5742
+ get: (name) => {
5743
+ const value = res.headers[name.toLowerCase()];
5744
+ if (value === void 0) return null;
5745
+ return Array.isArray(value) ? value.join(", ") : value;
5746
+ }
5747
+ };
5748
+ }
5749
+ function decodedBody(res) {
5750
+ const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
5751
+ const decoder = encoding === "gzip" || encoding === "x-gzip" ? zlib.createGunzip() : encoding === "deflate" ? zlib.createInflate() : encoding === "br" ? zlib.createBrotliDecompress() : void 0;
5752
+ if (!decoder) return res;
5753
+ pipeline(res, decoder, () => void 0);
5754
+ return decoder;
5755
+ }
5756
+ function pinnedTransport(lookup2) {
5757
+ return (url, init) => new Promise((resolve9, reject) => {
5758
+ const target = new URL(url);
5759
+ const client = target.protocol === "https:" ? https : http;
5760
+ const req = client.request(
5761
+ {
5762
+ protocol: target.protocol,
5763
+ hostname: target.hostname.replace(/^\[|\]$/g, ""),
5764
+ port: target.port || void 0,
5765
+ path: `${target.pathname}${target.search}`,
5766
+ method: "GET",
5767
+ // Sent, and decoded below, because the platform fetch did the same and
5768
+ // some hosts challenge clients that don't.
5769
+ headers: { ...init.headers, "accept-encoding": "gzip, deflate, br" },
5770
+ signal: init.signal,
5771
+ lookup: lookup2,
5772
+ // A pooled keep-alive socket would be reused without a fresh lookup.
5773
+ agent: false
5774
+ },
5775
+ (res) => {
5776
+ res.on("error", () => void 0);
5777
+ const body = decodedBody(res);
5778
+ resolve9({
5779
+ ok: res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300,
5780
+ status: res.statusCode ?? 0,
5781
+ headers: headerGetter(res),
5782
+ body,
5783
+ text: async () => (await readFromIterable(body, Infinity)).body
5784
+ });
5785
+ }
5786
+ );
5787
+ req.on("error", reject);
5788
+ req.end();
5789
+ });
5790
+ }
5791
+
4631
5792
  // src/services/PendingApprovals.ts
4632
- var DEFAULT_TIMEOUT_MS = 5 * 6e4;
5793
+ var DEFAULT_TIMEOUT_MS2 = 5 * 6e4;
4633
5794
  var counter = 0;
4634
5795
  var PendingApprovals = class {
4635
5796
  constructor(opts = {}) {
@@ -4650,7 +5811,7 @@ var PendingApprovals = class {
4650
5811
  return Promise.resolve(refused3);
4651
5812
  }
4652
5813
  const pending = { id, request, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
4653
- return new Promise((resolve6) => {
5814
+ return new Promise((resolve9) => {
4654
5815
  const settle = (decision) => {
4655
5816
  const entry = this.entries.get(id);
4656
5817
  if (!entry) return;
@@ -4658,11 +5819,11 @@ var PendingApprovals = class {
4658
5819
  this.entries.delete(id);
4659
5820
  options.onDecision?.(decision);
4660
5821
  this.opts.onSettled?.(id, isGranted(decision), { request, decision });
4661
- resolve6(decision);
5822
+ resolve9(decision);
4662
5823
  };
4663
5824
  let timer = null;
4664
5825
  if (!options.noTimeout) {
4665
- timer = setTimeout(() => settle({ decision: "deny" }), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
5826
+ timer = setTimeout(() => settle({ decision: "deny" }), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2);
4666
5827
  timer.unref?.();
4667
5828
  }
4668
5829
  this.entries.set(id, { pending, settle, timer });
@@ -5772,6 +6933,9 @@ var BaseConfig = class {
5772
6933
  get approvalMode() {
5773
6934
  return parseApprovalModeSetting(process.env.ORDEWELL_APPROVAL_MODE);
5774
6935
  }
6936
+ get webReadBudget() {
6937
+ return nonNegativeInt(process.env.ORDEWELL_WEB_READ_BUDGET, DEFAULT_WEB_READ_BUDGET);
6938
+ }
5775
6939
  get approvalDefaults() {
5776
6940
  return process.env.ORDEWELL_APPROVAL_DEFAULTS !== "false" && process.env.ORDEWELL_APPROVAL_DEFAULTS !== "0";
5777
6941
  }
@@ -5888,28 +7052,28 @@ function posixBinDirs() {
5888
7052
  ];
5889
7053
  }
5890
7054
  function windowsBinDirs(env, home) {
5891
- const join25 = (...parts) => path4.win32.join(...parts);
5892
- const under = (base, ...parts) => base ? [join25(base, ...parts)] : [];
7055
+ const join30 = (...parts) => path4.win32.join(...parts);
7056
+ const under = (base, ...parts) => base ? [join30(base, ...parts)] : [];
5893
7057
  const appData = env.APPDATA;
5894
7058
  const localAppData = env.LOCALAPPDATA;
5895
7059
  const programData = env.ProgramData;
5896
7060
  return [
5897
7061
  // Native per-user installers, PowerShell-driven and otherwise.
5898
- join25(home, ".local", "bin"),
5899
- join25(home, ".opencode", "bin"),
5900
- join25(home, ".claude", "bin"),
5901
- join25(home, ".codex", "bin"),
7062
+ join30(home, ".local", "bin"),
7063
+ join30(home, ".opencode", "bin"),
7064
+ join30(home, ".claude", "bin"),
7065
+ join30(home, ".codex", "bin"),
5902
7066
  ...under(localAppData, "Programs", "claude"),
5903
7067
  // Node package managers. npm's global prefix is `%APPDATA%\npm`; the
5904
7068
  // literal spelling is kept as a fallback for a host that does not export
5905
7069
  // APPDATA (a service account, a stripped CI container).
5906
7070
  ...under(appData, "npm"),
5907
- join25(home, "AppData", "Roaming", "npm"),
7071
+ join30(home, "AppData", "Roaming", "npm"),
5908
7072
  ...under(localAppData, "pnpm"),
5909
7073
  ...under(localAppData, "Yarn", "bin"),
5910
- join25(home, ".bun", "bin"),
7074
+ join30(home, ".bun", "bin"),
5911
7075
  // Windows package managers.
5912
- join25(home, "scoop", "shims"),
7076
+ join30(home, "scoop", "shims"),
5913
7077
  ...under(env.SCOOP, "shims"),
5914
7078
  ...under(env.SCOOP_GLOBAL ?? programData, "scoop", "shims"),
5915
7079
  ...under(programData, "chocolatey", "bin"),
@@ -5919,7 +7083,7 @@ function windowsBinDirs(env, home) {
5919
7083
  ...under(localAppData, "Microsoft", "WindowsApps"),
5920
7084
  // Version managers. Volta's Windows home is not the POSIX `~/.volta`.
5921
7085
  ...under(localAppData, "Volta", "bin"),
5922
- join25(home, ".volta", "bin")
7086
+ join30(home, ".volta", "bin")
5923
7087
  ];
5924
7088
  }
5925
7089
  function wellKnownBinDirs(deps = {}) {
@@ -7763,7 +8927,7 @@ function buildResearchToolsPrompt() {
7763
8927
  }
7764
8928
  function buildSubagentSystemPrompt() {
7765
8929
  return [
7766
- "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files, never fetch URLs, and cannot spawn further agents.",
8930
+ "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files and cannot spawn further agents. You may fetch or search the web, but a read that would need approval is refused \u2014 note the gap in your digest rather than retrying.",
7767
8931
  "",
7768
8932
  "Work efficiently: batch independent tool calls in one reply; prefer glob/grep to locate, then read only what matters. Your tool budget is small \u2014 stop exploring when you can answer the task.",
7769
8933
  "",
@@ -8348,12 +9512,12 @@ function buildConflictRepairPrompt(task, conflict, integrationBranch) {
8348
9512
  const files = conflict.conflictFiles ?? [];
8349
9513
  const asked = `What the task was asked to do:
8350
9514
  ${task.prompt ?? task.description}`;
8351
- const resolve6 = "resolve every conflict so that both sides' intent survives: keep the work already integrated and add what the task contributed.";
9515
+ const resolve9 = "resolve every conflict so that both sides' intent survives: keep the work already integrated and add what the task contributed.";
8352
9516
  const repos = conflict.repos.filter((repo) => repo !== SELF_REPO);
8353
9517
  if (repos.length === 0) {
8354
9518
  return [
8355
9519
  `Task #${task.order} "${task.title}" passed, but merging its branch \`${branch}\` into \`${integrationBranch}\` conflicted${files.length > 0 ? ` in ${files.join(", ")}` : ""}. None of its work has landed yet.`,
8356
- `This is the task's own worktree, on \`${branch}\`, with its work committed. Run \`git merge --no-edit ${integrationBranch}\` here and ${resolve6} Never drop either side wholesale, and never abort the merge or reset it away.`,
9520
+ `This is the task's own worktree, on \`${branch}\`, with its work committed. Run \`git merge --no-edit ${integrationBranch}\` here and ${resolve9} Never drop either side wholesale, and never abort the merge or reset it away.`,
8357
9521
  "Build and test the result the way this project does, commit the merge, and only then call `task_complete`.",
8358
9522
  "",
8359
9523
  asked
@@ -8362,7 +9526,7 @@ ${task.prompt ?? task.description}`;
8362
9526
  const stoppedIn = conflict.conflictRepo ?? repos[0];
8363
9527
  return [
8364
9528
  `Task #${task.order} "${task.title}" passed, but landing its branch \`${branch}\` on \`${integrationBranch}\` conflicted in ${stoppedIn}${files.length > 0 ? ` (${files.join(", ")})` : ""}. A task lands in every repository it changed or in none, so none of its work has landed yet.`,
8365
- `This is the task's own workspace, with each repository at its usual path on \`${branch}\` and the task's work committed. In each repository the task changed \u2014 ${repos.join(", ")} \u2014 run \`git merge --no-edit ${integrationBranch}\` inside that repository's directory, and ${resolve6} Never drop either side wholesale, and never abort a merge or reset it away.`,
9529
+ `This is the task's own workspace, with each repository at its usual path on \`${branch}\` and the task's work committed. In each repository the task changed \u2014 ${repos.join(", ")} \u2014 run \`git merge --no-edit ${integrationBranch}\` inside that repository's directory, and ${resolve9} Never drop either side wholesale, and never abort a merge or reset it away.`,
8366
9530
  "Build and test the result the way this project does, commit the merge in each repository, and only then call `task_complete`.",
8367
9531
  "",
8368
9532
  asked
@@ -8629,7 +9793,7 @@ var RESEARCH_TOOLS = [
8629
9793
  },
8630
9794
  {
8631
9795
  name: "fetch",
8632
- description: "Fetch content from a URL (HTTP/HTTPS). Use for reading API documentation, library references, or package docs. URL confirmation is required \u2014 the tool may pause while awaiting approval.",
9796
+ description: "Fetch content from a URL (HTTP/HTTPS). Use for reading API documentation, library references, or package docs. Depending on the approval settings, the first fetch from a site may wait for the user to approve it. Private and local addresses are always refused, as is a URL that contains a credential, and each turn has a limited number of web reads; a refusal says why.",
8633
9797
  properties: {
8634
9798
  url: { type: "string", description: "The URL to fetch" }
8635
9799
  },
@@ -8637,16 +9801,15 @@ var RESEARCH_TOOLS = [
8637
9801
  },
8638
9802
  {
8639
9803
  name: "web_search",
8640
- description: "Search the web for result titles, URLs and snippets. When to use: you need docs for an unfamiliar library or API and have no URL yet \u2014 search first, then fetch the most promising result. When NOT to use: anything answerable from the workspace itself (use grep or find_symbol).",
9804
+ description: "Search the web for result titles, URLs and snippets. When to use: you need docs for an unfamiliar library or API and have no URL yet \u2014 search first, then fetch the most promising result. When NOT to use: anything answerable from the workspace itself (use grep or find_symbol). Depending on the approval settings, the first search may wait for the user to approve it; a query that contains a credential is refused, and searches count toward the turn's web reads.",
8641
9805
  properties: {
8642
9806
  query: { type: "string", description: "Search query" }
8643
9807
  },
8644
9808
  required: ["query"]
8645
9809
  }
8646
9810
  ];
8647
- var SUBAGENT_EXCLUDED = /* @__PURE__ */ new Set(["fetch", "web_search"]);
8648
9811
  function subagentToolSpecs() {
8649
- return RESEARCH_TOOLS.filter((t) => !SUBAGENT_EXCLUDED.has(t.name));
9812
+ return RESEARCH_TOOLS;
8650
9813
  }
8651
9814
  function withSpawn() {
8652
9815
  return [...RESEARCH_TOOLS, SPAWN_RESEARCH_AGENT_SPEC];
@@ -8905,7 +10068,7 @@ ${turn.text}`;
8905
10068
  const results = [];
8906
10069
  for (const tc of turn.toolCalls) {
8907
10070
  if (!ALLOWED_TOOL_NAMES.has(tc.name)) {
8908
- const refusal = `Tool "${tc.name}" is not available. You are a read-only research subagent: your only tools are ${[...ALLOWED_TOOL_NAMES].join(", ")}. You cannot fetch URLs, spawn further agents, or modify files. If a specific URL or resource would have answered your question, name it in your digest so the planner can fetch it directly. Otherwise continue with the available tools, then reply with your digest.`;
10071
+ const refusal = `Tool "${tc.name}" is not available. You are a read-only research subagent: your only tools are ${[...ALLOWED_TOOL_NAMES].join(", ")}. You cannot spawn further agents or modify files. If a specific resource would have answered your question, name it in your digest so the planner can fetch it directly. Otherwise continue with the available tools, then reply with your digest.`;
8909
10072
  results.push({ name: tc.name, output: refusal, truncated: false, totalChars: refusal.length, id: tc.id });
8910
10073
  continue;
8911
10074
  }
@@ -8913,7 +10076,7 @@ ${turn.text}`;
8913
10076
  if (tc.name === "read_file" && !("maxBytes" in args) && !("limit" in args)) args.limit = 2e3;
8914
10077
  const toolArgs = JSON.stringify(tc.args);
8915
10078
  deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id, subagentId: deps.subagentId });
8916
- const res = await executeTool(tc.name, args, fs17, void 0, deps.signal);
10079
+ const res = await executeTool(tc.name, args, fs17, deps.fetcher, deps.signal);
8917
10080
  const output = res.output.length > SUBAGENT_LIMITS.toolOutputMaxChars ? res.output.slice(0, SUBAGENT_LIMITS.toolOutputMaxChars) + `
8918
10081
  [... truncated to ${SUBAGENT_LIMITS.toolOutputMaxChars} chars, total ${res.output.length}]` : res.output;
8919
10082
  const stepEntry = {
@@ -9042,7 +10205,7 @@ var BaseAiService = class _BaseAiService {
9042
10205
  * steering/failed result and the loop continues sequentially: a subagent can
9043
10206
  * never fail a plan or a turn.
9044
10207
  */
9045
- async executeSpawnAgent(args, fs17, onProgress, signal, subagentId) {
10208
+ async executeSpawnAgent(args, fs17, onProgress, signal, subagentId, fetcher) {
9046
10209
  const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
9047
10210
  if (!prompt) {
9048
10211
  return { success: false, output: 'spawn_research_agent requires a non-empty "prompt" string: a self-contained task for the agent, including what its digest must report back.', truncated: false };
@@ -9057,6 +10220,10 @@ var BaseAiService = class _BaseAiService {
9057
10220
  fs: fs17,
9058
10221
  signal,
9059
10222
  subagentId,
10223
+ // The subagent gets web tools only behind a non-prompting gate: no
10224
+ // approval channel exists in the background, so a read that would ask is
10225
+ // refused instead (ADR-0027, ADR-0008 T6).
10226
+ fetcher: fetcher?.forSubagent?.(),
9060
10227
  model: this.config.researchSubagentModel,
9061
10228
  usage: () => usage,
9062
10229
  onProgress: (progress) => {
@@ -9092,7 +10259,7 @@ var BaseAiService = class _BaseAiService {
9092
10259
  const subagentId = uuidv4();
9093
10260
  spawnSubagentIds.set(i, subagentId);
9094
10261
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
9095
- precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs17, onProgress, signal, subagentId));
10262
+ precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs17, onProgress, signal, subagentId, fetcher));
9096
10263
  }) : Promise.resolve(),
9097
10264
  parallelIndexes.length > 1 ? mapWithConcurrency(parallelIndexes, TOOL_ROUND_CONCURRENCY, async (i) => {
9098
10265
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), toolCallId: toolCalls[i].id });
@@ -10117,6 +11284,12 @@ var listModelsInput = z.object({
10117
11284
  var runCommandInput = z.object({
10118
11285
  command: z.string().min(1).describe("One shell command line, run from the workspace root.")
10119
11286
  });
11287
+ var fetchInput = z.object({
11288
+ url: z.string().min(1).describe("The http or https URL to read.")
11289
+ });
11290
+ var webSearchInput = z.object({
11291
+ query: z.string().trim().min(1).describe("What to search for.")
11292
+ });
10120
11293
  var loadSkillInput = z.object({
10121
11294
  name: z.string().min(1).describe("The name of an Ordewell planner skill to load.")
10122
11295
  });
@@ -10263,6 +11436,23 @@ var PLANNER_TOOLS = [
10263
11436
  (h) => h.runCommand?.bind(h),
10264
11437
  PLANNER_READ_ONLY
10265
11438
  ),
11439
+ // Fallbacks (ADR-0027): a runner's own fetch or search, where it has one, is
11440
+ // decided by the same policy and carries the provider's own summaries, so
11441
+ // the descriptions send the model there first.
11442
+ tool(
11443
+ "fetch",
11444
+ "Read a web page over http or https: documentation, a changelog, an API reference. A fallback: if you have a built-in web fetch tool, use that instead. Whether a site may be read is up to the user's settings, and a refusal says why; private and local addresses and URLs that carry a credential are always refused, and each planning turn has a limited number of web reads.",
11445
+ fetchInput,
11446
+ (h) => h.fetch?.bind(h),
11447
+ PLANNER_READ_ONLY
11448
+ ),
11449
+ tool(
11450
+ "web_search",
11451
+ "Search the web for result titles, URLs and snippets, then read the most promising result with a fetch. A fallback: if you have a built-in web search tool, use that instead. Whether a search may run is up to the user's settings, and a refusal says why; a query that carries a credential is refused, and searches count toward the turn's web reads.",
11452
+ webSearchInput,
11453
+ (h) => h.webSearch?.bind(h),
11454
+ PLANNER_READ_ONLY
11455
+ ),
10266
11456
  tool(
10267
11457
  "load_skill",
10268
11458
  "Load an Ordewell planner skill by name. Returns its instructions; only model-invocable planner skills can be loaded.",
@@ -10292,8 +11482,14 @@ var OrdewellMcpServer = class {
10292
11482
  issueTaskToken(scope, handler = {}) {
10293
11483
  return this.issue({ role: "task", scope: { ...scope }, handler, revoked: new AbortController() });
10294
11484
  }
10295
- issuePlannerToken(scope, handler = {}) {
10296
- return this.issue({ role: "planner", scope: { ...scope }, handler, revoked: new AbortController() });
11485
+ async issuePlannerToken(scope, handler = {}) {
11486
+ const hidden = [...scope.hidden ?? []];
11487
+ const credential = await this.issue({ role: "planner", scope: { ...scope, hidden }, handler, revoked: new AbortController() });
11488
+ return hidden.length > 0 ? { ...credential, hidden } : credential;
11489
+ }
11490
+ /** Every token still good here: secrets the outbound check keeps out of a URL (ADR-0027). */
11491
+ heldTokens() {
11492
+ return [...this.grants.keys()];
10297
11493
  }
10298
11494
  /** Refuse every later request carrying `token`, and abort the calls it has in flight. */
10299
11495
  revoke(token) {
@@ -10304,14 +11500,14 @@ var OrdewellMcpServer = class {
10304
11500
  }
10305
11501
  async dispose() {
10306
11502
  for (const token of [...this.grants.keys()]) this.revoke(token);
10307
- const http = this.http;
11503
+ const http2 = this.http;
10308
11504
  this.http = void 0;
10309
11505
  this.listening = void 0;
10310
11506
  this.boundUrl = void 0;
10311
11507
  this.boundHost = void 0;
10312
- if (!http) return;
10313
- http.closeAllConnections();
10314
- await new Promise((resolve6) => http.close(() => resolve6()));
11508
+ if (!http2) return;
11509
+ http2.closeAllConnections();
11510
+ await new Promise((resolve9) => http2.close(() => resolve9()));
10315
11511
  }
10316
11512
  async issue(grant) {
10317
11513
  const url = await this.start();
@@ -10320,29 +11516,29 @@ var OrdewellMcpServer = class {
10320
11516
  return { url, token };
10321
11517
  }
10322
11518
  start() {
10323
- this.listening ??= new Promise((resolve6, reject) => {
10324
- const http = createServer((req, res) => {
11519
+ this.listening ??= new Promise((resolve9, reject) => {
11520
+ const http2 = createServer((req, res) => {
10325
11521
  this.handle(req, res).catch(() => {
10326
11522
  if (!res.headersSent) reply(res, 500, "Internal error");
10327
11523
  else res.destroy();
10328
11524
  });
10329
11525
  });
10330
- http.once("error", (err) => {
11526
+ http2.once("error", (err) => {
10331
11527
  this.listening = void 0;
10332
11528
  reject(err);
10333
11529
  });
10334
- http.listen(0, "127.0.0.1", () => {
10335
- const address = http.address();
11530
+ http2.listen(0, "127.0.0.1", () => {
11531
+ const address = http2.address();
10336
11532
  if (address === null || typeof address === "string") {
10337
11533
  reject(new Error("Ordewell MCP server bound to no TCP port"));
10338
11534
  return;
10339
11535
  }
10340
11536
  this.boundHost = `127.0.0.1:${address.port}`;
10341
11537
  this.boundUrl = `http://${this.boundHost}${ORDEWELL_MCP_PATH}`;
10342
- resolve6(this.boundUrl);
11538
+ resolve9(this.boundUrl);
10343
11539
  });
10344
- http.unref();
10345
- this.http = http;
11540
+ http2.unref();
11541
+ this.http = http2;
10346
11542
  });
10347
11543
  return this.listening;
10348
11544
  }
@@ -10363,7 +11559,7 @@ var OrdewellMcpServer = class {
10363
11559
  }
10364
11560
  const mcp = new McpProtocolServer({ name: "ordewell", version: "1" }, { capabilities: { tools: {} } });
10365
11561
  if (grant.role === "task") serveTools(mcp, TASK_TOOLS, grant.handler, grant.revoked.signal, this.heartbeatMs);
10366
- else serveTools(mcp, PLANNER_TOOLS, grant.handler, grant.revoked.signal, this.heartbeatMs);
11562
+ else serveTools(mcp, PLANNER_TOOLS.filter((tool2) => !grant.scope.hidden?.includes(tool2.name)), grant.handler, grant.revoked.signal, this.heartbeatMs);
10367
11563
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
10368
11564
  res.on("close", () => {
10369
11565
  void transport.close();
@@ -10414,14 +11610,15 @@ function mcpClientConfig(credential) {
10414
11610
  return {
10415
11611
  name: ORDEWELL_MCP_SERVER_NAME,
10416
11612
  url: credential.url,
10417
- headers: { Authorization: bearerHeaderValue(credential.token) }
11613
+ headers: { Authorization: bearerHeaderValue(credential.token) },
11614
+ ...credential.hidden?.length ? { hidden: credential.hidden } : {}
10418
11615
  };
10419
11616
  }
10420
11617
  function ownerOnlyConfigFile(fileName, contents) {
10421
11618
  const dir = mkdtempSync(join12(tmpdir(), "ordewell-mcp-"));
10422
- const path26 = join12(dir, fileName);
10423
- writeFileSync3(path26, contents, { mode: 384 });
10424
- return { path: path26, remove: () => rmSync2(dir, { recursive: true, force: true }) };
11619
+ const path31 = join12(dir, fileName);
11620
+ writeFileSync3(path31, contents, { mode: 384 });
11621
+ return { path: path31, remove: () => rmSync2(dir, { recursive: true, force: true }) };
10425
11622
  }
10426
11623
 
10427
11624
  // src/services/harness/ordewellBinding.ts
@@ -10431,13 +11628,13 @@ var McpAttachError = class extends Error {
10431
11628
  this.name = "McpAttachError";
10432
11629
  }
10433
11630
  };
10434
- function ordewellToolNames(role) {
10435
- return (role === "task" ? TASK_TOOLS : PLANNER_TOOLS).map((tool2) => tool2.name);
11631
+ function ordewellToolNames(role, hidden = []) {
11632
+ return (role === "task" ? TASK_TOOLS : PLANNER_TOOLS).map((tool2) => tool2.name).filter((tool2) => !hidden.includes(tool2));
10436
11633
  }
10437
11634
  function prefixedToolNames(prefix) {
10438
11635
  return {
10439
11636
  toolName: (tool2) => `${prefix}${tool2}`,
10440
- toolNames: (role) => ordewellToolNames(role).map((tool2) => `${prefix}${tool2}`),
11637
+ toolNames: (role, hidden) => ordewellToolNames(role, hidden).map((tool2) => `${prefix}${tool2}`),
10441
11638
  hasPrefix: (name) => name.startsWith(prefix)
10442
11639
  };
10443
11640
  }
@@ -10453,8 +11650,8 @@ async function awaitAttach(probe, timeoutMs, pollMs) {
10453
11650
  const state = await probe(left);
10454
11651
  if (state !== "pending") return state === "connected";
10455
11652
  if (pollMs > 0) {
10456
- await new Promise((resolve6) => {
10457
- const timer = setTimeout(resolve6, pollMs);
11653
+ await new Promise((resolve9) => {
11654
+ const timer = setTimeout(resolve9, pollMs);
10458
11655
  timer.unref?.();
10459
11656
  });
10460
11657
  }
@@ -10462,7 +11659,7 @@ async function awaitAttach(probe, timeoutMs, pollMs) {
10462
11659
  }
10463
11660
 
10464
11661
  // src/services/TaskOrchestrator.ts
10465
- import * as path18 from "path";
11662
+ import * as path23 from "path";
10466
11663
 
10467
11664
  // src/services/taskSkills.ts
10468
11665
  import * as path14 from "path";
@@ -10831,11 +12028,11 @@ async function committedInHead(files) {
10831
12028
  }
10832
12029
  const result = /* @__PURE__ */ new Map();
10833
12030
  await Promise.all([...byDir].map(async ([dir, inDir]) => {
10834
- const relative7 = (file) => path14.relative(dir, file).split(path14.sep).join("/");
12031
+ const relative11 = (file) => path14.relative(dir, file).split(path14.sep).join("/");
10835
12032
  try {
10836
- const { stdout } = await runGit("git", ["ls-tree", "--name-only", "HEAD", "--", ...inDir.map(relative7)], { cwd: dir, env: cleanEnv() });
12033
+ const { stdout } = await runGit("git", ["ls-tree", "--name-only", "HEAD", "--", ...inDir.map(relative11)], { cwd: dir, env: cleanEnv() });
10837
12034
  const listed2 = new Set(stdout.split("\n").map((line) => line.trim()).filter(Boolean));
10838
- for (const file of inDir) result.set(file, listed2.has(relative7(file)));
12035
+ for (const file of inDir) result.set(file, listed2.has(relative11(file)));
10839
12036
  } catch {
10840
12037
  }
10841
12038
  }));
@@ -10866,13 +12063,13 @@ function checkPlanSkills(tasks, lookup2) {
10866
12063
  function checkTaskSkillsEdit(task, skills, lookup2) {
10867
12064
  return check([{ taskId: task.id, owner: `Task "${task.title}"`, ...attached(skills) }], lookup2);
10868
12065
  }
10869
- function subtaskEntries(owner, spec, path26 = []) {
12066
+ function subtaskEntries(owner, spec, path31 = []) {
10870
12067
  const subtasks = spec?.subtasks;
10871
12068
  if (!Array.isArray(subtasks)) return [];
10872
12069
  return subtasks.flatMap((sub, i) => {
10873
12070
  if (typeof sub !== "object" || sub === null) return [];
10874
12071
  const child = sub;
10875
- const at = [...path26, typeof child.title === "string" && child.title.trim() ? `"${child.title}"` : `#${i + 1}`];
12072
+ const at = [...path31, typeof child.title === "string" && child.title.trim() ? `"${child.title}"` : `#${i + 1}`];
10876
12073
  return [{ owner: `${owner}: subtask ${at.join(" > ")}`, ...attached(child.skills) }, ...subtaskEntries(owner, child, at)];
10877
12074
  });
10878
12075
  }
@@ -11103,7 +12300,7 @@ ${reminder.join("\n\n")}${COMPLETION_INSTRUCTION}`;
11103
12300
  }
11104
12301
 
11105
12302
  // src/services/VerdictEngine.ts
11106
- var IDLE_TIMEOUT_MS = 6e4;
12303
+ var IDLE_TIMEOUT_MS = 5 * 6e4;
11107
12304
  var VerdictEngine = class {
11108
12305
  pendingVerdicts = /* @__PURE__ */ new Map();
11109
12306
  sessions = /* @__PURE__ */ new Map();
@@ -11285,14 +12482,14 @@ var VerdictEngine = class {
11285
12482
  if (this.toolCheckpoints.has(taskId)) {
11286
12483
  return Promise.resolve({ kind: "withdrawn", why: "another checkpoint is still waiting for an answer. Wait for it before asking again." });
11287
12484
  }
11288
- return new Promise((resolve6) => {
12485
+ return new Promise((resolve9) => {
11289
12486
  const settle = (answer2) => {
11290
12487
  signal.removeEventListener("abort", onAbort);
11291
12488
  if (this.toolCheckpoints.get(taskId) === settle) {
11292
12489
  this.toolCheckpoints.delete(taskId);
11293
12490
  this.checkpointQuestions.delete(taskId);
11294
12491
  }
11295
- resolve6(answer2);
12492
+ resolve9(answer2);
11296
12493
  };
11297
12494
  const onAbort = () => {
11298
12495
  settle({ kind: "withdrawn", why: "the call was cancelled." });
@@ -12751,6 +13948,7 @@ var SETTINGS_ENV_REFUSED = [
12751
13948
  "ORDEWELL_APPROVAL_MODE",
12752
13949
  "ORDEWELL_APPROVAL_ALLOW",
12753
13950
  "ORDEWELL_APPROVAL_DEFAULTS",
13951
+ "ORDEWELL_WEB_READ_BUDGET",
12754
13952
  // Where settings are persisted.
12755
13953
  "ORDEWELL_SETTINGS_PATH",
12756
13954
  // Runtime options and dynamic loaders.
@@ -12802,10 +14000,10 @@ var DIRENV_TIMEOUT_MS = 5e3;
12802
14000
  var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
12803
14001
  function defaultDeps() {
12804
14002
  return {
12805
- run: (command, args, opts) => new Promise((resolve6) => {
14003
+ run: (command, args, opts) => new Promise((resolve9) => {
12806
14004
  execFile2(command, args, { ...opts, timeout: DIRENV_TIMEOUT_MS, encoding: "utf8" }, (err, stdout, stderr) => {
12807
14005
  const code = err ? typeof err.code === "number" ? Number(err.code) : -1 : 0;
12808
- resolve6({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
14006
+ resolve9({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
12809
14007
  });
12810
14008
  }),
12811
14009
  readFile: (file) => {
@@ -12887,10 +14085,10 @@ async function fromDirenv(cwd, deps) {
12887
14085
  }
12888
14086
  return { env, blocked: null };
12889
14087
  }
12890
- function findUp(start, relative7, deps) {
14088
+ function findUp(start, relative11, deps) {
12891
14089
  let dir = path15.resolve(start);
12892
14090
  for (; ; ) {
12893
- const candidate = path15.join(dir, relative7);
14091
+ const candidate = path15.join(dir, relative11);
12894
14092
  if (deps.readFile(candidate) !== null) return candidate;
12895
14093
  const parent = path15.dirname(dir);
12896
14094
  if (parent === dir) return null;
@@ -13349,6 +14547,7 @@ function killTree(proc, deps = {}) {
13349
14547
  // src/services/harness/openCodeOrdewell.ts
13350
14548
  var NAMES = prefixedToolNames(`${ORDEWELL_MCP_SERVER_NAME}_`);
13351
14549
  var RULE = NAMES.toolName("*");
14550
+ var OPENCODE_SEARCH_OFF = { websearch: "deny", codesearch: "deny" };
13352
14551
  var OPENCODE_PLANNER_PERMISSION = {
13353
14552
  "*": "ask",
13354
14553
  read: "allow",
@@ -13358,10 +14557,10 @@ var OPENCODE_PLANNER_PERMISSION = {
13358
14557
  lsp: "allow",
13359
14558
  skill: "allow",
13360
14559
  todoread: "allow",
13361
- websearch: "allow",
13362
- codesearch: "allow",
13363
- webfetch: "ask",
14560
+ websearch: "ask",
14561
+ codesearch: "ask",
13364
14562
  external_directory: "ask",
14563
+ webfetch: "deny",
13365
14564
  edit: "deny",
13366
14565
  write: "deny",
13367
14566
  patch: "deny",
@@ -13376,6 +14575,8 @@ var OPENCODE_PLANNER_PERMISSION = {
13376
14575
  browser: "deny"
13377
14576
  };
13378
14577
  var OPENCODE_CONFIG_VARIABLE = "OPENCODE_CONFIG_CONTENT";
14578
+ var OPENCODE_PLANNER_ENV = { OPENCODE_DISABLE_PROJECT_CONFIG: "1" };
14579
+ var OPENCODE_SEARCH_PROVIDER = { provider: "opencode", providerLabel: "OpenCode's web search (Exa, unless another search provider is chosen in OpenCode)" };
13379
14580
  function isRecord(value) {
13380
14581
  return typeof value === "object" && value !== null && !Array.isArray(value);
13381
14582
  }
@@ -13412,7 +14613,7 @@ function withoutParentOrdewell(content) {
13412
14613
  const kept = withoutOrdewell(parsed);
13413
14614
  return JSON.stringify(kept) === JSON.stringify(parsed) ? content : JSON.stringify(kept);
13414
14615
  }
13415
- function mergeOrdewellConfig(existing, mcp, role) {
14616
+ function mergeOrdewellConfig(existing, mcp, role, projectServers = {}) {
13416
14617
  let base = {};
13417
14618
  if (existing?.trim()) {
13418
14619
  let parsed;
@@ -13429,8 +14630,15 @@ function mergeOrdewellConfig(existing, mcp, role) {
13429
14630
  delete base.permissions;
13430
14631
  }
13431
14632
  const policy = typeof base.permission === "string" ? { "*": base.permission } : base.permission;
14633
+ const servers = {
14634
+ ...role === "planner" ? projectServers : {},
14635
+ ...mcp ? { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } : {}
14636
+ };
13432
14637
  const ours = {
13433
- ...mcp ? { mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } } : {},
14638
+ ...Object.keys(servers).length > 0 ? { mcp: servers } : {},
14639
+ // A language server opened on a file the planner reads can run the
14640
+ // repository's toolchain; 1.x's `read` opens every file it reads (ADR-0028).
14641
+ ...role === "planner" ? { lsp: false } : {},
13434
14642
  permission: { ...role === "planner" ? OPENCODE_PLANNER_PERMISSION : {}, ...mcp ? { [RULE]: "allow" } : {} }
13435
14643
  };
13436
14644
  return JSON.stringify(deepMerge({ ...base, ...policy === void 0 ? {} : { permission: policy } }, ours));
@@ -13438,12 +14646,17 @@ function mergeOrdewellConfig(existing, mcp, role) {
13438
14646
  var OPENCODE_ORDEWELL = {
13439
14647
  toolName: NAMES.toolName,
13440
14648
  toolNames: NAMES.toolNames,
14649
+ plannerHidden: () => [],
13441
14650
  /** A permission request's `permission`, which for a tool is its name. */
13442
14651
  isOrdewellAsk: (permission) => NAMES.hasPrefix(permission),
13443
14652
  /** `/mcp` lists a server only once it has started connecting, as `pending` until it settles. */
13444
14653
  attachState: (status) => status === "connected" ? "connected" : status === void 0 || status === null || status === "pending" ? "pending" : "failed",
13445
14654
  configContent: mergeOrdewellConfig,
13446
- sessionRules: () => [{ action: RULE, resource: "*", effect: "allow" }]
14655
+ sessionRules: () => [{ action: RULE, resource: "*", effect: "allow" }],
14656
+ plannerInstructions: (ownSearch = true) => [
14657
+ `Read web pages with \`${NAMES.toolName("fetch")}\`. Your own \`webfetch\` is off while planning: it follows redirects Ordewell cannot check.`,
14658
+ ownSearch ? `Search the web with your own \`websearch\` first. If \`websearch\` fails, is cancelled or is unavailable for your provider, search with \`${NAMES.toolName("web_search")}\` instead.` : `Your own \`websearch\` is off for this session: no search provider is chosen in OpenCode. Search the web with \`${NAMES.toolName("web_search")}\`.`
14659
+ ].join(" ")
13447
14660
  };
13448
14661
 
13449
14662
  // src/services/harness/runnerEnv.ts
@@ -13471,8 +14684,8 @@ var STDERR_TAIL_CHARS = 4e3;
13471
14684
  var RunnerProcess = class {
13472
14685
  constructor(deps) {
13473
14686
  this.deps = deps;
13474
- this.ended = new Promise((resolve6) => {
13475
- this.markEnded = resolve6;
14687
+ this.ended = new Promise((resolve9) => {
14688
+ this.markEnded = resolve9;
13476
14689
  });
13477
14690
  }
13478
14691
  deps;
@@ -13501,9 +14714,10 @@ var RunnerProcess = class {
13501
14714
  throw err;
13502
14715
  }
13503
14716
  }
13504
- async launch(cwd, describe, handshake) {
14717
+ async launch(cwd, describe2, handshake) {
13505
14718
  assertWorkspaceExists(cwd, { isDirectory: this.deps.isDirectory });
13506
- const command = describe();
14719
+ const workspace = await (this.deps.workspaceEnv ?? workspaceEnvOf)(cwd);
14720
+ const command = describe2(workspace);
13507
14721
  const resolvePath = this.deps.resolvePath ?? augmentedPath;
13508
14722
  const PATH = await resolvePath();
13509
14723
  const launch = await planDirectLaunch(command.command, command.args, {
@@ -13513,7 +14727,6 @@ var RunnerProcess = class {
13513
14727
  if (!isExecutableResolved(command.command, launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
13514
14728
  throw new ExecutableNotFoundError(command.command, PATH);
13515
14729
  }
13516
- const workspace = await (this.deps.workspaceEnv ?? workspaceEnvOf)(cwd);
13517
14730
  this.spawnEnv = runnerEnv(PATH, workspace, command.env?.(workspace));
13518
14731
  const child = spawnInOwnGroup((detached) => this.deps.spawn(launch.file, launch.args, {
13519
14732
  env: this.spawnEnv,
@@ -13636,8 +14849,8 @@ var StdioAgentAdapter = class {
13636
14849
  async start(opts) {
13637
14850
  this.role = opts.kind;
13638
14851
  try {
13639
- await this.runner.start(opts.cwd, () => {
13640
- const spec = this.spawnSpec(opts);
14852
+ await this.runner.start(opts.cwd, (workspace) => {
14853
+ const spec = this.spawnSpec(opts, workspace);
13641
14854
  return { command: spec.command, args: spec.args, env: () => spec.env ?? {} };
13642
14855
  }, (child) => {
13643
14856
  this.process = child;
@@ -13681,7 +14894,7 @@ var StdioAgentAdapter = class {
13681
14894
  onEvent({ type: "error", message: this.exitMessage() });
13682
14895
  return;
13683
14896
  }
13684
- await new Promise((resolve6) => {
14897
+ await new Promise((resolve9) => {
13685
14898
  let settled = false;
13686
14899
  const finish = () => {
13687
14900
  if (settled) return;
@@ -13692,7 +14905,7 @@ var StdioAgentAdapter = class {
13692
14905
  signal?.removeEventListener("abort", onAbort);
13693
14906
  this.process?.removeListener("exit", onExit);
13694
14907
  this.process?.removeListener("error", onExit);
13695
- resolve6();
14908
+ resolve9();
13696
14909
  };
13697
14910
  const emit = (event) => {
13698
14911
  onEvent(event);
@@ -13819,6 +15032,8 @@ ${lines.map((line) => `${line}
13819
15032
  var CLAUDE_ORDEWELL = {
13820
15033
  toolName: MCP_CLIENT_TOOL_NAMES.toolName,
13821
15034
  toolNames: MCP_CLIENT_TOOL_NAMES.toolNames,
15035
+ /** ADR-0027: `WebSearch` and `WebFetch` carry Claude's web reads, both asked through the policy. */
15036
+ plannerHidden: () => ["fetch", "web_search"],
13822
15037
  /** `can_use_tool`'s `tool_name`. */
13823
15038
  isOrdewellAsk: (toolName) => MCP_CLIENT_TOOL_NAMES.hasPrefix(toolName),
13824
15039
  /** `mcp_status` reports `pending` until the CLI's connection attempt settles. */
@@ -13830,24 +15045,24 @@ var CLAUDE_ORDEWELL = {
13830
15045
  * search, and a model that has to look an Ordewell tool up first tends to
13831
15046
  * go on without it.
13832
15047
  */
13833
- launch(mcp, role) {
15048
+ launch(mcp, role, servers = {}) {
13834
15049
  const config = ownerOnlyConfigFile("mcp.json", JSON.stringify({
13835
- mcpServers: { [mcp.name]: { type: "http", url: mcp.url, headers: mcp.headers, alwaysLoad: true } }
15050
+ mcpServers: { ...servers, [mcp.name]: { type: "http", url: mcp.url, headers: mcp.headers, alwaysLoad: true } }
13836
15051
  }));
13837
- return { args: ["--mcp-config", config.path, "--allowedTools", MCP_CLIENT_TOOL_NAMES.toolNames(role).join(",")], config };
15052
+ return { args: ["--mcp-config", config.path, "--allowedTools", MCP_CLIENT_TOOL_NAMES.toolNames(role, mcp.hidden).join(",")], config };
13838
15053
  }
13839
15054
  };
13840
15055
 
13841
15056
  // src/services/harness/settleWithin.ts
13842
15057
  function settleWithin(promise, wait) {
13843
- return new Promise((resolve6, reject) => {
15058
+ return new Promise((resolve9, reject) => {
13844
15059
  let settled = false;
13845
15060
  const settle = (outcome) => {
13846
15061
  if (settled) return;
13847
15062
  settled = true;
13848
15063
  clearTimeout(timer);
13849
15064
  try {
13850
- resolve6(outcome());
15065
+ resolve9(outcome());
13851
15066
  } catch (err) {
13852
15067
  reject(err);
13853
15068
  }
@@ -13864,6 +15079,180 @@ function settleWithin(promise, wait) {
13864
15079
  });
13865
15080
  }
13866
15081
 
15082
+ // src/services/harness/claudeRepoConfig.ts
15083
+ import * as os6 from "os";
15084
+ import * as path19 from "path";
15085
+
15086
+ // src/services/harness/repoConfig.ts
15087
+ import { existsSync as existsSync9, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync7 } from "fs";
15088
+ import * as path18 from "path";
15089
+ var NO_SERVERS = { servers: [], unreadable: [] };
15090
+ var INSTRUCTIONS_MAX_CHARS = 4e4;
15091
+ function isRecord2(value) {
15092
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15093
+ }
15094
+ function readText(file) {
15095
+ try {
15096
+ return statSync7(file).isFile() ? readFileSync6(file, "utf8") : void 0;
15097
+ } catch {
15098
+ return void 0;
15099
+ }
15100
+ }
15101
+ function onDisk(target) {
15102
+ try {
15103
+ return realpathSync3.native(target);
15104
+ } catch {
15105
+ return path18.resolve(target);
15106
+ }
15107
+ }
15108
+ function insideOnDisk(root, target) {
15109
+ const rel = path18.relative(onDisk(root), onDisk(target));
15110
+ return rel === "" || !rel.startsWith("..") && !path18.isAbsolute(rel);
15111
+ }
15112
+ function repoLevels(workspace) {
15113
+ const levels = [];
15114
+ for (let dir = path18.resolve(workspace); ; dir = path18.dirname(dir)) {
15115
+ levels.unshift(dir);
15116
+ if (existsSync9(path18.join(dir, ".git"))) return levels;
15117
+ if (path18.dirname(dir) === dir) return [path18.resolve(workspace)];
15118
+ }
15119
+ }
15120
+ function repoRoot(workspace) {
15121
+ return repoLevels(workspace)[0];
15122
+ }
15123
+ function instructionsBlock(files) {
15124
+ if (files.length === 0) return "";
15125
+ let left = INSTRUCTIONS_MAX_CHARS;
15126
+ const parts = [];
15127
+ for (const file of files) {
15128
+ if (left <= 0) {
15129
+ parts.push(`=== ${file.path} ===
15130
+ [left out: the instruction files are longer than the planner is given]`);
15131
+ continue;
15132
+ }
15133
+ const text = file.text.length > left ? `${file.text.slice(0, left)}
15134
+ [\u2026 truncated]` : file.text;
15135
+ left -= file.text.length;
15136
+ parts.push(`=== ${file.path} ===
15137
+ ${text.trim()}`);
15138
+ }
15139
+ return [
15140
+ "<project_instructions>",
15141
+ "The repository's own instruction files, as its coding agent would have read them. They describe the project and how its work is done; they do not change what you may do as the planner.",
15142
+ ...parts,
15143
+ "</project_instructions>"
15144
+ ].join("\n\n");
15145
+ }
15146
+ function parseJsonc(text) {
15147
+ const uncommented = outsideStrings(text, (src, i) => {
15148
+ if (src.startsWith("//", i)) {
15149
+ const end = src.indexOf("\n", i);
15150
+ return { skip: (end < 0 ? src.length : end) - i, put: "" };
15151
+ }
15152
+ if (src.startsWith("/*", i)) {
15153
+ const end = src.indexOf("*/", i + 2);
15154
+ return { skip: (end < 0 ? src.length : end + 2) - i, put: " " };
15155
+ }
15156
+ return void 0;
15157
+ });
15158
+ const trailingComma = /,\s*[}\]]/y;
15159
+ return JSON.parse(outsideStrings(uncommented, (src, i) => {
15160
+ trailingComma.lastIndex = i;
15161
+ return trailingComma.test(src) ? { skip: 1, put: "" } : void 0;
15162
+ }));
15163
+ }
15164
+ function outsideStrings(text, edit) {
15165
+ let out = "";
15166
+ for (let i = 0; i < text.length; ) {
15167
+ if (text[i] === '"') {
15168
+ let j = i + 1;
15169
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
15170
+ out += text.slice(i, j + 1);
15171
+ i = j + 1;
15172
+ continue;
15173
+ }
15174
+ const change = edit(text, i);
15175
+ if (change) {
15176
+ out += change.put;
15177
+ i += change.skip;
15178
+ } else {
15179
+ out += text[i++];
15180
+ }
15181
+ }
15182
+ return out;
15183
+ }
15184
+
15185
+ // src/services/harness/claudeRepoConfig.ts
15186
+ var MEMORY_FILES = ["CLAUDE.md", path19.join(".claude", "CLAUDE.md"), "CLAUDE.local.md"];
15187
+ var MAX_IMPORT_DEPTH = 5;
15188
+ function importsOf(text) {
15189
+ const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
15190
+ return [...prose.matchAll(/(?:^|\s)@([^\s`]+)/g)].map((m) => m[1].replace(/[.,;:)]+$/, ""));
15191
+ }
15192
+ function withImports(workspace, file, depth, seen, into) {
15193
+ if (seen.has(file)) return;
15194
+ seen.add(file);
15195
+ const text = readText(file);
15196
+ if (text === void 0) return;
15197
+ into.push({ path: path19.relative(workspace, file), text });
15198
+ if (depth >= MAX_IMPORT_DEPTH) return;
15199
+ for (const target of importsOf(text)) {
15200
+ if (target.startsWith("~")) continue;
15201
+ const abs = path19.resolve(path19.dirname(file), target);
15202
+ if (insideOnDisk(workspace, abs)) withImports(workspace, abs, depth + 1, seen, into);
15203
+ }
15204
+ }
15205
+ function server(name, entry, source) {
15206
+ if (!isRecord2(entry)) return "not an object";
15207
+ const remote = entry.type === "http" || entry.type === "sse" || entry.type === void 0 && typeof entry.url === "string";
15208
+ if (remote) {
15209
+ if (typeof entry.url !== "string") return "no url";
15210
+ return { name, source, transport: "remote", target: entry.url, names: isRecord2(entry.headers) ? Object.keys(entry.headers) : [], definition: entry };
15211
+ }
15212
+ if (typeof entry.command !== "string") return "no command";
15213
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
15214
+ return { name, source, transport: "stdio", target: [entry.command, ...args].join(" "), names: isRecord2(entry.env) ? Object.keys(entry.env) : [], definition: entry };
15215
+ }
15216
+ var CLAUDE_REPO_CONFIG = {
15217
+ instructions(workspace) {
15218
+ const files = [];
15219
+ const seen = /* @__PURE__ */ new Set();
15220
+ for (const name of MEMORY_FILES) withImports(workspace, path19.join(workspace, name), 0, seen, files);
15221
+ return files;
15222
+ },
15223
+ projectServers(workspace) {
15224
+ const source = ".mcp.json";
15225
+ const text = readText(path19.join(workspace, source));
15226
+ if (text === void 0) return NO_SERVERS;
15227
+ let parsed;
15228
+ try {
15229
+ parsed = JSON.parse(text);
15230
+ } catch (err) {
15231
+ return { servers: [], unreadable: [{ source, reason: err instanceof Error ? err.message : String(err) }] };
15232
+ }
15233
+ const entries = isRecord2(parsed) && isRecord2(parsed.mcpServers) ? parsed.mcpServers : {};
15234
+ const found = { servers: [], unreadable: [] };
15235
+ for (const [name, entry] of Object.entries(entries)) {
15236
+ const read2 = server(name, entry, source);
15237
+ if (typeof read2 === "string") found.unreadable.push({ source: `${source} (${name})`, reason: read2 });
15238
+ else found.servers.push(read2);
15239
+ }
15240
+ return found;
15241
+ }
15242
+ };
15243
+ function claudeLocalServers(configDir2, workspace) {
15244
+ const file = configDir2 ? path19.join(configDir2, ".claude.json") : path19.join(os6.homedir(), ".claude.json");
15245
+ const text = readText(file);
15246
+ if (text === void 0) return {};
15247
+ try {
15248
+ const parsed = JSON.parse(text);
15249
+ const project = isRecord2(parsed) && isRecord2(parsed.projects) ? parsed.projects[path19.resolve(workspace)] : void 0;
15250
+ return isRecord2(project) && isRecord2(project.mcpServers) ? project.mcpServers : {};
15251
+ } catch {
15252
+ return {};
15253
+ }
15254
+ }
15255
+
13867
15256
  // src/services/harness/ClaudeCodeAdapter.ts
13868
15257
  var DISALLOWED_TOOLS = [
13869
15258
  "Edit",
@@ -13877,20 +15266,25 @@ var DISALLOWED_TOOLS = [
13877
15266
  "ExitPlanMode"
13878
15267
  ];
13879
15268
  var PLANNER_PERMISSION_MODE = "default";
15269
+ var PLANNER_SETTINGS = JSON.stringify({
15270
+ disableAllHooks: true,
15271
+ permissions: { ask: ["WebFetch", "WebFetch(domain:*)", "WebSearch", "mcp__*", "Read", "Grep", "Glob", "LS"] }
15272
+ });
13880
15273
  var PATH_TOOLS = {
13881
15274
  Read: { field: "file_path", directory: false },
13882
15275
  Grep: { field: "path", directory: true },
13883
15276
  Glob: { field: "path", directory: true },
13884
15277
  LS: { field: "path", directory: true }
13885
15278
  };
13886
- function plannerAsk(name, input, server) {
15279
+ function plannerAsk(name, input, server4) {
13887
15280
  if (name.startsWith("mcp__")) {
13888
15281
  const tool2 = name.split("__").slice(2).join("__");
13889
- return { kind: "mcp", scope: name, tool: tool2 || name, ...server ? { server } : {} };
15282
+ return { kind: "mcp", scope: name, tool: tool2 || name, ...server4 ? { server: server4 } : {} };
13890
15283
  }
13891
15284
  if (name === "WebFetch" && typeof input.url === "string") return { kind: "fetch", url: input.url };
15285
+ if (name === "WebSearch" && typeof input.query === "string") return { kind: "search", query: input.query, provider: "anthropic" };
13892
15286
  const pathTool = PATH_TOOLS[name];
13893
- const target = pathTool ? input[pathTool.field] : void 0;
15287
+ const target = pathTool ? input[pathTool.field] ?? (pathTool.directory ? "." : void 0) : void 0;
13894
15288
  if (pathTool && typeof target === "string") return { kind: "path", path: target, directory: pathTool.directory };
13895
15289
  return { kind: "other" };
13896
15290
  }
@@ -13998,7 +15392,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
13998
15392
  steers = /* @__PURE__ */ new Map();
13999
15393
  /** The last turn's `result` ended it, so a steer's echo now opens a turn the CLI started for that message. */
14000
15394
  turnClosed = false;
14001
- spawnSpec(opts) {
15395
+ spawnSpec(opts, workspace) {
14002
15396
  if (opts.kind === "task") return this.taskSpawnSpec(opts);
14003
15397
  const args = [
14004
15398
  ...PROTOCOL_ARGS,
@@ -14009,10 +15403,16 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
14009
15403
  PLANNER_PERMISSION_MODE,
14010
15404
  "--disallowedTools",
14011
15405
  DISALLOWED_TOOLS.join(","),
15406
+ // In `-p` a repository's settings apply trusted or not: their hooks,
15407
+ // `env` and helper commands run before the first turn (ADR-0028).
15408
+ "--setting-sources",
15409
+ "user",
15410
+ "--settings",
15411
+ PLANNER_SETTINGS,
14012
15412
  "--append-system-prompt",
14013
15413
  opts.systemPrompt
14014
15414
  ];
14015
- if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "planner"));
15415
+ if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "planner", this.plannerServers(opts, workspace)));
14016
15416
  if (opts.model) args.push("--model", opts.model);
14017
15417
  if (opts.effort && opts.effort !== "adaptive") args.push("--effort", opts.effort);
14018
15418
  if (opts.resumeSessionId) {
@@ -14050,10 +15450,23 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
14050
15450
  if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "task"));
14051
15451
  return { command: "claude", args };
14052
15452
  }
15453
+ /**
15454
+ * What user-only settings stop the CLI loading that is not the
15455
+ * repository's to choose (ADR-0028): the user's own local-scope servers,
15456
+ * under the `CLAUDE_CONFIG_DIR` the planner runs with, and the project
15457
+ * servers approved for it.
15458
+ */
15459
+ plannerServers(opts, workspace) {
15460
+ const configDir2 = workspace.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR;
15461
+ return {
15462
+ ...claudeLocalServers(configDir2, opts.cwd),
15463
+ ...Object.fromEntries((opts.projectServers ?? []).map((server4) => [server4.name, server4.definition]))
15464
+ };
15465
+ }
14053
15466
  /** The file holding the token lives exactly as long as the process that reads it. */
14054
- ordewellServerArgs(mcp, role) {
15467
+ ordewellServerArgs(mcp, role, servers) {
14055
15468
  this.removeMcpConfig();
14056
- const { args, config } = CLAUDE_ORDEWELL.launch(mcp, role);
15469
+ const { args, config } = CLAUDE_ORDEWELL.launch(mcp, role, servers);
14057
15470
  this.mcpConfig = config;
14058
15471
  void this.processEnded.then(() => config.remove());
14059
15472
  this.givenOrdewell = true;
@@ -14102,8 +15515,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
14102
15515
  if (!this.process) return Promise.resolve(null);
14103
15516
  this.controlCount += 1;
14104
15517
  const requestId = `ordewell-${request.subtype}-${this.controlCount}`;
14105
- const answered = new Promise((resolve6) => {
14106
- this.pendingControl.set(requestId, resolve6);
15518
+ const answered = new Promise((resolve9) => {
15519
+ this.pendingControl.set(requestId, resolve9);
14107
15520
  });
14108
15521
  this.writeLine({ type: "control_request", request_id: requestId, request });
14109
15522
  return settleWithin(answered, { timeoutMs, ended: this.processEnded, onTimeout: () => null, onEnded: () => null }).finally(() => {
@@ -14408,7 +15821,7 @@ ${block.text}` : block.text });
14408
15821
 
14409
15822
  // src/services/harness/CodexAdapter.ts
14410
15823
  import { randomUUID as randomUUID2 } from "crypto";
14411
- import { isAbsolute as isAbsolute4, relative as relative5, sep as sep3 } from "path";
15824
+ import { isAbsolute as isAbsolute5, relative as relative8, sep as sep3 } from "path";
14412
15825
 
14413
15826
  // src/services/harness/codexSandbox.ts
14414
15827
  var PROBE_TIMEOUT_MS = 1e4;
@@ -14434,14 +15847,14 @@ function codexSandboxUnavailableMessage(role = "planner") {
14434
15847
  ].join("\n");
14435
15848
  }
14436
15849
  function runProbe(deps, cwd, env, flags) {
14437
- return new Promise((resolve6) => {
15850
+ return new Promise((resolve9) => {
14438
15851
  let output = "";
14439
15852
  let settled = false;
14440
15853
  const finish = (code) => {
14441
15854
  if (settled) return;
14442
15855
  settled = true;
14443
15856
  clearTimeout(timer);
14444
- resolve6({ code, output });
15857
+ resolve9({ code, output });
14445
15858
  };
14446
15859
  const timer = setTimeout(() => finish(null), PROBE_TIMEOUT_MS);
14447
15860
  timer.unref?.();
@@ -14456,27 +15869,306 @@ function runProbe(deps, cwd, env, flags) {
14456
15869
  finish(null);
14457
15870
  return;
14458
15871
  }
14459
- proc.stdout?.on("data", (chunk) => {
14460
- output += chunk.toString();
14461
- });
14462
- proc.stderr?.on("data", (chunk) => {
14463
- output += chunk.toString();
14464
- });
14465
- proc.on("error", () => finish(null));
14466
- proc.on("exit", (code) => finish(code));
14467
- });
15872
+ proc.stdout?.on("data", (chunk) => {
15873
+ output += chunk.toString();
15874
+ });
15875
+ proc.stderr?.on("data", (chunk) => {
15876
+ output += chunk.toString();
15877
+ });
15878
+ proc.on("error", () => finish(null));
15879
+ proc.on("exit", (code) => finish(code));
15880
+ });
15881
+ }
15882
+
15883
+ // src/services/harness/codexRepoConfig.ts
15884
+ import * as path20 from "path";
15885
+
15886
+ // src/services/harness/toml.ts
15887
+ function parseToml(text) {
15888
+ return new TomlReader(text).document();
15889
+ }
15890
+ var TomlError = class extends Error {
15891
+ };
15892
+ var BARE_KEY = /[A-Za-z0-9_-]/;
15893
+ var ESCAPES = { b: "\b", t: " ", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\", e: "\x1B" };
15894
+ var TomlReader = class {
15895
+ constructor(src) {
15896
+ this.src = src;
15897
+ }
15898
+ src;
15899
+ i = 0;
15900
+ document() {
15901
+ const root = {};
15902
+ let table = root;
15903
+ for (; ; ) {
15904
+ this.skipBlank(true);
15905
+ if (this.i >= this.src.length) return root;
15906
+ if (this.src.startsWith("[[", this.i)) {
15907
+ this.i += 2;
15908
+ const path31 = this.key();
15909
+ this.expect("]]");
15910
+ const parent = this.tableAt(root, path31.slice(0, -1));
15911
+ const last = path31[path31.length - 1];
15912
+ const list = parent[last] ?? (parent[last] = []);
15913
+ if (!Array.isArray(list)) throw this.fail(`${path31.join(".")} is not an array of tables`);
15914
+ table = {};
15915
+ list.push(table);
15916
+ } else if (this.src[this.i] === "[") {
15917
+ this.i += 1;
15918
+ const path31 = this.key();
15919
+ this.expect("]");
15920
+ table = this.tableAt(root, path31);
15921
+ } else {
15922
+ const path31 = this.key();
15923
+ this.skipBlank(false);
15924
+ this.expect("=");
15925
+ this.skipBlank(false);
15926
+ const owner = this.tableAt(table, path31.slice(0, -1));
15927
+ owner[path31[path31.length - 1]] = this.value();
15928
+ }
15929
+ this.endOfLine();
15930
+ }
15931
+ }
15932
+ tableAt(from, path31) {
15933
+ let table = from;
15934
+ for (const part of path31) {
15935
+ const next = table[part];
15936
+ if (next === void 0) table = table[part] = {};
15937
+ else if (isRecord2(next)) table = next;
15938
+ else if (Array.isArray(next) && isRecord2(next[next.length - 1])) table = next[next.length - 1];
15939
+ else throw this.fail(`${part} is not a table`);
15940
+ }
15941
+ return table;
15942
+ }
15943
+ key() {
15944
+ const parts = [];
15945
+ for (; ; ) {
15946
+ this.skipBlank(false);
15947
+ const ch = this.src[this.i];
15948
+ if (ch === '"') parts.push(this.basicString());
15949
+ else if (ch === "'") parts.push(this.literalString());
15950
+ else {
15951
+ const start = this.i;
15952
+ while (this.i < this.src.length && BARE_KEY.test(this.src[this.i])) this.i++;
15953
+ if (this.i === start) throw this.fail("expected a key");
15954
+ parts.push(this.src.slice(start, this.i));
15955
+ }
15956
+ this.skipBlank(false);
15957
+ if (this.src[this.i] !== ".") return parts;
15958
+ this.i++;
15959
+ }
15960
+ }
15961
+ value() {
15962
+ const ch = this.src[this.i];
15963
+ if (this.src.startsWith('"""', this.i)) return this.multilineString('"""');
15964
+ if (this.src.startsWith("'''", this.i)) return this.multilineString("'''");
15965
+ if (ch === '"') return this.basicString();
15966
+ if (ch === "'") return this.literalString();
15967
+ if (ch === "[") return this.array();
15968
+ if (ch === "{") return this.inlineTable();
15969
+ const start = this.i;
15970
+ while (this.i < this.src.length && !/[\s,\]}#]/.test(this.src[this.i])) this.i++;
15971
+ const raw = this.src.slice(start, this.i);
15972
+ if (!raw) throw this.fail("expected a value");
15973
+ if (raw === "true" || raw === "false") return raw === "true";
15974
+ const number = raw.replace(/_/g, "");
15975
+ if (/^[+-]?(\d+(\.\d+)?([eE][+-]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|inf|nan)$/.test(number)) return Number(number);
15976
+ return raw;
15977
+ }
15978
+ array() {
15979
+ this.i++;
15980
+ const items = [];
15981
+ for (; ; ) {
15982
+ this.skipBlank(true);
15983
+ if (this.src[this.i] === "]") {
15984
+ this.i++;
15985
+ return items;
15986
+ }
15987
+ items.push(this.value());
15988
+ this.skipBlank(true);
15989
+ if (this.src[this.i] === ",") this.i++;
15990
+ else if (this.src[this.i] !== "]") throw this.fail("expected , or ] in an array");
15991
+ }
15992
+ }
15993
+ inlineTable() {
15994
+ this.i++;
15995
+ const table = {};
15996
+ for (; ; ) {
15997
+ this.skipBlank(true);
15998
+ if (this.src[this.i] === "}") {
15999
+ this.i++;
16000
+ return table;
16001
+ }
16002
+ const path31 = this.key();
16003
+ this.expect("=");
16004
+ this.skipBlank(false);
16005
+ this.tableAt(table, path31.slice(0, -1))[path31[path31.length - 1]] = this.value();
16006
+ this.skipBlank(true);
16007
+ if (this.src[this.i] === ",") this.i++;
16008
+ else if (this.src[this.i] !== "}") throw this.fail("expected , or } in an inline table");
16009
+ }
16010
+ }
16011
+ basicString() {
16012
+ this.i++;
16013
+ let out = "";
16014
+ while (this.src[this.i] !== '"') {
16015
+ if (this.i >= this.src.length || this.src[this.i] === "\n") throw this.fail("unterminated string");
16016
+ out += this.src[this.i] === "\\" ? this.escape() : this.src[this.i++];
16017
+ }
16018
+ this.i++;
16019
+ return out;
16020
+ }
16021
+ literalString() {
16022
+ const end = this.src.indexOf("'", this.i + 1);
16023
+ const body = end < 0 ? "" : this.src.slice(this.i + 1, end);
16024
+ if (end < 0 || body.includes("\n")) throw this.fail("unterminated string");
16025
+ this.i = end + 1;
16026
+ return body;
16027
+ }
16028
+ multilineString(quote) {
16029
+ this.i += 3;
16030
+ if (this.src[this.i] === "\n") this.i++;
16031
+ else if (this.src.startsWith("\r\n", this.i)) this.i += 2;
16032
+ let out = "";
16033
+ for (; ; ) {
16034
+ if (this.i >= this.src.length) throw this.fail("unterminated string");
16035
+ if (this.src.startsWith(quote, this.i)) {
16036
+ let extra = 0;
16037
+ while (extra < 2 && this.src[this.i + 3 + extra] === quote[0]) extra++;
16038
+ out += quote[0].repeat(extra);
16039
+ this.i += 3 + extra;
16040
+ return out;
16041
+ }
16042
+ if (quote === '"""' && this.src[this.i] === "\\") {
16043
+ const rest = /^\\[ \t]*\r?\n[\s]*/.exec(this.src.slice(this.i, this.i + 256));
16044
+ if (rest) {
16045
+ this.i += rest[0].length;
16046
+ continue;
16047
+ }
16048
+ out += this.escape();
16049
+ continue;
16050
+ }
16051
+ out += this.src[this.i++];
16052
+ }
16053
+ }
16054
+ escape() {
16055
+ const code = this.src[this.i + 1];
16056
+ if (code in ESCAPES) {
16057
+ this.i += 2;
16058
+ return ESCAPES[code];
16059
+ }
16060
+ const width = code === "u" ? 4 : code === "U" ? 8 : 0;
16061
+ const hex = this.src.slice(this.i + 2, this.i + 2 + width);
16062
+ if (!width || !/^[0-9a-fA-F]+$/.test(hex) || hex.length !== width) throw this.fail("bad escape");
16063
+ this.i += 2 + width;
16064
+ return String.fromCodePoint(parseInt(hex, 16));
16065
+ }
16066
+ /** Spaces, tabs and comments; newlines too when `newlines`. */
16067
+ skipBlank(newlines) {
16068
+ while (this.i < this.src.length) {
16069
+ const ch = this.src[this.i];
16070
+ if (ch === " " || ch === " " || newlines && (ch === "\n" || ch === "\r")) this.i++;
16071
+ else if (ch === "#") while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
16072
+ else return;
16073
+ }
16074
+ }
16075
+ endOfLine() {
16076
+ this.skipBlank(false);
16077
+ if (this.i < this.src.length && this.src[this.i] !== "\n" && this.src[this.i] !== "\r") throw this.fail("expected the end of the line");
16078
+ }
16079
+ expect(token) {
16080
+ this.skipBlank(false);
16081
+ if (!this.src.startsWith(token, this.i)) throw this.fail(`expected ${token}`);
16082
+ this.i += token.length;
16083
+ }
16084
+ fail(message) {
16085
+ const line = this.src.slice(0, this.i).split("\n").length;
16086
+ return new TomlError(`line ${line}: ${message}`);
16087
+ }
16088
+ };
16089
+
16090
+ // src/services/harness/codexRepoConfig.ts
16091
+ var CONFIG_FILE = path20.join(".codex", "config.toml");
16092
+ function stringsOf(value) {
16093
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
16094
+ }
16095
+ function keysOf(value) {
16096
+ return isRecord2(value) ? Object.keys(value) : [];
16097
+ }
16098
+ function server2(name, entry, source) {
16099
+ if (!isRecord2(entry)) return "not a table";
16100
+ if (entry.enabled === false) return null;
16101
+ if (typeof entry.url === "string") {
16102
+ const names2 = [...keysOf(entry.http_headers), ...keysOf(entry.env_http_headers), ...typeof entry.bearer_token_env_var === "string" ? ["Authorization"] : []];
16103
+ return { name, source, transport: "remote", target: entry.url, names: names2, definition: entry };
16104
+ }
16105
+ if (typeof entry.command !== "string") return "no command or url";
16106
+ const names = [...keysOf(entry.env), ...stringsOf(entry.env_vars)];
16107
+ return { name, source, transport: "stdio", target: [entry.command, ...stringsOf(entry.args)].join(" "), names, definition: entry };
16108
+ }
16109
+ var CODEX_REPO_CONFIG = {
16110
+ /** Codex reads one file per directory from the repository root down, an override in place of the plain one. */
16111
+ instructions(workspace) {
16112
+ const files = [];
16113
+ for (const dir of repoLevels(workspace)) {
16114
+ for (const name of ["AGENTS.override.md", "AGENTS.md"]) {
16115
+ const text = readText(path20.join(dir, name));
16116
+ if (text === void 0) continue;
16117
+ files.push({ path: path20.relative(workspace, path20.join(dir, name)) || name, text });
16118
+ break;
16119
+ }
16120
+ }
16121
+ return files;
16122
+ },
16123
+ /** Each level's `.codex/config.toml`, the one nearer the workspace winning for a server both name. */
16124
+ projectServers(workspace) {
16125
+ const byName = /* @__PURE__ */ new Map();
16126
+ const unreadable = [];
16127
+ for (const dir of repoLevels(workspace)) {
16128
+ const file = path20.join(dir, CONFIG_FILE);
16129
+ const text = readText(file);
16130
+ if (text === void 0) continue;
16131
+ const source = path20.relative(workspace, file);
16132
+ let parsed;
16133
+ try {
16134
+ parsed = parseToml(text);
16135
+ } catch (err) {
16136
+ unreadable.push({ source, reason: err instanceof Error ? err.message : String(err) });
16137
+ continue;
16138
+ }
16139
+ for (const [name, entry] of Object.entries(isRecord2(parsed.mcp_servers) ? parsed.mcp_servers : {})) {
16140
+ const read2 = server2(name, entry, source);
16141
+ if (typeof read2 === "string") unreadable.push({ source: `${source} (${name})`, reason: read2 });
16142
+ else if (read2) byName.set(name, read2);
16143
+ else byName.delete(name);
16144
+ }
16145
+ }
16146
+ return { servers: [...byName.values()], unreadable };
16147
+ }
16148
+ };
16149
+ function untrustedProjects(workspace) {
16150
+ const projects = {};
16151
+ for (let dir = path20.resolve(workspace); ; dir = path20.dirname(dir)) {
16152
+ projects[dir] = { trust_level: "untrusted" };
16153
+ if (path20.dirname(dir) === dir) return projects;
16154
+ }
14468
16155
  }
14469
16156
 
14470
16157
  // src/services/harness/codexOrdewell.ts
16158
+ var CODEX_SEARCH_PROVIDER = "openai";
14471
16159
  function headerEnv(mcp) {
14472
16160
  return Object.fromEntries(Object.keys(mcp.headers).map((header, i) => [header, `${MCP_TOKEN_VARIABLE_PREFIX}${i}`]));
14473
16161
  }
14474
16162
  var COUNT_WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
16163
+ var countOf = (names) => COUNT_WORDS[names.length] ?? String(names.length);
14475
16164
  var quoted2 = (names) => listed(names.map((name) => `\`${name}\``));
14476
16165
  var CODEX_ORDEWELL = {
14477
16166
  // Codex names a call by server and tool; the model reaches the tool by Claude Code's name for it.
14478
16167
  toolName: MCP_CLIENT_TOOL_NAMES.toolName,
14479
16168
  toolNames: MCP_CLIENT_TOOL_NAMES.toolNames,
16169
+ // With its own search on, a model offered Ordewell's too tends to take that instead. Codex has no fetch of its own.
16170
+ plannerHidden: (start) => start.nativeSearch ? ["web_search"] : [],
16171
+ sessionSearchProvider: CODEX_SEARCH_PROVIDER,
14480
16172
  /** An elicitation Codex raises for one of the server's tools: an approval the grant should have made unnecessary. */
14481
16173
  isOrdewellAsk: ({ method, params }) => method === "mcpServer/elicitation/request" && params.serverName === ORDEWELL_MCP_SERVER_NAME,
14482
16174
  /** `mcpServer/startupStatus/updated`'s status, unsent until the thread has tried to connect. */
@@ -14502,25 +16194,28 @@ var CODEX_ORDEWELL = {
14502
16194
  taskInstructions() {
14503
16195
  const tools = ordewellToolNames("task");
14504
16196
  return [
14505
- `This task has ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
16197
+ `This task has ${countOf(tools)} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
14506
16198
  `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("task"))}.`,
14507
16199
  `Look for them before you finish; the task is reported complete through \`${TASK_COMPLETE_TOOL}\`.`
14508
16200
  ].join(" ");
14509
16201
  },
14510
16202
  /**
14511
16203
  * The same discovery note for a planner, which also needs to know that its
14512
- * commands go through `run_command`: Codex's own shell is sandboxed without
14513
- * network, so `gh` or a cloud CLI fails there, and a model that tried its
14514
- * shell first concludes the command cannot run.
16204
+ * commands and fetches go through Ordewell's tools: Codex's own shell is
16205
+ * sandboxed without network, so `gh`, a cloud CLI or `curl` fails there, and
16206
+ * a model that tried its shell first concludes the command cannot run.
14515
16207
  */
14516
- plannerInstructions() {
14517
- const tools = ordewellToolNames("planner");
16208
+ plannerInstructions(nativeSearch, hidden) {
16209
+ const tools = ordewellToolNames("planner", hidden);
14518
16210
  return [
14519
- `You have ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
14520
- `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("planner"))}.`,
14521
- `Run shell commands with \`${this.toolName("run_command")}\`, not your own shell: yours is sandboxed without network, so git remotes, gh and cloud CLIs fail there.`
16211
+ `You have ${countOf(tools)} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
16212
+ `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("planner", hidden))}.`,
16213
+ `Run shell commands with \`${this.toolName("run_command")}\`, not your own shell: yours is sandboxed without network, so git remotes, gh and cloud CLIs fail there.`,
16214
+ `Fetch web pages with \`${this.toolName("fetch")}\`, not curl or your shell.`,
16215
+ nativeSearch ? "Search the web with your built-in web search." : `Your built-in web search is off for this session; search with \`${this.toolName("web_search")}\`.`
14522
16216
  ].join(" ");
14523
- }
16217
+ },
16218
+ plannerWebSearch: (nativeSearch) => nativeSearch ? "cached" : "disabled"
14524
16219
  };
14525
16220
 
14526
16221
  // src/services/harness/codexRpc.ts
@@ -14540,7 +16235,7 @@ var CodexRpc = class {
14540
16235
  }
14541
16236
  /** The request as the awaited form of {@link send}, for steps that wait on one answer and nothing else. */
14542
16237
  call(method, params) {
14543
- return new Promise((resolve6) => this.send(method, params, (response) => resolve6(response)));
16238
+ return new Promise((resolve9) => this.send(method, params, (response) => resolve9(response)));
14544
16239
  }
14545
16240
  /**
14546
16241
  * A request framed as the line to write, left to the caller to write — for
@@ -14641,6 +16336,34 @@ function shownToolName(item) {
14641
16336
  const tool2 = item.tool ?? "mcp_tool";
14642
16337
  return item.server === ORDEWELL_MCP_SERVER_NAME ? CODEX_ORDEWELL.toolName(tool2) : tool2;
14643
16338
  }
16339
+ function webSearchSummary(item) {
16340
+ const action = item.action;
16341
+ switch (action?.type) {
16342
+ case "search": {
16343
+ const queries = action.queries?.length ? action.queries : [action.query ?? item.query ?? ""];
16344
+ return queries.filter(Boolean).join("\n");
16345
+ }
16346
+ case "openPage":
16347
+ return `open ${action.url ?? "(no URL)"}`;
16348
+ case "findInPage":
16349
+ return `find ${JSON.stringify(action.pattern ?? "")} in ${action.url ?? "(no URL)"}`;
16350
+ default:
16351
+ return item.query ?? "";
16352
+ }
16353
+ }
16354
+ function webSearchArgs(item) {
16355
+ const action = item.action;
16356
+ switch (action?.type) {
16357
+ case "search":
16358
+ return action.queries?.length ? { queries: action.queries } : { query: action.query ?? item.query ?? "" };
16359
+ case "openPage":
16360
+ return { url: action.url ?? "" };
16361
+ case "findInPage":
16362
+ return { url: action.url ?? "", pattern: action.pattern ?? "" };
16363
+ default:
16364
+ return { query: item.query ?? "" };
16365
+ }
16366
+ }
14644
16367
  var REVIEW_DECISIONS = { allow: "accept", allowForTask: "acceptForSession", deny: "decline" };
14645
16368
  var TASK_APPROVALS = {
14646
16369
  "item/commandExecution/requestApproval": {
@@ -14813,6 +16536,8 @@ ${this.exitMessage()}`);
14813
16536
  ...threadConfig
14814
16537
  };
14815
16538
  }
16539
+ const nativeSearch = opts?.nativeSearch ?? false;
16540
+ const servers = { ...Object.fromEntries((opts?.projectServers ?? []).map((server4) => [server4.name, server4.definition])), ...config.mcp_servers };
14816
16541
  return {
14817
16542
  ...common,
14818
16543
  sandbox: "read-only",
@@ -14821,13 +16546,22 @@ ${this.exitMessage()}`);
14821
16546
  // changes it asks about are still declined: the sandbox holds, and commands go
14822
16547
  // through Ordewell's `run_command`.
14823
16548
  approvalPolicy: "on-request",
14824
- ...threadConfig,
16549
+ // Codex's search raises no request the policy could decide call by
16550
+ // call, so it is decided once here (ADR-0027).
16551
+ // The repository's own config never loads for a planner, so its servers
16552
+ // are only those approved, handed over as parsed (ADR-0028).
16553
+ config: {
16554
+ ...config,
16555
+ ...Object.keys(servers).length > 0 ? { mcp_servers: servers } : {},
16556
+ projects: opts ? untrustedProjects(opts.cwd) : {},
16557
+ web_search: CODEX_ORDEWELL.plannerWebSearch(nativeSearch)
16558
+ },
14825
16559
  // `developerInstructions` layers on top of Codex's own base prompt, the
14826
16560
  // way Claude Code's `--append-system-prompt` does. `baseInstructions`
14827
16561
  // replaces it — which takes Codex's description of its own tools with
14828
16562
  // it, and a planner that has forgotten it can read the workspace
14829
16563
  // answers from a web search instead.
14830
- developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions() : ""].filter(Boolean).join("\n\n") || void 0
16564
+ developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions(nativeSearch, opts.mcp.hidden) : ""].filter(Boolean).join("\n\n") || void 0
14831
16565
  };
14832
16566
  }
14833
16567
  /**
@@ -14840,8 +16574,8 @@ ${this.exitMessage()}`);
14840
16574
  return awaitAttach(async (left) => {
14841
16575
  const known = CODEX_ORDEWELL.attachState(this.mcpStartup);
14842
16576
  if (known !== "pending") return known;
14843
- const reported = new Promise((resolve6) => {
14844
- this.mcpStartupSettled = () => resolve6(CODEX_ORDEWELL.attachState(this.mcpStartup));
16577
+ const reported = new Promise((resolve9) => {
16578
+ this.mcpStartupSettled = () => resolve9(CODEX_ORDEWELL.attachState(this.mcpStartup));
14845
16579
  });
14846
16580
  const state = await settleWithin(reported, {
14847
16581
  timeoutMs: left,
@@ -14866,8 +16600,8 @@ ${this.exitMessage()}`);
14866
16600
  const turn = this.turn;
14867
16601
  if (!this.process || !turn) return Promise.resolve(false);
14868
16602
  this.interruptRequested = true;
14869
- const acknowledged = new Promise((resolve6) => {
14870
- this.whenNamed(turn, () => this.rpc.send("turn/interrupt", { threadId: this.threadId, turnId: turn.id }, (response) => resolve6(response.ok)));
16603
+ const acknowledged = new Promise((resolve9) => {
16604
+ this.whenNamed(turn, () => this.rpc.send("turn/interrupt", { threadId: this.threadId, turnId: turn.id }, (response) => resolve9(response.ok)));
14871
16605
  });
14872
16606
  return settleWithin(acknowledged, { timeoutMs, ended: this.processEnded, onTimeout: () => false, onEnded: () => false });
14873
16607
  }
@@ -14888,7 +16622,7 @@ ${this.exitMessage()}`);
14888
16622
  const turn = this.turn;
14889
16623
  if (!this.process || !turn) return Promise.resolve(false);
14890
16624
  const clientId = randomUUID2();
14891
- return new Promise((resolve6) => {
16625
+ return new Promise((resolve9) => {
14892
16626
  let settled = false;
14893
16627
  const steer = {
14894
16628
  id,
@@ -14897,7 +16631,7 @@ ${this.exitMessage()}`);
14897
16631
  if (settled) return;
14898
16632
  settled = true;
14899
16633
  if (!accepted) turn.steers.delete(clientId);
14900
- resolve6(accepted);
16634
+ resolve9(accepted);
14901
16635
  }
14902
16636
  };
14903
16637
  turn.steers.set(clientId, steer);
@@ -15246,16 +16980,16 @@ ${this.exitMessage()}`);
15246
16980
  holdMcpToolApproval(msg, method, params, emit) {
15247
16981
  if (method !== "mcpServer/elicitation/request") return false;
15248
16982
  const meta = params._meta;
15249
- const server = params.serverName;
15250
- if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server !== "string") return false;
16983
+ const server4 = params.serverName;
16984
+ if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server4 !== "string") return false;
15251
16985
  const asked = typeof params.message === "string" ? /run tool "([^"]+)"/.exec(params.message)?.[1] : void 0;
15252
- const tool2 = asked ?? this.mcpCallsInProgress.get(server);
16986
+ const tool2 = asked ?? this.mcpCallsInProgress.get(server4);
15253
16987
  if (!tool2) return false;
15254
16988
  const id = String(msg.id);
15255
16989
  const input = typeof meta.tool_params === "object" && meta.tool_params !== null ? meta.tool_params : {};
15256
- const scope = `mcp__${server}__${tool2}`;
16990
+ const scope = `mcp__${server4}__${tool2}`;
15257
16991
  this.openPermissions.set(id, { requestId: msg.id, method, params });
15258
- emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server } });
16992
+ emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server: server4 } });
15259
16993
  return true;
15260
16994
  }
15261
16995
  /** A tool item entering `inProgress` — announce the call so the timeline moves. */
@@ -15275,11 +17009,11 @@ ${this.exitMessage()}`);
15275
17009
  case "dynamicToolCall":
15276
17010
  emit({ type: "tool_call", id: item.id, name: item.tool ?? "tool", args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
15277
17011
  return;
17012
+ // Announced on completion instead: at start the query is still empty.
15278
17013
  case "webSearch":
15279
- emit({ type: "tool_call", id: item.id, name: "web_search", args: { query: item.query }, ...subagentId ? { subagentId } : {} });
15280
17014
  return;
15281
17015
  case "fileChange": {
15282
- const paths = (item.changes ?? []).map((change) => change.path).filter((path26) => !!path26);
17016
+ const paths = (item.changes ?? []).map((change) => change.path).filter((path31) => !!path31);
15283
17017
  this.fileChangePaths.set(item.id, paths);
15284
17018
  if (this.startOpts?.kind === "task") {
15285
17019
  for (const change of item.changes ?? []) this.announceFileChange(item.id, change, emit, subagentId);
@@ -15350,9 +17084,10 @@ ${item.text}` : item.text });
15350
17084
  });
15351
17085
  return;
15352
17086
  // The query is empty when the search starts and filled when it lands, so
15353
- // the result — not the call — is what carries what was actually searched.
17087
+ // the call is announced only now, with what was actually searched.
15354
17088
  case "webSearch":
15355
- emit({ type: "tool_result", id, name: "web_search", output: item.query ?? "", success: true, ...subagentId ? { subagentId } : {} });
17089
+ emit({ type: "tool_call", id, name: "web_search", args: webSearchArgs(item), ...subagentId ? { subagentId } : {} });
17090
+ emit({ type: "tool_result", id, name: "web_search", output: webSearchSummary(item), success: true, ...subagentId ? { subagentId } : {} });
15356
17091
  return;
15357
17092
  case "fileChange":
15358
17093
  this.fileChangePaths.delete(id);
@@ -15380,20 +17115,20 @@ ${item.text}` : item.text });
15380
17115
  announceFileChange(itemId, change, emit, subagentId) {
15381
17116
  const rows = this.fileChangeRows.get(itemId) ?? /* @__PURE__ */ new Map();
15382
17117
  this.fileChangeRows.set(itemId, rows);
15383
- const path26 = this.shownChangePath(change);
15384
- const known = rows.get(path26);
17118
+ const path31 = this.shownChangePath(change);
17119
+ const known = rows.get(path31);
15385
17120
  if (known) return known;
15386
- const row = rows.size === 0 ? itemId : `${itemId}:${path26}`;
15387
- rows.set(path26, row);
15388
- emit({ type: "tool_call", id: row, name: changeName(change), args: { path: path26 }, ...subagentId ? { subagentId } : {} });
17121
+ const row = rows.size === 0 ? itemId : `${itemId}:${path31}`;
17122
+ rows.set(path31, row);
17123
+ emit({ type: "tool_call", id: row, name: changeName(change), args: { path: path31 }, ...subagentId ? { subagentId } : {} });
15389
17124
  return row;
15390
17125
  }
15391
17126
  /** Relative to the task's worktree, where every path but a stray one lies; a move names both ends. */
15392
17127
  shownChangePath(change) {
15393
- const shown = (path26) => {
17128
+ const shown = (path31) => {
15394
17129
  const cwd = this.startOpts?.cwd;
15395
- const rel = cwd ? relative5(cwd, path26) : path26;
15396
- return rel && rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute4(rel) ? rel : path26;
17130
+ const rel = cwd ? relative8(cwd, path31) : path31;
17131
+ return rel && rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute5(rel) ? rel : path31;
15397
17132
  };
15398
17133
  const from = shown(change.path ?? "");
15399
17134
  return change.kind?.move_path ? `${from} \u2192 ${shown(change.kind.move_path)}` : from;
@@ -15439,6 +17174,87 @@ ${item.text}` : item.text });
15439
17174
  // src/services/harness/OpenCodeAdapter.ts
15440
17175
  import { randomBytes as randomBytes6 } from "crypto";
15441
17176
 
17177
+ // src/services/harness/projectServers.ts
17178
+ import { createHash as createHash2 } from "crypto";
17179
+ import * as path21 from "path";
17180
+ function canonical(value) {
17181
+ if (Array.isArray(value)) return value.map(canonical);
17182
+ if (!isRecord2(value)) return value;
17183
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
17184
+ }
17185
+ function projectServerScope(server4) {
17186
+ const hash = createHash2("sha256").update(JSON.stringify(canonical(server4.definition))).digest("hex").slice(0, 12);
17187
+ return `mcp-server:${server4.name}@${hash}`;
17188
+ }
17189
+ var scopeName = (scope) => scope.slice(0, scope.lastIndexOf("@"));
17190
+ function fileProjectServerApprovals(file = path21.join(globalDataDir(), "project-mcp-servers.json")) {
17191
+ const load = () => {
17192
+ const text = readText(file);
17193
+ if (text === void 0) return {};
17194
+ try {
17195
+ const parsed = JSON.parse(text);
17196
+ const repositories = isRecord2(parsed) && isRecord2(parsed.repositories) ? parsed.repositories : {};
17197
+ return Object.fromEntries(Object.entries(repositories).map(([repo, scopes]) => [repo, Array.isArray(scopes) ? scopes.filter((s) => typeof s === "string") : []]));
17198
+ } catch {
17199
+ return {};
17200
+ }
17201
+ };
17202
+ return {
17203
+ approved: (repoRoot2, scope) => load()[repoRoot2]?.includes(scope) ?? false,
17204
+ remember(repoRoot2, scope) {
17205
+ const repositories = load();
17206
+ repositories[repoRoot2] = [...(repositories[repoRoot2] ?? []).filter((s) => scopeName(s) !== scopeName(scope)), scope];
17207
+ writePrivateFile(file, `${JSON.stringify({ repositories }, null, 2)}
17208
+ `);
17209
+ }
17210
+ };
17211
+ }
17212
+ function describe(server4, scope) {
17213
+ const what = server4.transport === "stdio" ? `Starting it runs this command as you${server4.names.length ? `, setting ${server4.names.join(", ")}` : ""}.` : `Starting it connects to this URL${server4.names.length ? `, sending ${server4.names.join(", ")}` : ""}.`;
17214
+ return [
17215
+ `The repository declares this MCP server for the planner in ${server4.source}. ${what}`,
17216
+ "Any ${VAR} in it is filled in from your environment.",
17217
+ `Approving is remembered for this repository until the definition changes; the planner allowlist entry ${scope} starts it without asking.`
17218
+ ].join(" ");
17219
+ }
17220
+ function startRequest(server4) {
17221
+ const scope = projectServerScope(server4);
17222
+ return { kind: "mcp_server", subject: `${server4.name}: ${server4.target}`, scope, detail: describe(server4, scope) };
17223
+ }
17224
+ async function decideProjectServers(servers, opts) {
17225
+ const decision = { started: [], held: [] };
17226
+ for (const server4 of servers) {
17227
+ const req = startRequest(server4);
17228
+ const { scope } = req;
17229
+ const list = opts.approval?.allowlist?.();
17230
+ let started = false;
17231
+ if (list && scopeExcluded(list, scope)) {
17232
+ opts.approval?.record?.(req, false, "excluded");
17233
+ } else if (opts.approvals.approved(opts.repoRoot, scope)) {
17234
+ opts.approval?.record?.(req, true, "remembered");
17235
+ started = true;
17236
+ } else if (opts.approval) {
17237
+ const unasked = opts.approval.peek?.(req).decision === "allow";
17238
+ started = await opts.approval.request(req).catch(() => false);
17239
+ if (started && !unasked) opts.approvals.remember(opts.repoRoot, scope);
17240
+ }
17241
+ (started ? decision.started : decision.held).push(server4);
17242
+ }
17243
+ return decision;
17244
+ }
17245
+ function recordUnusable(servers, approval) {
17246
+ for (const server4 of servers) approval?.record?.(startRequest(server4), false, "unsupported");
17247
+ }
17248
+ var NOT_APPROVED = "not approved for planning";
17249
+ function heldServersNote(held, unreadable = []) {
17250
+ const lines = [
17251
+ ...held.map(({ server: server4, reason }) => `- ${server4.name} (${server4.source}): ${reason}`),
17252
+ ...unreadable.map((file) => `- ${file.source}: Ordewell could not read it (${file.reason})`)
17253
+ ];
17254
+ if (lines.length === 0) return "";
17255
+ return ["The repository declares MCP servers whose tools are not available to you while planning. Work that needs them belongs in the plan's tasks.", ...lines].join("\n");
17256
+ }
17257
+
15442
17258
  // src/services/harness/openCodeTransport.ts
15443
17259
  import { randomBytes as randomBytes5 } from "crypto";
15444
17260
  var STREAM_CONNECT_TIMEOUT_MS = 5e3;
@@ -15477,9 +17293,9 @@ var OPENCODE_BUILTINS = /* @__PURE__ */ new Set([
15477
17293
  function openCodePlannerAsk(permission, details, servers) {
15478
17294
  const meta = details.metadata ?? {};
15479
17295
  const pattern = details.patterns?.[0];
15480
- if (permission === "webfetch") {
15481
- const url = typeof meta.url === "string" ? meta.url : pattern;
15482
- return url ? { kind: "fetch", url } : { kind: "other" };
17296
+ if (permission === "websearch" || permission === "codesearch") {
17297
+ const query = typeof meta.query === "string" ? meta.query : pattern;
17298
+ return query ? { kind: "search", query, ...OPENCODE_SEARCH_PROVIDER } : { kind: "other" };
15483
17299
  }
15484
17300
  if (permission === "external_directory") {
15485
17301
  if (typeof meta.filepath === "string") return { kind: "path", path: meta.filepath, directory: false };
@@ -15487,9 +17303,13 @@ function openCodePlannerAsk(permission, details, servers) {
15487
17303
  return dir ? { kind: "path", path: dir, directory: true } : { kind: "other" };
15488
17304
  }
15489
17305
  if (OPENCODE_BUILTINS.has(permission)) return { kind: "other" };
15490
- const server = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
15491
- if (!server) return { kind: "other" };
15492
- return { kind: "mcp", scope: permission, tool: permission.slice(server.length + 1), server };
17306
+ const server4 = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
17307
+ if (!server4) return { kind: "other" };
17308
+ return { kind: "mcp", scope: permission, tool: permission.slice(server4.length + 1), server: server4 };
17309
+ }
17310
+ function plannerSystem(opts, ownSearch = true) {
17311
+ const system = [opts.systemPrompt, opts.mcp ? OPENCODE_ORDEWELL.plannerInstructions(ownSearch) : ""].filter(Boolean).join("\n\n");
17312
+ return system ? { system } : {};
15493
17313
  }
15494
17314
  function splitModelId(id) {
15495
17315
  const slash = id.indexOf("/");
@@ -15497,14 +17317,14 @@ function splitModelId(id) {
15497
17317
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
15498
17318
  }
15499
17319
  function delay(ms, signal) {
15500
- return new Promise((resolve6) => {
17320
+ return new Promise((resolve9) => {
15501
17321
  const onAbort = () => {
15502
17322
  clearTimeout(timer);
15503
- resolve6();
17323
+ resolve9();
15504
17324
  };
15505
17325
  const timer = setTimeout(() => {
15506
17326
  signal?.removeEventListener("abort", onAbort);
15507
- resolve6();
17327
+ resolve9();
15508
17328
  }, ms);
15509
17329
  timer.unref?.();
15510
17330
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -15706,8 +17526,8 @@ function streamTurn() {
15706
17526
  }
15707
17527
  function turnLatch() {
15708
17528
  let markDone;
15709
- const ended = new Promise((resolve6) => {
15710
- markDone = resolve6;
17529
+ const ended = new Promise((resolve9) => {
17530
+ markDone = resolve9;
15711
17531
  });
15712
17532
  const turn = { live: false, done: false, ended, finish: () => {
15713
17533
  turn.done = true;
@@ -15717,9 +17537,9 @@ function turnLatch() {
15717
17537
  }
15718
17538
  async function settleTurn(turn, how, onEvent) {
15719
17539
  const { signal } = how;
15720
- const aborted = new Promise((resolve6) => {
15721
- if (signal?.aborted) resolve6();
15722
- signal?.addEventListener("abort", () => resolve6(), { once: true });
17540
+ const aborted = new Promise((resolve9) => {
17541
+ if (signal?.aborted) resolve9();
17542
+ signal?.addEventListener("abort", () => resolve9(), { once: true });
15723
17543
  });
15724
17544
  await Promise.race([turn.ended, aborted, how.processEnded]);
15725
17545
  if (signal?.aborted) return false;
@@ -15738,8 +17558,8 @@ async function interruptAcknowledged(turn, processEnded, timeoutMs) {
15738
17558
  async function openEventStream(connect, onFrame, onActivity) {
15739
17559
  const streamAbort = new AbortController();
15740
17560
  let connected;
15741
- const ready = new Promise((resolve6) => {
15742
- connected = resolve6;
17561
+ const ready = new Promise((resolve9) => {
17562
+ connected = resolve9;
15743
17563
  });
15744
17564
  const live = streamEvents(connect, streamAbort.signal, onFrame, connected, onActivity);
15745
17565
  await settleWithin(ready, { timeoutMs: STREAM_CONNECT_TIMEOUT_MS, onTimeout: () => void 0 });
@@ -15775,7 +17595,35 @@ async function streamEvents(connect, signal, onFrame, onConnected, onActivity) {
15775
17595
  var ACTIVE_POLL_INTERVAL_MS = 1e3;
15776
17596
  var IDLE_POLLS_BEFORE_START = 3;
15777
17597
  var PLANNER_RULES = Object.entries(OPENCODE_PLANNER_PERMISSION).map(([action, effect]) => ({ action, resource: "*", effect }));
17598
+ var SEARCH_OFF_RULES = Object.entries(OPENCODE_SEARCH_OFF).map(([action, effect]) => ({ action, resource: "*", effect }));
17599
+ var CODE_MODE_OFF_RULES = [{ action: "execute", resource: "*", effect: "deny" }];
17600
+ function codeModeOnly(version) {
17601
+ return `OpenCode${version ? ` ${version}` : ""} cannot run the planner safely: it can only offer Ordewell's tools inside code mode, which runs code the planner writes without asking. The planner needs an OpenCode version that can list MCP tools outside code mode. Update OpenCode, or choose another planner runner.`;
17602
+ }
17603
+ var UNUSABLE = "not usable on this OpenCode version: its tools are only reachable through code mode's `execute`, which the planner may not use";
15778
17604
  var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
17605
+ function isRecord3(value) {
17606
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17607
+ }
17608
+ function searchProviderChosen(config) {
17609
+ const layers = Array.isArray(config) ? config : isRecord3(config) && Array.isArray(config.data) ? config.data : [];
17610
+ for (let i = layers.length - 1; i >= 0; i--) {
17611
+ const layer = layers[i];
17612
+ if (!isRecord3(layer) || layer.type !== "document" || !isRecord3(layer.info) || layer.info.websearch === void 0) continue;
17613
+ const setting = layer.info.websearch;
17614
+ return isRecord3(setting) && typeof setting.provider === "string" && setting.provider !== "";
17615
+ }
17616
+ return false;
17617
+ }
17618
+ function rulesDeny(rules, action) {
17619
+ if (!Array.isArray(rules)) return false;
17620
+ const last = [...rules].reverse().find((rule) => isRecord3(rule) && rule.action === action && (rule.resource === "*" || rule.resource === void 0));
17621
+ return isRecord3(last) && last.effect === "deny";
17622
+ }
17623
+ function transportFields(definition) {
17624
+ const fields = definition.type === "remote" ? ["type", "url", "headers"] : ["type", "command", "environment"];
17625
+ return Object.fromEntries(fields.filter((field) => definition[field] !== void 0).map((field) => [field, definition[field]]));
17626
+ }
15779
17627
  function newTurnState() {
15780
17628
  return { ...streamTurn(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
15781
17629
  }
@@ -15813,6 +17661,12 @@ var OpenCodeV2 = class {
15813
17661
  agent;
15814
17662
  turn = null;
15815
17663
  interruptRequested = false;
17664
+ /** Whether a planner's own `websearch` is on for its session (ADR-0027). */
17665
+ ownSearch = true;
17666
+ /** Approved project servers whose tools are listed as tools of their own. */
17667
+ exposed = [];
17668
+ /** Approved project servers whose tools this OpenCode would list only inside `execute`, which a planner may not use. */
17669
+ unusable = [];
15816
17670
  permissions = new OpenCodePermissions(
15817
17671
  (id, sessionId, decision) => this.replyPermission(id, sessionId, decision),
15818
17672
  () => this.opts.mcp
@@ -15820,13 +17674,18 @@ var OpenCodeV2 = class {
15820
17674
  nativeSessionId() {
15821
17675
  return this.sessionId;
15822
17676
  }
17677
+ unusableProjectServers() {
17678
+ return [...this.unusable];
17679
+ }
15823
17680
  async start() {
15824
- await this.exposeOrdewellTools();
17681
+ await this.exposeServers();
15825
17682
  const resume = this.opts.resumeSessionId;
15826
17683
  if (resume) {
15827
17684
  const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
15828
- if (existing?.data?.id) {
17685
+ const resumable = this.role === "task" || rulesDeny(existing?.data?.permissions, "execute");
17686
+ if (existing?.data?.id && resumable) {
15829
17687
  this.sessionId = existing.data.id;
17688
+ if (this.role === "planner") this.ownSearch = !rulesDeny(existing.data.permissions, "websearch");
15830
17689
  if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
15831
17690
  await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
15832
17691
  await this.applyInstructions();
@@ -15834,10 +17693,11 @@ var OpenCodeV2 = class {
15834
17693
  }
15835
17694
  if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
15836
17695
  }
17696
+ if (this.role === "planner") this.ownSearch = searchProviderChosen(await this.host.json("GET", "/api/config").catch(() => null));
15837
17697
  const created = await this.host.json("POST", "/api/session", {
15838
17698
  agent: this.agent,
15839
17699
  ...this.model ? { model: this.model } : {},
15840
- permissions: [...this.role === "planner" ? PLANNER_RULES : TASK_RULES, ...this.ordewellRules()]
17700
+ permissions: [...this.role === "planner" ? [...PLANNER_RULES, ...this.ownSearch ? [] : SEARCH_OFF_RULES, ...CODE_MODE_OFF_RULES] : TASK_RULES, ...this.ordewellRules()]
15841
17701
  });
15842
17702
  if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
15843
17703
  this.sessionId = created.data.id;
@@ -15845,16 +17705,31 @@ var OpenCodeV2 = class {
15845
17705
  }
15846
17706
  /**
15847
17707
  * 2.x lists an MCP server's tools inside its code-mode `execute` tool, where
15848
- * a model told to call `task_complete` does not look for it. The server is
15849
- * replaced at runtime with `codemode: false`, which lists its tools as tools
15850
- * of their own: the config it started with is in the 1.x shape both versions
15851
- * read, and that shape drops the setting. If the route is gone, the tools
15852
- * are still reachable through `execute`.
15853
- */
15854
- async exposeOrdewellTools() {
17708
+ * a model told to call `task_complete` does not look for it, and where a
17709
+ * planner may not reach them: `execute` runs model-written code over every
17710
+ * tool at once, past per-tool approval. Each server is replaced at runtime
17711
+ * with `codemode: false`, which lists its tools as tools of their own: the
17712
+ * config it started with is in the 1.x shape both versions read, and that
17713
+ * shape drops the setting. Replaced before anything lists the servers, a
17714
+ * local one starts once; replaced later, OpenCode stops it and starts the
17715
+ * new one. If the route is gone, Ordewell's tools are reachable only
17716
+ * through `execute`: a task keeps that, but a planner would have to choose
17717
+ * between an unchecked code runner and no way to submit a plan, so it is
17718
+ * not started. A project server's tools are then not reachable at all.
17719
+ */
17720
+ async exposeServers() {
15855
17721
  const mcp = this.opts.mcp;
15856
- if (!mcp) return;
15857
- await this.host.request("PUT", `/api/experimental/mcp/${mcp.name}`, { config: { type: "remote", url: mcp.url, headers: mcp.headers, codemode: false } }).catch(() => void 0);
17722
+ if (mcp && !await this.expose(mcp.name, { type: "remote", url: mcp.url, headers: mcp.headers }) && this.role === "planner") {
17723
+ throw new Error(codeModeOnly(this.host.version));
17724
+ }
17725
+ const project = this.opts.kind === "planner" ? this.opts.projectServers ?? [] : [];
17726
+ for (const server4 of project) {
17727
+ if (await this.expose(server4.name, transportFields(server4.definition))) this.exposed.push(server4.name);
17728
+ else this.unusable.push(server4);
17729
+ }
17730
+ }
17731
+ async expose(name, config) {
17732
+ return this.host.request("PUT", `/api/experimental/mcp/${name}`, { config: { ...config, codemode: false } }).then((response) => response.ok, () => false);
15858
17733
  }
15859
17734
  /** Allow the Ordewell server's tools, so a call never waits on a person (ADR-0022, S3). */
15860
17735
  ordewellRules() {
@@ -15862,8 +17737,11 @@ var OpenCodeV2 = class {
15862
17737
  }
15863
17738
  /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
15864
17739
  async applyInstructions() {
15865
- if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
15866
- const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
17740
+ if (this.opts.kind !== "planner") return;
17741
+ const unusable = heldServersNote(this.unusable.map((server4) => ({ server: server4, reason: UNUSABLE })));
17742
+ const system = [plannerSystem(this.opts, this.ownSearch).system, unusable].filter(Boolean).join("\n\n");
17743
+ if (!system) return;
17744
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: system });
15867
17745
  if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
15868
17746
  }
15869
17747
  async send(message, onEvent, signal, onActivity) {
@@ -16121,7 +17999,8 @@ var OpenCodeV2 = class {
16121
17999
  const request = this.permissionRequest(ask);
16122
18000
  if (!request) return;
16123
18001
  if (this.role !== "task") {
16124
- const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, this.host.mcpServers?.() ?? []);
18002
+ const listed2 = (this.host.mcpServers?.() ?? []).filter((name) => !this.unusable.some((server4) => server4.name === name));
18003
+ const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, [...listed2, ...this.exposed]);
16125
18004
  this.permissions.ask({ ...request, ask: target }, session, false, state.seen, onEvent);
16126
18005
  } else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
16127
18006
  }
@@ -16249,12 +18128,14 @@ var OpenCodeAdapter = class {
16249
18128
  args: ["serve", "--hostname", "127.0.0.1", "--port", "0"],
16250
18129
  env: (workspaceEnv) => {
16251
18130
  const existing = workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE];
16252
- const ordewellConfig = opts.kind === "planner" ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp ?? null, "planner") ?? OPENCODE_ORDEWELL.configContent(void 0, opts.mcp ?? null, "planner") : opts.mcp ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp, "task") : null;
18131
+ const projectServers = opts.kind === "planner" ? Object.fromEntries((opts.projectServers ?? []).map((server4) => [server4.name, server4.definition])) : {};
18132
+ const ordewellConfig = opts.kind === "planner" ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp ?? null, "planner", projectServers) ?? OPENCODE_ORDEWELL.configContent(void 0, opts.mcp ?? null, "planner", projectServers) : opts.mcp ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp, "task") : null;
16253
18133
  if (opts.mcp && ordewellConfig === null) {
16254
18134
  console.error("[opencode] OPENCODE_CONFIG_CONTENT is not a JSON object, so the Ordewell tools were not injected.");
16255
18135
  }
16256
18136
  this.ordewell = ordewellConfig === null ? null : opts.mcp ?? null;
16257
18137
  return {
18138
+ ...opts.kind === "planner" ? OPENCODE_PLANNER_ENV : {},
16258
18139
  ...ordewellConfig === null ? {} : { [OPENCODE_CONFIG_VARIABLE]: ordewellConfig },
16259
18140
  OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
16260
18141
  OPENCODE_SERVER_PASSWORD: password
@@ -16274,7 +18155,7 @@ var OpenCodeAdapter = class {
16274
18155
  * only until the address is known.
16275
18156
  */
16276
18157
  serverAddress() {
16277
- return new Promise((resolve6) => {
18158
+ return new Promise((resolve9) => {
16278
18159
  let partial = "";
16279
18160
  const stopReading = this.runner.readStdout((text) => {
16280
18161
  const lines = (partial + text).split("\n");
@@ -16289,7 +18170,7 @@ var OpenCodeAdapter = class {
16289
18170
  const settle = (url) => {
16290
18171
  stopReading();
16291
18172
  clearTimeout(timer);
16292
- resolve6(url);
18173
+ resolve9(url);
16293
18174
  };
16294
18175
  void this.processEnded.then(() => settle(null));
16295
18176
  });
@@ -16300,10 +18181,12 @@ var OpenCodeAdapter = class {
16300
18181
  if (!this.baseUrl) {
16301
18182
  throw new Error(this.runner.withStderrTail(`The OpenCode ${this.role()} server did not start.`));
16302
18183
  }
16303
- if (await this.speaksV2()) {
18184
+ const version = await this.v2Version();
18185
+ if (version !== null) {
16304
18186
  this.v2 = new OpenCodeV2({
16305
- request: (method, path26, body, signal) => this.request(method, path26, body, signal),
16306
- json: (method, path26, body, signal) => this.json(method, path26, body, signal),
18187
+ version,
18188
+ request: (method, path31, body, signal) => this.request(method, path31, body, signal),
18189
+ json: (method, path31, body, signal) => this.json(method, path31, body, signal),
16307
18190
  openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
16308
18191
  processEnded: this.processEnded,
16309
18192
  isExited: () => this.exited,
@@ -16353,7 +18236,7 @@ var OpenCodeAdapter = class {
16353
18236
  tools: DISABLED_TOOLS,
16354
18237
  ...model ? { model } : {},
16355
18238
  ...this.planner?.effort ? { variant: this.planner.effort } : {},
16356
- ...this.planner?.systemPrompt ? { system: this.planner.systemPrompt } : {}
18239
+ ...this.planner ? plannerSystem(this.planner) : {}
16357
18240
  };
16358
18241
  const reply2 = await this.json("POST", `/session/${this.sessionId}/message`, body, signal);
16359
18242
  if (signal?.aborted) {
@@ -16569,9 +18452,9 @@ var OpenCodeAdapter = class {
16569
18452
  * only 2.x answers `/api/info` with its version. A 1.x server — or one that
16570
18453
  * answers with anything else — keeps the 1.x protocol this class speaks.
16571
18454
  */
16572
- async speaksV2() {
18455
+ async v2Version() {
16573
18456
  const info = await this.json("GET", "/api/info").catch(() => null);
16574
- return typeof info === "object" && info !== null && typeof info.version === "string";
18457
+ return typeof info === "object" && info !== null && typeof info.version === "string" ? info.version : null;
16575
18458
  }
16576
18459
  role() {
16577
18460
  return this.task ? "task" : "planner";
@@ -16814,13 +18697,13 @@ var OpenCodeAdapter = class {
16814
18697
  onActivity
16815
18698
  );
16816
18699
  }
16817
- async json(method, path26, body, signal) {
16818
- const response = await this.request(method, path26, body, signal);
16819
- if (!response.ok) throw new Error(`${method} ${path26} failed: ${response.status} ${response.statusText}`);
18700
+ async json(method, path31, body, signal) {
18701
+ const response = await this.request(method, path31, body, signal);
18702
+ if (!response.ok) throw new Error(`${method} ${path31} failed: ${response.status} ${response.statusText}`);
16820
18703
  return await response.json().catch(() => null);
16821
18704
  }
16822
- request(method, path26, body, signal) {
16823
- return this.deps.fetch(`${this.baseUrl}${path26}`, {
18705
+ request(method, path31, body, signal) {
18706
+ return this.deps.fetch(`${this.baseUrl}${path31}`, {
16824
18707
  method,
16825
18708
  headers: this.headers({ "content-type": "application/json" }),
16826
18709
  ...body === void 0 ? {} : { body: JSON.stringify(body) },
@@ -16833,6 +18716,9 @@ var OpenCodeAdapter = class {
16833
18716
  nativeSessionId() {
16834
18717
  return this.sessionId;
16835
18718
  }
18719
+ unusableProjectServers() {
18720
+ return this.v2?.unusableProjectServers() ?? [];
18721
+ }
16836
18722
  /** Asks the server, which lists every MCP server it has with the state of its connection. */
16837
18723
  async mcpAttached() {
16838
18724
  const ordewell = this.ordewell;
@@ -16867,11 +18753,68 @@ var OpenCodeAdapter = class {
16867
18753
  }
16868
18754
  };
16869
18755
 
18756
+ // src/services/harness/openCodeRepoConfig.ts
18757
+ import * as path22 from "path";
18758
+ var CONFIG_FILES = ["opencode.json", "opencode.jsonc", path22.join(".opencode", "opencode.json"), path22.join(".opencode", "opencode.jsonc")];
18759
+ var INSTRUCTION_NAMES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
18760
+ function server3(name, entry, source) {
18761
+ if (!isRecord2(entry)) return "not an object";
18762
+ if (entry.enabled === false) return null;
18763
+ if (entry.type === "remote") {
18764
+ if (typeof entry.url !== "string") return "no url";
18765
+ return { name, source, transport: "remote", target: entry.url, names: isRecord2(entry.headers) ? Object.keys(entry.headers) : [], definition: entry };
18766
+ }
18767
+ const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
18768
+ if (entry.type !== "local" || command.length === 0) return "no command or url";
18769
+ return { name, source, transport: "stdio", target: command.join(" "), names: isRecord2(entry.environment) ? Object.keys(entry.environment) : [], definition: entry };
18770
+ }
18771
+ var OPENCODE_REPO_CONFIG = {
18772
+ instructions(workspace) {
18773
+ const levels = repoLevels(workspace);
18774
+ for (const name of INSTRUCTION_NAMES) {
18775
+ const files = levels.flatMap((dir) => {
18776
+ const text = readText(path22.join(dir, name));
18777
+ return text === void 0 ? [] : [{ path: path22.relative(workspace, path22.join(dir, name)) || name, text }];
18778
+ });
18779
+ if (files.length > 0) return files;
18780
+ }
18781
+ return [];
18782
+ },
18783
+ /** Every level's config, the one nearer the workspace winning for a server both name. */
18784
+ projectServers(workspace) {
18785
+ const byName = /* @__PURE__ */ new Map();
18786
+ const unreadable = [];
18787
+ for (const dir of repoLevels(workspace)) {
18788
+ for (const name of CONFIG_FILES) {
18789
+ const file = path22.join(dir, name);
18790
+ const text = readText(file);
18791
+ if (text === void 0) continue;
18792
+ const source = path22.relative(workspace, file);
18793
+ let parsed;
18794
+ try {
18795
+ parsed = parseJsonc(text);
18796
+ } catch (err) {
18797
+ unreadable.push({ source, reason: err instanceof Error ? err.message : String(err) });
18798
+ continue;
18799
+ }
18800
+ const entries = isRecord2(parsed) && isRecord2(parsed.mcp) ? parsed.mcp : {};
18801
+ for (const [serverName, entry] of Object.entries(entries)) {
18802
+ const read2 = server3(serverName, entry, source);
18803
+ if (typeof read2 === "string") unreadable.push({ source: `${source} (${serverName})`, reason: read2 });
18804
+ else if (read2) byName.set(serverName, read2);
18805
+ else byName.delete(serverName);
18806
+ }
18807
+ }
18808
+ }
18809
+ return { servers: [...byName.values()], unreadable };
18810
+ }
18811
+ };
18812
+
16870
18813
  // src/services/harness/connectors.ts
16871
18814
  var CONNECTORS = {
16872
- "claude-code": { create: (deps) => new ClaudeCodeAdapter(deps), ordewellTools: CLAUDE_ORDEWELL },
16873
- codex: { create: (deps) => new CodexAdapter(deps), ordewellTools: CODEX_ORDEWELL },
16874
- opencode: { create: (deps) => new OpenCodeAdapter(deps), ordewellTools: OPENCODE_ORDEWELL }
18815
+ "claude-code": { create: (deps) => new ClaudeCodeAdapter(deps), ordewellTools: CLAUDE_ORDEWELL, repoConfig: CLAUDE_REPO_CONFIG },
18816
+ codex: { create: (deps) => new CodexAdapter(deps), ordewellTools: CODEX_ORDEWELL, repoConfig: CODEX_REPO_CONFIG },
18817
+ opencode: { create: (deps) => new OpenCodeAdapter(deps), ordewellTools: OPENCODE_ORDEWELL, repoConfig: OPENCODE_REPO_CONFIG }
16875
18818
  };
16876
18819
  function connectorFor(runner) {
16877
18820
  return Object.hasOwn(CONNECTORS, runner) ? CONNECTORS[runner] : void 0;
@@ -17069,7 +19012,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
17069
19012
  this.notifications.warn(message);
17070
19013
  };
17071
19014
  if (resolved.blockedEnvrc) {
17072
- warn(`blocked:${resolved.blockedEnvrc}`, `direnv has blocked ${resolved.blockedEnvrc}, so tasks start without its variables. Run \`direnv allow\` in ${path18.dirname(resolved.blockedEnvrc)} to use them.`);
19015
+ warn(`blocked:${resolved.blockedEnvrc}`, `direnv has blocked ${resolved.blockedEnvrc}, so tasks start without its variables. Run \`direnv allow\` in ${path23.dirname(resolved.blockedEnvrc)} to use them.`);
17073
19016
  }
17074
19017
  if (resolved.trackedEnvFile) {
17075
19018
  warn(`tracked:${resolved.trackedEnvFile}`, `Ignored ${resolved.trackedEnvFile}: git tracks it, and a committed file must not choose the environment agents run in. Untrack it to use it.`);
@@ -18072,7 +20015,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
18072
20015
  uncommittedSkillFolder(name) {
18073
20016
  const root = this.workspaceRootFn();
18074
20017
  const skill = this.skillsAt(this.skillRoots(root)).findSkill(name);
18075
- return skill?.source === "workspace" ? path18.relative(root, path18.dirname(skill.path)).split(path18.sep).join("/") : void 0;
20018
+ return skill?.source === "workspace" ? path23.relative(root, path23.dirname(skill.path)).split(path23.sep).join("/") : void 0;
18076
20019
  }
18077
20020
  /**
18078
20021
  * The roots whose `.ordewell/skills/` an attempt at `cwd` reads: in a repo
@@ -18311,7 +20254,7 @@ ${errors.map((e) => `- ${e}`).join("\n")}`
18311
20254
  };
18312
20255
 
18313
20256
  // src/services/ModelDiscovery.ts
18314
- import * as path19 from "path";
20257
+ import * as path24 from "path";
18315
20258
  import { exec as exec3, spawn } from "child_process";
18316
20259
  import { promisify as promisify4 } from "util";
18317
20260
  import * as fsSync3 from "fs";
@@ -18326,7 +20269,7 @@ var defaultAppServerClient = async (command, args, options) => {
18326
20269
  const PATH = await augmentedPath();
18327
20270
  const launch = await planDirectLaunch(command, args).catch(() => null);
18328
20271
  if (!launch) return null;
18329
- return new Promise((resolve6) => {
20272
+ return new Promise((resolve9) => {
18330
20273
  let child;
18331
20274
  try {
18332
20275
  child = spawn(launch.file, launch.args, {
@@ -18335,7 +20278,7 @@ var defaultAppServerClient = async (command, args, options) => {
18335
20278
  windowsVerbatimArguments: launch.verbatim
18336
20279
  });
18337
20280
  } catch {
18338
- resolve6(null);
20281
+ resolve9(null);
18339
20282
  return;
18340
20283
  }
18341
20284
  let settled = false;
@@ -18344,7 +20287,7 @@ var defaultAppServerClient = async (command, args, options) => {
18344
20287
  settled = true;
18345
20288
  clearTimeout(timer);
18346
20289
  killTree(child);
18347
- resolve6(value);
20290
+ resolve9(value);
18348
20291
  };
18349
20292
  const timer = setTimeout(() => finish(null), options?.timeout ?? 2e4);
18350
20293
  child.on("error", () => finish(null));
@@ -18411,7 +20354,7 @@ function geminiDiscoveryCacheKey(apiKey) {
18411
20354
  return {
18412
20355
  key: `gemini-models:${hash}`,
18413
20356
  dir,
18414
- file: path19.join(dir, `gemini_models_${hash}.json`)
20357
+ file: path24.join(dir, `gemini_models_${hash}.json`)
18415
20358
  };
18416
20359
  }
18417
20360
  function readGeminiCache(apiKey) {
@@ -18608,10 +20551,10 @@ async function discoverFromCommand(manifest, execImpl) {
18608
20551
  return { models: applyVariants(buildFallbackModels(manifest), manifest), fromFallback: true };
18609
20552
  }
18610
20553
  function mergeCanonicalAliases(discovered, manifest) {
18611
- const canonical = manifest.modelDiscovery.canonicalAliases;
18612
- if (!canonical || canonical.length === 0) return discovered;
20554
+ const canonical2 = manifest.modelDiscovery.canonicalAliases;
20555
+ if (!canonical2 || canonical2.length === 0) return discovered;
18613
20556
  const seen = new Set(discovered.map((m) => m.modelId));
18614
- const gaps = canonical.filter((f) => !seen.has(f.modelId)).map((f) => ({
20557
+ const gaps = canonical2.filter((f) => !seen.has(f.modelId)).map((f) => ({
18615
20558
  modelId: f.modelId,
18616
20559
  modelLabel: f.modelLabel,
18617
20560
  runnerProvider: f.modelId.includes("/") ? f.modelId.split("/")[0] : void 0,
@@ -18935,7 +20878,7 @@ var ModelDiscovery = class {
18935
20878
 
18936
20879
  // src/services/ModelCatalog.ts
18937
20880
  import * as fs11 from "fs";
18938
- import * as path20 from "path";
20881
+ import * as path25 from "path";
18939
20882
  var CACHE_KEY = "model-catalog";
18940
20883
  var CACHE_TTL_MS = 60 * 60 * 1e3;
18941
20884
  function storageKey(baseUrl) {
@@ -18987,7 +20930,7 @@ var ModelCatalog = class {
18987
20930
  const url = (baseUrl || "https://openrouter.ai/api/v1").replace(/\/+$/, "");
18988
20931
  const key = storageKey(url);
18989
20932
  const cacheDir = globalCacheDir();
18990
- const cacheFile = path20.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
20933
+ const cacheFile = path25.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
18991
20934
  if (fs11.existsSync(cacheFile)) fs11.rmSync(cacheFile);
18992
20935
  } catch {
18993
20936
  }
@@ -18995,7 +20938,7 @@ var ModelCatalog = class {
18995
20938
  static readCache(key) {
18996
20939
  try {
18997
20940
  const cacheDir = globalCacheDir();
18998
- const cacheFile = path20.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
20941
+ const cacheFile = path25.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
18999
20942
  if (!fs11.existsSync(cacheFile)) return null;
19000
20943
  const raw = fs11.readFileSync(cacheFile, "utf8");
19001
20944
  const data = JSON.parse(raw);
@@ -19009,7 +20952,7 @@ var ModelCatalog = class {
19009
20952
  try {
19010
20953
  const cacheDir = globalCacheDir();
19011
20954
  fs11.mkdirSync(cacheDir, { recursive: true });
19012
- const cacheFile = path20.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
20955
+ const cacheFile = path25.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
19013
20956
  fs11.writeFileSync(
19014
20957
  cacheFile,
19015
20958
  JSON.stringify({ models, cachedAt: Date.now() }, null, 2)
@@ -19429,6 +21372,14 @@ function plannerToolHandler(host) {
19429
21372
  const outcome = await host.runCommand(command, signal);
19430
21373
  return { text: outcome.output || (outcome.success ? "(no output)" : "The command failed with no output."), ...outcome.success ? {} : { isError: true } };
19431
21374
  },
21375
+ async fetch({ url }) {
21376
+ return outcomeReply(await host.fetchUrl(url));
21377
+ },
21378
+ async webSearch({ query }) {
21379
+ const outcome = await host.webSearch(query);
21380
+ if (!outcome) return { isError: true, text: "Web search is not available in this session. If you already know a documentation URL, fetch it instead." };
21381
+ return outcomeReply(outcome);
21382
+ },
19432
21383
  async listRunners() {
19433
21384
  const catalog = await host.liveCatalog();
19434
21385
  return answer({
@@ -19583,6 +21534,9 @@ function catalogDetail(catalog) {
19583
21534
  })
19584
21535
  };
19585
21536
  }
21537
+ function outcomeReply(outcome) {
21538
+ return { text: outcome.output || (outcome.success ? "(nothing returned)" : "The read failed with no message."), ...outcome.success ? {} : { isError: true } };
21539
+ }
19586
21540
  function answer(body) {
19587
21541
  return { text: JSON.stringify(body) };
19588
21542
  }
@@ -19679,15 +21633,28 @@ var TurnStream = class {
19679
21633
  };
19680
21634
 
19681
21635
  // src/services/harness/plannerPermissions.ts
19682
- var READ_ONLY_DENIAL = "The Ordewell planner is read-only: it changes no files. Use run_command for commands; changes belong to the plan's tasks.";
21636
+ import { realpathSync as realpathSync4 } from "fs";
21637
+ var UNKNOWN_TOOL_DENIAL = "The Ordewell planner is read-only and its envelope has no rule for this tool, so it did not run. Read with the read tools, use run_command for commands and fetch or web_search for the web; changes belong to the plan's tasks.";
19683
21638
  var OPS_HINT = "Continue without it. If it would change something outside the repository, add it to the plan as an ops task; if it only reads, ask the user to approve it or add it to the planner allowlist.";
19684
21639
  var ALLOW2 = { decision: "allow" };
19685
21640
  var SUBJECT_MAX_CHARS = 400;
19686
21641
  function deny(note) {
19687
21642
  return { decision: "deny", note };
19688
21643
  }
21644
+ function onDisk2(target) {
21645
+ try {
21646
+ return realpathSync4.native(target);
21647
+ } catch {
21648
+ return void 0;
21649
+ }
21650
+ }
21651
+ function readTarget(root, target) {
21652
+ const written = resolveWithin(root, target);
21653
+ const real = onDisk2(written.abs);
21654
+ return real === void 0 ? written : resolveWithin(onDisk2(root) ?? root, real);
21655
+ }
19689
21656
  async function decidePlannerPermission(ask, input, gate) {
19690
- if (!ask || ask.kind === "other") return deny(READ_ONLY_DENIAL);
21657
+ if (!ask || ask.kind === "other") return deny(ask?.note ?? UNKNOWN_TOOL_DENIAL);
19691
21658
  if (!gate) return deny("Nobody can approve this here. Continue without it.");
19692
21659
  switch (ask.kind) {
19693
21660
  case "mcp": {
@@ -19702,11 +21669,25 @@ async function decidePlannerPermission(ask, input, gate) {
19702
21669
  });
19703
21670
  return granted ? ALLOW2 : deny(`Not approved: ${ask.scope}. ${OPS_HINT}`);
19704
21671
  }
19705
- case "fetch":
21672
+ case "fetch": {
19706
21673
  if (!gate.fetcher) return deny("This fetch cannot be approved here.");
19707
- return await gate.fetcher.confirm(ask.url) ? ALLOW2 : deny(`Fetch denied (blocked host or not approved): ${ask.url}. ${OPS_HINT}`);
21674
+ const decision = await gate.fetcher.decide({ kind: "fetch", url: ask.url });
21675
+ return decision.allowed ? ALLOW2 : deny(decision.reason);
21676
+ }
21677
+ case "search": {
21678
+ if (!gate.fetcher) return deny("This search cannot be approved here.");
21679
+ const decision = await gate.fetcher.decide({
21680
+ kind: "search",
21681
+ query: ask.query,
21682
+ provider: ask.provider,
21683
+ providerLabel: ask.providerLabel,
21684
+ // It runs at the provider, which already holds the conversation the query came from.
21685
+ note: `${ask.provider} runs this search itself and already sees this planner's conversation.`
21686
+ });
21687
+ return decision.allowed ? ALLOW2 : deny(decision.reason);
21688
+ }
19708
21689
  case "path": {
19709
- const { abs, inside } = resolveWithin(gate.workspaceRoot, ask.path);
21690
+ const { abs, inside } = readTarget(gate.workspaceRoot, ask.path);
19710
21691
  if (inside) return ALLOW2;
19711
21692
  const granted = await gate.approval.request({
19712
21693
  kind: "external_path",
@@ -19738,23 +21719,31 @@ var CliAgentAiService = class {
19738
21719
  const runner = runnerForProvider(config.aiProvider);
19739
21720
  if (!runner) throw new Error(`${config.aiProvider} is not a coding-agent planner.`);
19740
21721
  this.runner = runner;
21722
+ const spawn2 = deps.spawn ?? nodeSpawn;
19741
21723
  this.processDeps = {
19742
- spawn: deps.spawn ?? nodeSpawn,
21724
+ spawn: (command, args, options) => {
21725
+ const child = spawn2(command, args, options);
21726
+ this.environmentSecrets = sensitiveEnvValues(options.env);
21727
+ return child;
21728
+ },
19743
21729
  fetch: deps.fetch ?? globalThis.fetch,
19744
21730
  resolvePath: deps.resolvePath,
19745
21731
  platform: deps.platform,
19746
21732
  isDirectory: deps.isDirectory,
19747
- exists: deps.exists
21733
+ exists: deps.exists,
21734
+ workspaceEnv: deps.workspaceEnv
19748
21735
  };
19749
21736
  this.makeAdapter = deps.createAdapter ?? createPlannerAdapter;
19750
21737
  this.workspaceRoot = deps.workspaceRoot ?? (() => process.cwd());
19751
21738
  this.mcpServer = deps.mcpServer ?? sharedMcpServer();
21739
+ this.projectServerApprovals = deps.projectServerApprovals ?? fileProjectServerApprovals();
19752
21740
  }
19753
21741
  config;
19754
21742
  runner;
19755
21743
  processDeps;
19756
21744
  makeAdapter;
19757
21745
  workspaceRoot;
21746
+ environmentSecrets = [];
19758
21747
  adapter = null;
19759
21748
  /**
19760
21749
  * The agent's own session id, kept across a process death so the next turn
@@ -19764,9 +21753,18 @@ var CliAgentAiService = class {
19764
21753
  lastNativeSessionId = null;
19765
21754
  /** `gate` is what the agent's own tool requests are decided against; absent, only Ordewell's tools are allowed (ADR-0026). */
19766
21755
  conversation = null;
21756
+ /**
21757
+ * What the runner's own session-wide search is decided against
21758
+ * (ADR-0027), asked again before every turn: the session's mode, its
21759
+ * exclusions or its budget can change between turns. Absent, it is off.
21760
+ */
21761
+ searchesUnasked;
19767
21762
  mcpServer;
21763
+ projectServerApprovals;
19768
21764
  /** The planner token the running process was spawned with; revoked with the process (ADR-0022, A3). */
19769
21765
  plannerToken = null;
21766
+ /** The {@link PlannerStartOptions.nativeSearch} the running process was spawned with. */
21767
+ adapterNativeSearch = false;
19770
21768
  activeAbort = null;
19771
21769
  /**
19772
21770
  * Every subagent this conversation has reported starting, and finishing.
@@ -19797,12 +21795,17 @@ var CliAgentAiService = class {
19797
21795
  nativeSessionId() {
19798
21796
  return this.adapter?.nativeSessionId() ?? null;
19799
21797
  }
21798
+ heldSecrets() {
21799
+ return this.environmentSecrets;
21800
+ }
19800
21801
  reset() {
19801
21802
  this.activeAbort?.abort();
19802
21803
  this.activeAbort = null;
19803
21804
  this.disposeAdapter();
21805
+ this.environmentSecrets = [];
19804
21806
  this.lastNativeSessionId = null;
19805
21807
  this.conversation = null;
21808
+ this.searchesUnasked = void 0;
19806
21809
  this.subagents.started.clear();
19807
21810
  this.subagents.finished.clear();
19808
21811
  }
@@ -19822,15 +21825,19 @@ var CliAgentAiService = class {
19822
21825
  req.autonomousDefault ?? true,
19823
21826
  { harness: true, isolatedExecution: req.isolatedExecution, skills: req.skills }
19824
21827
  );
21828
+ const fetcher = req.fetcher;
21829
+ const cwd = this.workspaceRoot();
19825
21830
  const startOptions = {
19826
21831
  kind: "planner",
19827
- cwd: this.workspaceRoot(),
19828
- systemPrompt,
21832
+ cwd,
21833
+ ...await this.repoConfig(systemPrompt, cwd, req.approval),
19829
21834
  model: this.plannerModel(),
19830
21835
  effort: this.config.plannerThinkingEffort
19831
21836
  };
21837
+ if (req.approval && fetcher?.searchesUnasked) this.searchesUnasked = (provider) => fetcher.searchesUnasked?.(provider) ?? false;
19832
21838
  const tools = req.plannerTools;
19833
- await this.startAdapter(startOptions, tools);
21839
+ const adapter = await this.startAdapter(startOptions, tools);
21840
+ recordUnusable(adapter.unusableProjectServers?.() ?? [], req.approval);
19834
21841
  const gate = req.approval ? { approval: req.approval, fetcher: req.fetcher, workspaceRoot: startOptions.cwd } : void 0;
19835
21842
  this.conversation = { startOptions, tools, gate };
19836
21843
  const opening = this.openingMessage(req);
@@ -20065,25 +22072,26 @@ var CliAgentAiService = class {
20065
22072
  }
20066
22073
  /** One spawn with the Ordewell server injected: the adapter once the runner reports it connected, otherwise why not, with nothing left running. */
20067
22074
  async startWithTools(opts, tools) {
20068
- const server = this.mcpServer;
20069
- if (!server) return "no Ordewell MCP server was given";
22075
+ const server4 = this.mcpServer;
22076
+ if (!server4) return "no Ordewell MCP server was given";
20070
22077
  const adapter = this.newAdapter();
20071
22078
  if (!adapter.mcpAttached) {
20072
22079
  adapter.dispose();
20073
22080
  return `${this.runner} cannot be handed Ordewell's MCP server`;
20074
22081
  }
22082
+ const start = { ...opts, nativeSearch: this.nativeSearch() };
20075
22083
  let credential;
20076
22084
  try {
20077
- credential = await server.issuePlannerToken({ sessionId: tools.sessionId }, tools.handler);
22085
+ credential = await server4.issuePlannerToken({ sessionId: tools.sessionId, hidden: connectorFor(this.runner)?.ordewellTools.plannerHidden(start) }, tools.handler);
20078
22086
  } catch (err) {
20079
22087
  adapter.dispose();
20080
22088
  return `Ordewell's MCP server could not issue a token (${err instanceof Error ? err.message : String(err)})`;
20081
22089
  }
20082
22090
  try {
20083
- await adapter.start({ ...opts, mcp: mcpClientConfig(credential) });
22091
+ await adapter.start({ ...start, mcp: mcpClientConfig(credential) });
20084
22092
  } catch (err) {
20085
22093
  adapter.dispose();
20086
- server.revoke(credential.token);
22094
+ server4.revoke(credential.token);
20087
22095
  throw err;
20088
22096
  }
20089
22097
  let failure = `${this.runner} did not report Ordewell's MCP server connected`;
@@ -20091,13 +22099,14 @@ var CliAgentAiService = class {
20091
22099
  if (await adapter.mcpAttached()) {
20092
22100
  this.adapter = adapter;
20093
22101
  this.plannerToken = credential.token;
22102
+ this.adapterNativeSearch = start.nativeSearch ?? false;
20094
22103
  return adapter;
20095
22104
  }
20096
22105
  } catch (err) {
20097
22106
  failure = `${this.runner} could not check Ordewell's MCP attach state (${err instanceof Error ? err.message : String(err)})`;
20098
22107
  }
20099
22108
  adapter.dispose();
20100
- server.revoke(credential.token);
22109
+ server4.revoke(credential.token);
20101
22110
  return failure;
20102
22111
  }
20103
22112
  /** Kill the planner process, and with it the token it was spawned with. */
@@ -20107,19 +22116,46 @@ var CliAgentAiService = class {
20107
22116
  if (this.plannerToken) this.mcpServer?.revoke(this.plannerToken);
20108
22117
  this.plannerToken = null;
20109
22118
  }
22119
+ /** Whether the web-read policy lets the runner's own session-wide search run unasked now (ADR-0027). */
22120
+ nativeSearch() {
22121
+ const provider = connectorFor(this.runner)?.ordewellTools.sessionSearchProvider;
22122
+ return provider !== void 0 && (this.searchesUnasked?.(provider) ?? false);
22123
+ }
20110
22124
  /**
20111
22125
  * The live agent process, restarted from its own session id if it died
20112
22126
  * between turns. Resume is a hint: when it fails, the caller's next
20113
22127
  * `startConversation` reseeds from Ordewell's transcript, which is the same
20114
22128
  * degradation `restoreChat` already performs on every surface.
22129
+ *
22130
+ * Also restarted when the policy's decision on the runner's own search has
22131
+ * changed since the process was spawned: the running thread would otherwise
22132
+ * keep searching unasked after Full turned Guarded. Codex 0.162 applies
22133
+ * `thread/resume`'s `config.web_search` either way (checked live).
20115
22134
  */
20116
22135
  async ensureAdapter() {
20117
- if (this.adapter) return this.adapter;
22136
+ if (this.adapter && this.adapterNativeSearch === this.nativeSearch()) return this.adapter;
22137
+ this.disposeAdapter();
20118
22138
  const conversation = this.conversation;
20119
22139
  if (!conversation) throw new Error("No active planner conversation.");
20120
22140
  await this.startAdapter({ ...conversation.startOptions, resumeSessionId: this.lastNativeSessionId ?? void 0 }, conversation.tools);
20121
22141
  return this.adapter;
20122
22142
  }
22143
+ /**
22144
+ * What the planner gets of the repository's config, its runner being
22145
+ * launched without it (ADR-0028): the instruction files, which are text, in
22146
+ * its system prompt, and the project MCP servers it may start. Decided once
22147
+ * per conversation, so a respawn starts the same servers without asking.
22148
+ */
22149
+ async repoConfig(systemPrompt, cwd, approval) {
22150
+ const reader = connectorFor(this.runner)?.repoConfig;
22151
+ if (!reader) return { systemPrompt };
22152
+ const found = reader.projectServers(cwd);
22153
+ const { started, held } = await decideProjectServers(found.servers, { repoRoot: repoRoot(cwd), approval, approvals: this.projectServerApprovals });
22154
+ return {
22155
+ systemPrompt: [systemPrompt, instructionsBlock(reader.instructions(cwd)), heldServersNote(held.map((server4) => ({ server: server4, reason: NOT_APPROVED })), found.unreadable)].filter(Boolean).join("\n\n"),
22156
+ ...started.length > 0 ? { projectServers: started } : {}
22157
+ };
22158
+ }
20123
22159
  plannerModel() {
20124
22160
  const id = (this.config.orchestratorModel ?? "").trim();
20125
22161
  return id || void 0;
@@ -20162,8 +22198,11 @@ ${corrective}`, catalog, onProgress, signal),
20162
22198
  const previousConversation = this.conversation;
20163
22199
  const previousSessionId = this.lastNativeSessionId;
20164
22200
  const previousToken = this.plannerToken;
22201
+ const previousNativeSearch = this.adapterNativeSearch;
22202
+ const previousSearchesUnasked = this.searchesUnasked;
20165
22203
  this.adapter = null;
20166
22204
  this.plannerToken = null;
22205
+ this.searchesUnasked = void 0;
20167
22206
  let submitted;
20168
22207
  let refused3 = [];
20169
22208
  let ended = false;
@@ -20194,11 +22233,13 @@ ${corrective}`, catalog, onProgress, signal),
20194
22233
  }
20195
22234
  }
20196
22235
  };
22236
+ const cwd = this.workspaceRoot();
20197
22237
  const startOptions = {
20198
22238
  kind: "planner",
20199
- cwd: this.workspaceRoot(),
20200
- systemPrompt: `${prompt}
20201
- Before submitting, call list_runners and list_models to check the available assignments.`,
22239
+ cwd,
22240
+ // No approval channel here: only servers a person approved before start.
22241
+ ...await this.repoConfig(`${prompt}
22242
+ Before submitting, call list_runners and list_models to check the available assignments.`, cwd, void 0),
20202
22243
  model: this.plannerModel(),
20203
22244
  effort: this.config.plannerThinkingEffort
20204
22245
  };
@@ -20224,6 +22265,8 @@ Before submitting, call list_runners and list_models to check the available assi
20224
22265
  if (this.plannerToken) this.mcpServer?.revoke(this.plannerToken);
20225
22266
  this.adapter = previous;
20226
22267
  this.plannerToken = previousToken;
22268
+ this.adapterNativeSearch = previousNativeSearch;
22269
+ this.searchesUnasked = previousSearchesUnasked;
20227
22270
  this.conversation = previousConversation;
20228
22271
  this.lastNativeSessionId = previousSessionId;
20229
22272
  }
@@ -21295,213 +23338,6 @@ function forkPlanState(plan, tasks, dialogue, now) {
21295
23338
  };
21296
23339
  }
21297
23340
 
21298
- // src/services/HttpWebFetcher.ts
21299
- import { lookup } from "node:dns/promises";
21300
- var DEFAULT_MAX_CHARS = 4e4;
21301
- var DEFAULT_TIMEOUT_MS2 = 2e4;
21302
- var MAX_BODY_BYTES = 2e6;
21303
- var MAX_REDIRECTS = 5;
21304
- var USER_AGENT = "Mozilla/5.0 (compatible; Ordewell-Planner/1.0; +https://github.com/ordewell)";
21305
- var SEARCH_ENDPOINT = "https://lite.duckduckgo.com/lite/?q=";
21306
- function decodeEntities(text) {
21307
- return text.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'");
21308
- }
21309
- function htmlToText(html) {
21310
- return decodeEntities(
21311
- html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
21312
- ).replace(/[ \t\f\v]+/g, " ").replace(/\n\s*\n\s*\n+/g, "\n\n").split("\n").map((line) => line.trim()).join("\n").trim();
21313
- }
21314
- function parseSearchHits(html) {
21315
- const hits = [];
21316
- const linkPattern = /<a\b[^>]*class="[^"]*result(?:-link|__a)[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
21317
- const snippetPattern = /class="[^"]*result(?:-snippet|__snippet)[^"]*"[^>]*>([\s\S]*?)</gi;
21318
- const snippets = [];
21319
- for (let m = snippetPattern.exec(html); m; m = snippetPattern.exec(html)) {
21320
- snippets.push(decodeEntities(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim());
21321
- }
21322
- for (let m = linkPattern.exec(html); m; m = linkPattern.exec(html)) {
21323
- const title = decodeEntities(m[2].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
21324
- if (!title) continue;
21325
- hits.push({ title, url: decodeEntities(m[1]), snippet: snippets[hits.length] });
21326
- }
21327
- return hits;
21328
- }
21329
- function isPrivateIp(ip) {
21330
- if (!ip) return true;
21331
- if (ip === "0.0.0.0" || ip === "::" || ip === "::1" || ip === "::ffff:127.0.0.1") return true;
21332
- if (/^127\./.test(ip)) return true;
21333
- if (/^169\.254\./.test(ip)) return true;
21334
- if (/^10\./.test(ip)) return true;
21335
- if (/^192\.168\./.test(ip)) return true;
21336
- const m172 = /^172\.(\d+)\./.exec(ip);
21337
- if (m172 && Number(m172[1]) >= 16 && Number(m172[1]) <= 31) return true;
21338
- if (/^fe[89ab][0-9a-f]/i.test(ip)) return true;
21339
- return false;
21340
- }
21341
- function isIpLiteral(host) {
21342
- return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || /^\[?[0-9a-fA-F:]+\]?$/i.test(host);
21343
- }
21344
- function sanitizeError(err) {
21345
- const msg = err instanceof Error ? err.message : String(err);
21346
- if (/abort/i.test(msg)) return "timed out";
21347
- if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return "host not reachable";
21348
- if (/ENOTFOUND|getaddrinfo/i.test(msg)) return "host not found";
21349
- return msg.replace(/https?:\/\/\S+/g, "<url>");
21350
- }
21351
- var HttpWebFetcher = class {
21352
- approval;
21353
- fetchImpl;
21354
- maxChars;
21355
- timeoutMs;
21356
- isBlockedHost;
21357
- resolveHost;
21358
- constructor(opts) {
21359
- this.approval = opts.approval;
21360
- this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
21361
- this.maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
21362
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
21363
- this.isBlockedHost = opts.isBlockedHost ?? this.defaultIsBlocked.bind(this);
21364
- this.resolveHost = opts.resolveHost ?? (async (h) => {
21365
- const r = await lookup(h);
21366
- return [r.address];
21367
- });
21368
- }
21369
- async defaultIsBlocked(host, port) {
21370
- if (!["", "80", "443"].includes(port)) return true;
21371
- if (isIpLiteral(host)) return isPrivateIp(host.startsWith("[") ? host.slice(1, -1) : host);
21372
- const lower = host.toLowerCase();
21373
- if (lower === "localhost" || lower === "ip6-localhost" || lower === "ip6-loopback") return true;
21374
- try {
21375
- const addrs = await this.resolveHost(host);
21376
- if (addrs.some((a) => isPrivateIp(a))) return true;
21377
- } catch {
21378
- return true;
21379
- }
21380
- return false;
21381
- }
21382
- async confirm(url) {
21383
- let parsed;
21384
- try {
21385
- parsed = new URL(url);
21386
- } catch {
21387
- return false;
21388
- }
21389
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
21390
- if (await this.isBlockedHost(parsed.hostname, parsed.port)) return false;
21391
- return this.approval.request({
21392
- kind: "url_fetch",
21393
- subject: url,
21394
- scope: `${parsed.origin}/*`,
21395
- detail: `Planner research wants to fetch ${parsed.origin}`
21396
- });
21397
- }
21398
- async fetch(url) {
21399
- if (!await this.confirm(url)) {
21400
- return { success: false, output: `Fetch denied (blocked host or not approved): ${url}. Continue without it, or ask the user to approve this origin or add it to the planner allowlist.`, truncated: false };
21401
- }
21402
- return this.get(url, "Fetch");
21403
- }
21404
- async search(query) {
21405
- const url = SEARCH_ENDPOINT + encodeURIComponent(query);
21406
- if (!await this.confirm(url)) {
21407
- return { success: false, output: `Web search denied by user for query: ${query}`, truncated: false };
21408
- }
21409
- const page = await this.request(url);
21410
- if (!page.ok) return { success: false, output: `Web search failed: ${page.error}`, truncated: false };
21411
- const hits = parseSearchHits(page.body).slice(0, 10);
21412
- if (hits.length === 0) {
21413
- return {
21414
- success: false,
21415
- output: `No results could be parsed for "${query}". Search is best-effort; if you already know a documentation URL, use the fetch tool directly.`,
21416
- truncated: false
21417
- };
21418
- }
21419
- const output = hits.map((hit, i) => `${i + 1}. ${hit.title}
21420
- ${hit.url}${hit.snippet ? `
21421
- ${hit.snippet}` : ""}`).join("\n\n");
21422
- return { success: true, output, truncated: false };
21423
- }
21424
- async get(url, label) {
21425
- const page = await this.request(url);
21426
- if (!page.ok) return { success: false, output: `${label} failed: ${page.error}`, truncated: false };
21427
- const text = page.isHtml ? htmlToText(page.body) : page.body;
21428
- if (page.truncated || text.length > this.maxChars) {
21429
- const sliced = text.slice(0, this.maxChars);
21430
- return {
21431
- success: true,
21432
- output: `${sliced}
21433
-
21434
- [... truncated to ${this.maxChars} chars, total ${text.length}${page.truncated ? "+, body was byte-capped at the source" : ""}]`,
21435
- truncated: true
21436
- };
21437
- }
21438
- return { success: true, output: text, truncated: false };
21439
- }
21440
- async request(url) {
21441
- let current = url;
21442
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
21443
- const controller = new AbortController();
21444
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
21445
- try {
21446
- const res = await this.fetchImpl(current, {
21447
- signal: controller.signal,
21448
- headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8" },
21449
- redirect: "manual"
21450
- });
21451
- if (res.status >= 300 && res.status < 400) {
21452
- const location = res.headers.get("location");
21453
- if (!location) return { ok: false, error: `redirect (${res.status}) with no Location` };
21454
- const next = new URL(location, current).href;
21455
- const nextParsed = new URL(next);
21456
- if (nextParsed.protocol !== "https:" && nextParsed.protocol !== "http:") {
21457
- return { ok: false, error: "redirect to a non-HTTP scheme" };
21458
- }
21459
- if (await this.isBlockedHost(nextParsed.hostname, nextParsed.port)) {
21460
- return { ok: false, error: "redirect to a blocked host" };
21461
- }
21462
- current = next;
21463
- continue;
21464
- }
21465
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
21466
- const contentType = res.headers.get("content-type") ?? "";
21467
- const { body, truncated } = await readBounded(res, MAX_BODY_BYTES);
21468
- return { ok: true, body, isHtml: /html|xml/i.test(contentType), truncated };
21469
- } catch (err) {
21470
- return { ok: false, error: sanitizeError(err) };
21471
- } finally {
21472
- clearTimeout(timer);
21473
- }
21474
- }
21475
- return { ok: false, error: `too many redirects (>${MAX_REDIRECTS})` };
21476
- }
21477
- };
21478
- async function readBounded(res, maxBytes) {
21479
- const reader = res.body?.[Symbol.asyncIterator] ? readFromIterable(res.body, maxBytes) : readFromText(res, maxBytes);
21480
- return reader;
21481
- }
21482
- async function readFromIterable(source, maxBytes) {
21483
- const chunks = [];
21484
- let total = 0;
21485
- let truncated = false;
21486
- for await (const chunk of source) {
21487
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
21488
- if (total + buf.length > maxBytes) {
21489
- chunks.push(buf.subarray(0, maxBytes - total));
21490
- total = maxBytes;
21491
- truncated = true;
21492
- break;
21493
- }
21494
- chunks.push(buf);
21495
- total += buf.length;
21496
- }
21497
- return { body: Buffer.concat(chunks).toString("utf8"), truncated };
21498
- }
21499
- async function readFromText(res, maxBytes) {
21500
- const text = await res.text();
21501
- if (Buffer.byteLength(text, "utf8") <= maxBytes) return { body: text, truncated: false };
21502
- return { body: Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8"), truncated: true };
21503
- }
21504
-
21505
23341
  // src/services/SessionEventRelay.ts
21506
23342
  var SessionEventRelay = class {
21507
23343
  broadcast;
@@ -21718,7 +23554,7 @@ var SessionEventRelay = class {
21718
23554
 
21719
23555
  // src/utils/sessionStore.ts
21720
23556
  import * as fs12 from "fs";
21721
- import * as path21 from "path";
23557
+ import * as path26 from "path";
21722
23558
 
21723
23559
  // src/utils/sessionId.ts
21724
23560
  import { randomBytes as randomBytes7 } from "crypto";
@@ -21729,13 +23565,13 @@ function mintSessionId() {
21729
23565
  // src/utils/sessionStore.ts
21730
23566
  var SESSIONS_SUBDIR = "sessions";
21731
23567
  function getSessionsDir(baseDir) {
21732
- return path21.join(getStateDir(baseDir), SESSIONS_SUBDIR);
23568
+ return path26.join(getStateDir(baseDir), SESSIONS_SUBDIR);
21733
23569
  }
21734
23570
  function idSegment(id) {
21735
23571
  return /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(id) ? id : `~${Buffer.from(id, "utf-8").toString("hex")}`;
21736
23572
  }
21737
23573
  function sessionDataDir(sessionId, baseDir) {
21738
- return path21.join(getSessionsDir(baseDir), idSegment(sessionId));
23574
+ return path26.join(getSessionsDir(baseDir), idSegment(sessionId));
21739
23575
  }
21740
23576
  var REJECTION_MESSAGE = "This session was created with an older version of Ordewell. Please re-plan.";
21741
23577
  function hasScalarRunner(parsed) {
@@ -21757,8 +23593,8 @@ function legacySessionFilename(session) {
21757
23593
  return `${filenameStem(session)}_${session.id.slice(0, 8)}.json`;
21758
23594
  }
21759
23595
  function dropLegacyFile(sessionsDir, meta) {
21760
- const legacy = path21.join(sessionsDir, legacySessionFilename(meta));
21761
- if (legacy === path21.join(sessionsDir, sessionFilename(meta)) || !fs12.existsSync(legacy)) return;
23596
+ const legacy = path26.join(sessionsDir, legacySessionFilename(meta));
23597
+ if (legacy === path26.join(sessionsDir, sessionFilename(meta)) || !fs12.existsSync(legacy)) return;
21762
23598
  try {
21763
23599
  const held = JSON.parse(fs12.readFileSync(legacy, "utf-8")).meta?.id;
21764
23600
  if (held === meta.id) fs12.unlinkSync(legacy);
@@ -21784,7 +23620,7 @@ function saveSession(plan, goal, baseDir, id) {
21784
23620
  plan: JSON.parse(JSON.stringify(plan))
21785
23621
  };
21786
23622
  const filename = sessionFilename(meta);
21787
- const filePath = path21.join(sessionsDir, filename);
23623
+ const filePath = path26.join(sessionsDir, filename);
21788
23624
  fs12.writeFileSync(filePath, JSON.stringify(session, null, 2));
21789
23625
  dropLegacyFile(sessionsDir, meta);
21790
23626
  return meta;
@@ -21795,7 +23631,7 @@ function listSessions(baseDir, logger = defaultLogger) {
21795
23631
  const files = fs12.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
21796
23632
  return files.map((f) => {
21797
23633
  try {
21798
- const raw = fs12.readFileSync(path21.join(sessionsDir, f), "utf-8");
23634
+ const raw = fs12.readFileSync(path26.join(sessionsDir, f), "utf-8");
21799
23635
  const parsed = JSON.parse(raw);
21800
23636
  if (hasScalarRunner(parsed)) {
21801
23637
  logger.warn("sessionStore", `session file ${f}: ${REJECTION_MESSAGE}`);
@@ -21813,7 +23649,7 @@ function findSessionFile(sessionsDir, sessionId, logger) {
21813
23649
  const files = fs12.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
21814
23650
  for (const f of files) {
21815
23651
  try {
21816
- const raw = fs12.readFileSync(path21.join(sessionsDir, f), "utf-8");
23652
+ const raw = fs12.readFileSync(path26.join(sessionsDir, f), "utf-8");
21817
23653
  const session = JSON.parse(raw);
21818
23654
  if (session.meta.id === sessionId) return f;
21819
23655
  } catch (err) {
@@ -21828,7 +23664,7 @@ function loadSession(sessionId, baseDir, logger = defaultLogger) {
21828
23664
  try {
21829
23665
  const file = findSessionFile(sessionsDir, sessionId, logger);
21830
23666
  if (file) {
21831
- const raw = fs12.readFileSync(path21.join(sessionsDir, file), "utf-8");
23667
+ const raw = fs12.readFileSync(path26.join(sessionsDir, file), "utf-8");
21832
23668
  const parsed = JSON.parse(raw);
21833
23669
  if (hasScalarRunner(parsed)) {
21834
23670
  logger.warn("sessionStore", `session file ${file}: ${REJECTION_MESSAGE}`);
@@ -21869,7 +23705,7 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
21869
23705
  try {
21870
23706
  const file = findSessionFile(sessionsDir, sessionId, logger);
21871
23707
  if (file) {
21872
- fs12.unlinkSync(path21.join(sessionsDir, file));
23708
+ fs12.unlinkSync(path26.join(sessionsDir, file));
21873
23709
  return true;
21874
23710
  }
21875
23711
  } catch (err) {
@@ -21880,10 +23716,10 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
21880
23716
 
21881
23717
  // src/utils/taskLogStore.ts
21882
23718
  import * as fs13 from "fs";
21883
- import * as path22 from "path";
23719
+ import * as path27 from "path";
21884
23720
  var EXTENSION = ".jsonl";
21885
23721
  function taskDir({ baseDir, sessionId }, taskId) {
21886
- return path22.join(sessionDataDir(sessionId, baseDir), "tasks", idSegment(taskId));
23722
+ return path27.join(sessionDataDir(sessionId, baseDir), "tasks", idSegment(taskId));
21887
23723
  }
21888
23724
  function listTaskLogAttempts(location, taskId) {
21889
23725
  let names;
@@ -21900,7 +23736,7 @@ function openTaskLog(location, taskId) {
21900
23736
  fs13.mkdirSync(dir, { recursive: true });
21901
23737
  let attempt = (listTaskLogAttempts(location, taskId).pop() ?? 0) + 1;
21902
23738
  for (; ; ) {
21903
- const file = path22.join(dir, `${attempt}${EXTENSION}`);
23739
+ const file = path27.join(dir, `${attempt}${EXTENSION}`);
21904
23740
  try {
21905
23741
  fs13.writeFileSync(file, "", { flag: "wx" });
21906
23742
  } catch (err) {
@@ -21927,7 +23763,7 @@ function readTaskLog(location, taskId, attempt) {
21927
23763
  if (!Number.isInteger(attempt) || attempt < 1) return [];
21928
23764
  let raw;
21929
23765
  try {
21930
- raw = fs13.readFileSync(path22.join(taskDir(location, taskId), `${attempt}${EXTENSION}`), "utf-8");
23766
+ raw = fs13.readFileSync(path27.join(taskDir(location, taskId), `${attempt}${EXTENSION}`), "utf-8");
21931
23767
  } catch {
21932
23768
  return [];
21933
23769
  }
@@ -22355,7 +24191,8 @@ function isPlannerApproval(request) {
22355
24191
  }
22356
24192
  function createSession(deps) {
22357
24193
  const pinnedAiService = deps.aiService;
22358
- const aiService = pinnedAiService ? () => pinnedAiService : liveAiService(deps.config, deps.workspaceRoot, deps.mcpServer ?? sharedMcpServer());
24194
+ const mcpServer = deps.mcpServer ?? sharedMcpServer();
24195
+ const aiService = pinnedAiService ? () => pinnedAiService : liveAiService(deps.config, deps.workspaceRoot, mcpServer);
22359
24196
  const store = new PlanStore();
22360
24197
  const taskLogs = new TaskLogRecorder({
22361
24198
  broadcast: deps.broadcast,
@@ -22411,8 +24248,9 @@ function createSession(deps) {
22411
24248
  checkpointQuestion: (taskId) => orchestrator.getCheckpointQuestion(taskId)
22412
24249
  });
22413
24250
  const approvalPolicy = new ApprovalPolicy({
24251
+ autonomous: () => deps.config.autonomousMode,
22414
24252
  // Read per request, so a change of autonomy level or allowlist applies to the next call.
22415
- mode: () => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode),
24253
+ mode: (kind) => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode, kind),
22416
24254
  preApproved: () => [...deps.config.approvalDefaults ? DEFAULT_PLANNER_ALLOWLIST : [], ...deps.config.approvalPreApproved],
22417
24255
  ask: (req) => approvals.ask(req),
22418
24256
  // The interactive path (`asked`) already broadcasts approval_request +
@@ -22432,6 +24270,14 @@ function createSession(deps) {
22432
24270
  }
22433
24271
  });
22434
24272
  deps.fsAdapter.setApproval?.(approvalPolicy);
24273
+ const outboundSecrets = () => [...configSecrets(deps.config), ...mcpServer.heldTokens(), ...aiService().heldSecrets?.() ?? []];
24274
+ deps.fsAdapter.setOutboundSecrets?.(outboundSecrets);
24275
+ const webReads = new WebReadPolicy({
24276
+ approval: approvalPolicy,
24277
+ secrets: outboundSecrets,
24278
+ budget: () => deps.config.webReadBudget,
24279
+ turn: () => conversation?.currentTurnId
24280
+ });
22435
24281
  const catalog = new SessionCatalog({
22436
24282
  config: deps.config,
22437
24283
  registry: deps.registry,
@@ -22460,15 +24306,28 @@ function createSession(deps) {
22460
24306
  usage,
22461
24307
  approvals,
22462
24308
  approvalPolicy,
22463
- // `fetch`/`web_search` route through the same approval channel as paths and
22464
- // commands — one decision surface for everything that leaves the workspace.
22465
- fetcher: new HttpWebFetcher({ approval: approvalPolicy }),
24309
+ // Every planner's web reads, carried here or natively, are decided by the
24310
+ // one policy, which asks through the same channel as paths and commands.
24311
+ // The response cache and search provider are process-wide, so parallel
24312
+ // sessions and their subagents share bytes and the search cooldown.
24313
+ fetcher: new HttpWebFetcher({ policy: webReads, cache: sharedWebResponseCache, searchProvider: sharedSearchProvider }),
22466
24314
  skillsService: deps.skillsService ?? new SkillsService(deps.workspaceRoot()),
22467
24315
  saveSession: deps.saveSession ?? saveSession,
22468
24316
  conversation: (host) => conversation = new PlannerConversation(host)
22469
24317
  });
22470
24318
  return session;
22471
24319
  }
24320
+ function configSecrets(config) {
24321
+ const providers = Object.keys(ALL_PROVIDERS);
24322
+ return [
24323
+ config.apiKey,
24324
+ config.openAiApiKey,
24325
+ config.openrouterKey,
24326
+ config.geminiKey,
24327
+ config.openaiCompatibleApiKey,
24328
+ ...providers.map((provider) => config.getProviderApiKey(provider))
24329
+ ];
24330
+ }
22472
24331
  var Session = class {
22473
24332
  aiService;
22474
24333
  usage;
@@ -22517,7 +24376,9 @@ var Session = class {
22517
24376
  liveOutput: (taskId, opts) => this.orchestrator.getLiveOutput(taskId, opts),
22518
24377
  lastAttempt: (taskId) => lastAttemptDigest(this.taskLogLocation, taskId),
22519
24378
  taskSkills: () => this.workspaceSkills(),
22520
- runCommand: (command, signal) => this.fsAdapter.bash(command, signal)
24379
+ runCommand: (command, signal) => this.fsAdapter.bash(command, signal),
24380
+ fetchUrl: (url) => this.fetcher.fetch(url),
24381
+ webSearch: async (query) => this.fetcher.search?.(query)
22521
24382
  });
22522
24383
  constructor(parts) {
22523
24384
  this.config = parts.config;
@@ -23626,10 +25487,10 @@ var StructuredSession = class extends AbstractRunnerSession {
23626
25487
  if (second) throw new McpAttachError(`Could not start ${this.launch.runner} with Ordewell's tools, so the task was not sent to it. First spawn: ${first}. Respawn: ${second}.`);
23627
25488
  }
23628
25489
  async startWithTools(opts) {
23629
- const { server, scope } = this.launch.tools;
25490
+ const { server: server4, scope } = this.launch.tools;
23630
25491
  let credential;
23631
25492
  try {
23632
- credential = await server.issueTaskToken(scope, {
25493
+ credential = await server4.issueTaskToken(scope, {
23633
25494
  taskComplete: async (report) => {
23634
25495
  this.structuredEmitter.emit("taskComplete", report);
23635
25496
  return { text: "Recorded. End your turn now." };
@@ -23643,7 +25504,7 @@ var StructuredSession = class extends AbstractRunnerSession {
23643
25504
  return `Ordewell's MCP server could not issue a token (${err instanceof Error ? err.message : String(err)})`;
23644
25505
  }
23645
25506
  if (this.exited) {
23646
- server.revoke(credential.token);
25507
+ server4.revoke(credential.token);
23647
25508
  return null;
23648
25509
  }
23649
25510
  this.toolToken = credential.token;
@@ -23814,7 +25675,7 @@ var StructuredSession = class extends AbstractRunnerSession {
23814
25675
  }
23815
25676
  async interruptTurn(turn, adapter) {
23816
25677
  const grace = this.launch.interruptGraceMs;
23817
- const ended = new Promise((resolve6) => this.structuredEmitter.once("turnEnd", () => resolve6()));
25678
+ const ended = new Promise((resolve9) => this.structuredEmitter.once("turnEnd", () => resolve9()));
23818
25679
  if (await adapter.interrupt(grace)) {
23819
25680
  const settled = await settleWithin(ended.then(() => true), { timeoutMs: grace, onTimeout: () => false });
23820
25681
  if (settled) return;
@@ -24355,7 +26216,7 @@ var RunnerRegistry = class {
24355
26216
 
24356
26217
  // src/plugins/removedPluginNotice.ts
24357
26218
  import * as fs14 from "fs";
24358
- import * as path23 from "path";
26219
+ import * as path28 from "path";
24359
26220
  function isManifestFile(file) {
24360
26221
  try {
24361
26222
  return fs14.statSync(file).isFile();
@@ -24364,10 +26225,10 @@ function isManifestFile(file) {
24364
26225
  }
24365
26226
  }
24366
26227
  function removedPluginNotice(dataDir = globalDataDir()) {
24367
- const directory = path23.join(dataDir, "plugins");
26228
+ const directory = path28.join(dataDir, "plugins");
24368
26229
  try {
24369
- const hasManifest = isManifestFile(path23.join(directory, "manifest.json")) || fs14.readdirSync(directory, { withFileTypes: true }).some(
24370
- (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && isManifestFile(path23.join(directory, entry.name, "manifest.json"))
26230
+ const hasManifest = isManifestFile(path28.join(directory, "manifest.json")) || fs14.readdirSync(directory, { withFileTypes: true }).some(
26231
+ (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && isManifestFile(path28.join(directory, entry.name, "manifest.json"))
24371
26232
  );
24372
26233
  if (hasManifest) return "Plugin runners were removed; manifests in ~/.ordewell/plugins/ are ignored. See the CHANGELOG.";
24373
26234
  } catch {
@@ -24378,14 +26239,14 @@ function removedPluginNotice(dataDir = globalDataDir()) {
24378
26239
 
24379
26240
  // src/utils/stateStore.ts
24380
26241
  import * as fs15 from "fs";
24381
- import * as path24 from "path";
26242
+ import * as path29 from "path";
24382
26243
  var STATE_FILE = "state.json";
24383
26244
  function getStatePath(baseDir) {
24384
- return path24.join(getStateDir(baseDir), STATE_FILE);
26245
+ return path29.join(getStateDir(baseDir), STATE_FILE);
24385
26246
  }
24386
26247
  function saveState(plan, baseDir) {
24387
26248
  const statePath = getStatePath(baseDir ?? process.cwd());
24388
- ensureDir(path24.dirname(statePath));
26249
+ ensureDir(path29.dirname(statePath));
24389
26250
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
24390
26251
  fs15.writeFileSync(statePath, JSON.stringify(plan, null, 2));
24391
26252
  }
@@ -24421,11 +26282,11 @@ function stateExists(baseDir) {
24421
26282
 
24422
26283
  // src/services/SettingsService.ts
24423
26284
  import * as fs16 from "fs";
24424
- import * as path25 from "path";
26285
+ import * as path30 from "path";
24425
26286
  function getSettingsPath() {
24426
26287
  const override = process.env.ORDEWELL_SETTINGS_PATH;
24427
26288
  if (override && override.trim()) return override.trim();
24428
- return path25.join(globalDataDir(), "settings.json");
26289
+ return path30.join(globalDataDir(), "settings.json");
24429
26290
  }
24430
26291
  function readPlannerModels(raw) {
24431
26292
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
@@ -24534,7 +26395,7 @@ var SettingsService = class {
24534
26395
  return {};
24535
26396
  }
24536
26397
  persist() {
24537
- const dir = path25.dirname(this.filePath);
26398
+ const dir = path30.dirname(this.filePath);
24538
26399
  if (!fs16.existsSync(dir)) {
24539
26400
  fs16.mkdirSync(dir, { recursive: true });
24540
26401
  }
@@ -24607,7 +26468,9 @@ export {
24607
26468
  DEFAULT_MAX_PARALLEL,
24608
26469
  DEFAULT_PLANNER_ALLOWLIST,
24609
26470
  DEFAULT_RUNNERS,
26471
+ DEFAULT_WEB_READ_BUDGET,
24610
26472
  DENY_ALL,
26473
+ DuckDuckGoSearchProvider,
24611
26474
  EMPTY_CONVERSATION,
24612
26475
  EMPTY_HOLD,
24613
26476
  EMPTY_TASK_LOG,
@@ -24619,6 +26482,7 @@ export {
24619
26482
  GREP_PCRE_PROBE_ARGS,
24620
26483
  GeminiService,
24621
26484
  HARNESS_PLANNER_REASON,
26485
+ HttpWebFetcher,
24622
26486
  JSON_REPAIR_INSTRUCTION,
24623
26487
  LineBuffer,
24624
26488
  ModelCatalog,
@@ -24685,6 +26549,7 @@ export {
24685
26549
  TaskOrchestrator,
24686
26550
  VerdictEngine,
24687
26551
  WINDOWS_MAX_COMMAND_LINE,
26552
+ WebResponseCache,
24688
26553
  WorkspaceNotAProjectError,
24689
26554
  WorkspaceNotFoundError,
24690
26555
  abbreviateHome,
@@ -24826,6 +26691,7 @@ export {
24826
26691
  loadSessionPlanState,
24827
26692
  loadState,
24828
26693
  loadedSkillTokens,
26694
+ looksLikeChallenge,
24829
26695
  looksLikePlanAttempt,
24830
26696
  mapAgentTool,
24831
26697
  markAction,
@@ -24858,6 +26724,7 @@ export {
24858
26724
  parseMaxParallel,
24859
26725
  parsePartialPlan,
24860
26726
  parsePlanJson,
26727
+ parseSearchHits,
24861
26728
  parseTaskOpsJson,
24862
26729
  parseTaskQueryJson,
24863
26730
  partedPromptUsage,
@@ -24918,6 +26785,8 @@ export {
24918
26785
  sessionDataDir,
24919
26786
  sessionRuntimeSettings,
24920
26787
  sharedMcpServer,
26788
+ sharedSearchProvider,
26789
+ sharedWebResponseCache,
24921
26790
  skillNames,
24922
26791
  skillTokens,
24923
26792
  stateExists,