@runuai/host 0.9.152 → 0.9.153

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.
@@ -96,6 +96,7 @@ import {
96
96
  verifyTaskGatewayAuthentication,
97
97
  } from "./task-gateway-state";
98
98
  import { MCP_GATEWAY_PORT } from "./task-gateway-port";
99
+ import { MAX_MANIFEST_MCP } from "../src/cloud-credentials";
99
100
 
100
101
  export type { McpGatewayCapability } from "../src/protocol";
101
102
 
@@ -1580,20 +1581,65 @@ export async function stopMcpGateway(): Promise<void> {
1580
1581
 
1581
1582
  // --- task container wiring (ADR-057 task-up writers) -------------------------
1582
1583
 
1583
- /** Idempotent node -e merge of entries (argv[2]) into a JSON file (argv[1])
1584
- * under a top-level object key (argv[3], default "mcpServers") — reused for
1585
- * Claude's /workspace/.mcp.json + Cursor's ~/.cursor/mcp.json (mcpServers)
1586
- * and OpenCode's ~/.config/opencode/opencode.json (mcp). Creates the parent
1587
- * dir when missing. */
1584
+ /** Reconcile JSON MCP definitions atomically and with compare-before-rename.
1585
+ * Only exact Uai gateway shapes whose URL names this task are owned: stale
1586
+ * ones are removed, while browser and foreign/user entries are untouched. */
1588
1587
  export const MERGE_MCP_JSON = `
1589
1588
  const fs = require("fs");
1590
1589
  const path = require("path");
1591
1590
  const crypto = require("crypto");
1592
1591
  const p = process.argv[1];
1593
1592
  const key = process.argv[3] || "mcpServers";
1593
+ const flavor = process.argv[4] || "claude";
1594
+ const taskId = process.argv[5];
1594
1595
  const plainObject = (value) =>
1595
1596
  value !== null && typeof value === "object" && !Array.isArray(value);
1596
- fs.mkdirSync(path.dirname(p), { recursive: true });
1597
+ if (!["claude", "cursor", "opencode"].includes(flavor)) {
1598
+ throw new Error("invalid MCP config flavor");
1599
+ }
1600
+ if (typeof taskId !== "string" || taskId.length === 0) {
1601
+ throw new Error("invalid MCP task id");
1602
+ }
1603
+ const desired = JSON.parse(process.argv[2]);
1604
+ if (!plainObject(desired)) {
1605
+ throw new Error("desired MCP definitions must be an object");
1606
+ }
1607
+ const exactKeys = (value, keys) => plainObject(value) &&
1608
+ Object.keys(value).sort().join("\\0") === keys.slice().sort().join("\\0");
1609
+ const gatewayPort = ${JSON.stringify(String(MCP_GATEWAY_PORT))};
1610
+ const managedUrl = (value) => {
1611
+ if (flavor === "claude" && exactKeys(value, ["type", "url"]) &&
1612
+ value.type === "http" && typeof value.url === "string") return value.url;
1613
+ if (flavor === "cursor" && exactKeys(value, ["url"]) &&
1614
+ typeof value.url === "string") return value.url;
1615
+ if (flavor === "opencode" &&
1616
+ exactKeys(value, ["enabled", "oauth", "type", "url"]) &&
1617
+ value.type === "remote" && value.enabled === true && value.oauth === false &&
1618
+ typeof value.url === "string") return value.url;
1619
+ return null;
1620
+ };
1621
+ const isTaskGatewayUrl = (value, slug) => {
1622
+ let parsed;
1623
+ try { parsed = new URL(value); } catch { return false; }
1624
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== gatewayPort ||
1625
+ parsed.username || parsed.password || parsed.search || parsed.hash) return false;
1626
+ const segments = parsed.pathname.split("/").filter(Boolean);
1627
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug) return false;
1628
+ const prefix = taskId + ".";
1629
+ return value === "http://" + parsed.hostname + ":" + gatewayPort +
1630
+ "/t/" + segments[1] + "/" + slug &&
1631
+ segments[1].startsWith(prefix) &&
1632
+ /^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length));
1633
+ };
1634
+ const isManaged = (value, slug) => {
1635
+ const url = managedUrl(value);
1636
+ return url !== null && isTaskGatewayUrl(url, slug);
1637
+ };
1638
+ for (const [slug, value] of Object.entries(desired)) {
1639
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) || !isManaged(value, slug)) {
1640
+ throw new Error("invalid desired MCP definition for " + slug);
1641
+ }
1642
+ }
1597
1643
  let original = null;
1598
1644
  let j = {};
1599
1645
  try {
@@ -1603,15 +1649,47 @@ try {
1603
1649
  if (!error || error.code !== "ENOENT") throw error;
1604
1650
  }
1605
1651
  if (!plainObject(j)) throw new Error("MCP config root must be an object");
1606
- if (j[key] === undefined) j[key] = {};
1652
+ if (j[key] === undefined) {
1653
+ if (Object.keys(desired).length === 0 && flavor !== "claude") process.exit(0);
1654
+ j[key] = {};
1655
+ }
1607
1656
  if (!plainObject(j[key])) {
1608
1657
  throw new Error("MCP config " + key + " must be an object");
1609
1658
  }
1610
1659
  let changed = false;
1611
- for (const [k, v] of Object.entries(JSON.parse(process.argv[2]))) {
1612
- if (JSON.stringify(j[key][k]) !== JSON.stringify(v)) { j[key][k] = v; changed = true; }
1660
+ // A foreign same-name entry is a local shadow, not a failed reconciliation:
1661
+ // preserve it, apply every other slug, and memoize success. If the user later
1662
+ // removes it, the next dirty pass or task restart installs the Uai definition.
1663
+ const skipped = new Set();
1664
+ const warnings = [];
1665
+ for (const [slug, current] of Object.entries(j[key])) {
1666
+ if (Object.hasOwn(desired, slug)) {
1667
+ if (JSON.stringify(current) === JSON.stringify(desired[slug])) continue;
1668
+ if (!isManaged(current, slug)) {
1669
+ skipped.add(slug);
1670
+ continue;
1671
+ }
1672
+ j[key][slug] = desired[slug];
1673
+ changed = true;
1674
+ } else if (isManaged(current, slug)) {
1675
+ delete j[key][slug];
1676
+ changed = true;
1677
+ }
1678
+ }
1679
+ for (const [slug, value] of Object.entries(desired)) {
1680
+ if (!Object.hasOwn(j[key], slug)) {
1681
+ j[key][slug] = value;
1682
+ changed = true;
1683
+ }
1684
+ }
1685
+ for (const slug of skipped) {
1686
+ warnings.push(
1687
+ "[uai-mcp] kept foreign server " + slug +
1688
+ "; skipped its Uai definition in " + p + "\\n"
1689
+ );
1613
1690
  }
1614
1691
  if (changed || original === null) {
1692
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1615
1693
  const mode = original === null ? 0o644 : fs.statSync(p).mode & 0o777;
1616
1694
  const tmp = path.join(
1617
1695
  path.dirname(p),
@@ -1638,6 +1716,7 @@ if (changed || original === null) {
1638
1716
  try { fs.unlinkSync(tmp); } catch {}
1639
1717
  }
1640
1718
  }
1719
+ for (const warning of warnings) process.stderr.write(warning);
1641
1720
  `.trim();
1642
1721
 
1643
1722
  /**
@@ -1646,8 +1725,8 @@ if (changed || original === null) {
1646
1725
  * Codex itself is the TOML parser. This matters because a text search cannot
1647
1726
  * distinguish `[mcp_servers.foo]` from a comment/string, and quoted or spaced
1648
1727
  * spellings name the same TOML table. A same-name definition is accepted only
1649
- * when Codex reports the exact shape Uai writes; foreign definitions and
1650
- * malformed configs fail without being modified.
1728
+ * when Codex reports the exact shape Uai writes. Foreign same-name definitions
1729
+ * shadow only that slug; malformed configs still fail without modification.
1651
1730
  */
1652
1731
  export const ENSURE_CODEX_MCP_TOML = String.raw`
1653
1732
  const fs = require("fs");
@@ -1658,11 +1737,15 @@ const child = require("child_process");
1658
1737
  const home = process.argv[1];
1659
1738
  const desired = JSON.parse(process.argv[2]);
1660
1739
  const codexBin = process.argv[3] || "codex";
1740
+ const taskId = process.argv[4];
1661
1741
  const plainObject = (value) =>
1662
1742
  value !== null && typeof value === "object" && !Array.isArray(value);
1663
1743
  if (typeof home !== "string" || !path.isAbsolute(home)) {
1664
1744
  throw new Error("Codex home must be an absolute path");
1665
1745
  }
1746
+ if (typeof taskId !== "string" || taskId.length === 0) {
1747
+ throw new Error("invalid MCP task id");
1748
+ }
1666
1749
  if (!plainObject(desired)) {
1667
1750
  throw new Error("Codex MCP definitions must be an object");
1668
1751
  }
@@ -1676,7 +1759,11 @@ for (const [slug, url] of Object.entries(desired)) {
1676
1759
  }
1677
1760
 
1678
1761
  fs.mkdirSync(home, { recursive: true });
1679
- const file = path.join(home, "config.toml");
1762
+ // Codex canonicalizes CODEX_HOME before resolving relative config paths. Keep
1763
+ // the same stable target so symlinked homes validate beside the directory
1764
+ // Codex actually uses, not beside the link's lexical parent.
1765
+ const sourceHome = fs.realpathSync(home);
1766
+ const file = path.join(sourceHome, "config.toml");
1680
1767
  let original = null;
1681
1768
  try {
1682
1769
  original = fs.readFileSync(file, "utf8");
@@ -1684,10 +1771,10 @@ try {
1684
1771
  if (!error || error.code !== "ENOENT") throw error;
1685
1772
  }
1686
1773
 
1687
- function runCodex(args) {
1774
+ function runCodex(args, codexHome = sourceHome) {
1688
1775
  return child.spawnSync(codexBin, args, {
1689
1776
  encoding: "utf8",
1690
- env: Object.assign({}, process.env, { CODEX_HOME: home }),
1777
+ env: Object.assign({}, process.env, { CODEX_HOME: codexHome }),
1691
1778
  maxBuffer: 1024 * 1024,
1692
1779
  timeout: 5000,
1693
1780
  killSignal: "SIGKILL",
@@ -1709,7 +1796,7 @@ function exactKeys(value, keys) {
1709
1796
 
1710
1797
  function managedDefinitionUrl(value, slug) {
1711
1798
  const topKeys = [
1712
- "disabled_reason", "disabled_tools", "enabled", "enabled_tools", "name",
1799
+ "auth_status", "disabled_reason", "enabled", "name",
1713
1800
  "startup_timeout_sec", "tool_timeout_sec", "transport"
1714
1801
  ];
1715
1802
  const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
@@ -1717,8 +1804,7 @@ function managedDefinitionUrl(value, slug) {
1717
1804
  value.name === slug &&
1718
1805
  value.enabled === true &&
1719
1806
  value.disabled_reason === null &&
1720
- value.enabled_tools === null &&
1721
- value.disabled_tools === null &&
1807
+ value.auth_status === "unsupported" &&
1722
1808
  value.startup_timeout_sec === null &&
1723
1809
  value.tool_timeout_sec === null &&
1724
1810
  exactKeys(value.transport, transportKeys) &&
@@ -1750,15 +1836,18 @@ function gatewayIdentity(value, slug) {
1750
1836
  } catch {
1751
1837
  return null;
1752
1838
  }
1753
- if (parsed.username || parsed.password || parsed.search || parsed.hash) {
1839
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
1840
+ parsed.username || parsed.password || parsed.search || parsed.hash) {
1754
1841
  return null;
1755
1842
  }
1756
1843
  const segments = parsed.pathname.split("/").filter(Boolean);
1757
- if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug) {
1844
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
1845
+ parsed.pathname !== "/t/" + segments[1] + "/" + slug) {
1758
1846
  return null;
1759
1847
  }
1760
- const token = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(segments[1]);
1761
- if (!token) return null;
1848
+ const token = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{32})$/.exec(segments[1]);
1849
+ if (!token || value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
1850
+ "/t/" + segments[1] + "/" + slug) return null;
1762
1851
  return { origin: parsed.origin, taskId: token[1] };
1763
1852
  }
1764
1853
 
@@ -1779,12 +1868,12 @@ function escapeRegExp(value) {
1779
1868
  }
1780
1869
 
1781
1870
  /**
1782
- * Replace only the URL token inside a raw block Uai can prove it authored.
1871
+ * Replace or remove a raw block Uai can prove it authored.
1783
1872
  * The exact legacy three-line shape predates the marker. New blocks carry the
1784
1873
  * ADR-057 marker. A quoted/spaced header, extra field, or neighboring body
1785
1874
  * text is deliberately foreign even if Codex normalizes it to the same JSON.
1786
1875
  */
1787
- function rewriteManagedRawBlock(text, slug, oldUrl, desiredUrl) {
1876
+ function editManagedRawBlock(text, slug, oldUrl, desiredUrl) {
1788
1877
  const prefix =
1789
1878
  "(^|\\r?\\n)\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
1790
1879
  "command = \\\"npx\\\"\\r?\\n";
@@ -1824,64 +1913,176 @@ function rewriteManagedRawBlock(text, slug, oldUrl, desiredUrl) {
1824
1913
  if (matches.length !== 1) return null;
1825
1914
  const match = matches[0];
1826
1915
  const start = match.index || 0;
1827
- const replacement = match[0].replace(
1828
- JSON.stringify(oldUrl),
1829
- JSON.stringify(desiredUrl)
1830
- );
1916
+ const replacement = desiredUrl === null
1917
+ ? match[1]
1918
+ : match[0].replace(JSON.stringify(oldUrl), JSON.stringify(desiredUrl));
1831
1919
  return text.slice(0, start) + replacement + text.slice(start + match[0].length);
1832
1920
  }
1833
1921
 
1834
- function getDefinition(slug) {
1835
- const result = runCodex(["mcp", "get", slug, "--json"]);
1922
+ /** The Codex parser has already proved the table's semantics. This raw check
1923
+ * rejects user customizations the list view intentionally omits, while still
1924
+ * accepting equivalent quoted/spaced table headers without claiming them as
1925
+ * removable Uai-owned text. */
1926
+ function plainRawDefinition(text, slug) {
1927
+ const atom = escapeRegExp(slug);
1928
+ const header = new RegExp(
1929
+ "^[ \\t]*\\[[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
1930
+ "[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
1931
+ "[ \\t]*\\][ \\t]*(?:#.*)?$",
1932
+ "gm"
1933
+ );
1934
+ const matches = Array.from(text.matchAll(header));
1935
+ if (matches.length !== 1) return false;
1936
+ const match = matches[0];
1937
+ const remainder = text.slice((match.index || 0) + match[0].length);
1938
+ const nextHeader = remainder.search(/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m);
1939
+ const body = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
1940
+ const fields = body
1941
+ .split(/\r?\n/)
1942
+ .map((line) => line.trim())
1943
+ .filter((line) => line !== "" && !line.startsWith("#"));
1944
+ return fields.length === 2 &&
1945
+ fields.some((line) => /^command[ \t]*=/.test(line)) &&
1946
+ fields.some((line) => /^args[ \t]*=/.test(line));
1947
+ }
1948
+
1949
+ /** Any descendant table is user/Codex-authored state that the parent marker
1950
+ * does not prove Uai owns. Scan the whole document: TOML permits unrelated
1951
+ * tables between a parent and its descendants. */
1952
+ function hasRawDescendantTable(text, slug) {
1953
+ const atom = escapeRegExp(slug);
1954
+ return new RegExp(
1955
+ "^[ \\t]*\\[\\[?[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
1956
+ "[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
1957
+ "[ \\t]*\\.",
1958
+ "m"
1959
+ ).test(text);
1960
+ }
1961
+
1962
+ function listDefinitions(codexHome = sourceHome) {
1963
+ // One list call validates the whole TOML document and identifies truly
1964
+ // absent servers. A malformed document fails before any edit.
1965
+ const result = runCodex(["mcp", "list", "--json"], codexHome);
1836
1966
  if (result.status !== 0) {
1837
- throw new Error("codex mcp get " + slug + " failed: " + detail(result));
1967
+ throw new Error("Codex config is invalid: " + detail(result));
1838
1968
  }
1839
1969
  let value;
1840
1970
  try {
1841
1971
  value = JSON.parse(result.stdout);
1842
1972
  } catch {
1843
- throw new Error("codex mcp get " + slug + " returned invalid JSON");
1973
+ throw new Error("codex mcp list returned invalid JSON");
1974
+ }
1975
+ if (!Array.isArray(value) ||
1976
+ !value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
1977
+ throw new Error("codex mcp list returned an invalid server list");
1844
1978
  }
1845
1979
  return value;
1846
1980
  }
1847
1981
 
1848
- // One list call validates the whole TOML document and identifies truly absent
1849
- // servers. A malformed document therefore fails before any append.
1850
- const listedResult = runCodex(["mcp", "list", "--json"]);
1851
- if (listedResult.status !== 0) {
1852
- throw new Error("Codex config is invalid: " + detail(listedResult));
1982
+ /** Validate a candidate before publication through the same Codex parser used
1983
+ * for the real home. The sibling directory preserves ../ relative paths; its
1984
+ * top-level symlinks preserve paths within CODEX_HOME without copying secrets
1985
+ * or other account state. */
1986
+ function validateCandidate(text, mode) {
1987
+ const validationHome = fs.mkdtempSync(path.join(
1988
+ path.dirname(sourceHome),
1989
+ "." + path.basename(sourceHome) + ".uai-validate-"
1990
+ ));
1991
+ try {
1992
+ for (const name of fs.readdirSync(sourceHome)) {
1993
+ if (name === path.basename(file)) continue;
1994
+ fs.symlinkSync(
1995
+ path.join(sourceHome, name),
1996
+ path.join(validationHome, name)
1997
+ );
1998
+ }
1999
+ const validationFile = path.join(validationHome, path.basename(file));
2000
+ fs.writeFileSync(validationFile, text, {
2001
+ encoding: "utf8",
2002
+ flag: "wx",
2003
+ mode,
2004
+ });
2005
+ const definitions = listDefinitions(validationHome);
2006
+ if (fs.readFileSync(validationFile, "utf8") !== text) {
2007
+ throw new Error("Codex changed its config during candidate validation");
2008
+ }
2009
+ return definitions;
2010
+ } finally {
2011
+ fs.rmSync(validationHome, { recursive: true, force: true });
2012
+ }
2013
+ }
2014
+
2015
+ for (const [slug, url] of Object.entries(desired)) {
2016
+ if (gatewayIdentity(url, slug)?.taskId !== taskId) {
2017
+ throw new Error("invalid desired Codex MCP gateway URL");
2018
+ }
1853
2019
  }
1854
- let listed;
1855
- try {
1856
- listed = JSON.parse(listedResult.stdout);
1857
- } catch {
1858
- throw new Error("codex mcp list returned invalid JSON");
2020
+
2021
+ const listed = listDefinitions();
2022
+ const bySlug = new Map();
2023
+ for (const entry of listed) {
2024
+ if (bySlug.has(entry.name)) {
2025
+ throw new Error("Codex MCP server " + entry.name + " is duplicated - left unchanged");
2026
+ }
2027
+ bySlug.set(entry.name, entry);
1859
2028
  }
1860
- if (!Array.isArray(listed) ||
1861
- !listed.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
1862
- throw new Error("codex mcp list returned an invalid server list");
2029
+
2030
+ const managed = new Map();
2031
+ const customized = new Set();
2032
+ for (const [slug, definition] of bySlug) {
2033
+ const url = managedDefinitionUrl(definition, slug);
2034
+ if (url !== null && gatewayIdentity(url, slug)?.taskId === taskId) {
2035
+ if (!hasRawDescendantTable(original || "", slug) &&
2036
+ editManagedRawBlock(original || "", slug, url, url) !== null) {
2037
+ managed.set(slug, url);
2038
+ } else {
2039
+ customized.add(slug);
2040
+ }
2041
+ }
1863
2042
  }
1864
2043
 
1865
2044
  const missing = [];
1866
2045
  const rewrites = [];
2046
+ // As with JSON configs, a foreign definition shadows only this slug in this
2047
+ // home. Other homes and engine writers must still run.
2048
+ const skipped = new Set();
2049
+ const warnings = [];
1867
2050
  for (const [slug, url] of Object.entries(desired)) {
1868
- const matches = listed.filter((entry) => entry.name === slug);
1869
- if (matches.length === 0) {
2051
+ const definition = bySlug.get(slug);
2052
+ if (definition === undefined) {
1870
2053
  missing.push([slug, url]);
1871
2054
  continue;
1872
2055
  }
1873
- if (matches.length !== 1) {
1874
- throw new Error("Codex MCP server " + slug + " is foreign - left unchanged");
1875
- }
1876
- const definition = getDefinition(slug);
1877
- if (exactDefinition(definition, slug, url)) continue;
1878
- const oldUrl = managedDefinitionUrl(definition, slug);
1879
- if (oldUrl === null || !sameTaskGateway(oldUrl, url, slug)) {
1880
- throw new Error("Codex MCP server " + slug + " is foreign - left unchanged");
2056
+ if (exactDefinition(definition, slug, url) &&
2057
+ plainRawDefinition(original || "", slug)) continue;
2058
+ const oldUrl = managed.get(slug);
2059
+ if (oldUrl === undefined || !sameTaskGateway(oldUrl, url, slug)) {
2060
+ skipped.add(slug);
2061
+ continue;
1881
2062
  }
1882
2063
  rewrites.push([slug, oldUrl, url]);
1883
2064
  }
1884
- if (missing.length === 0 && rewrites.length === 0) process.exit(0);
2065
+ const removals = [...managed.entries()].filter(([slug]) =>
2066
+ !Object.hasOwn(desired, slug)
2067
+ );
2068
+ for (const slug of skipped) {
2069
+ warnings.push(
2070
+ "[uai-mcp] kept foreign Codex server " + slug +
2071
+ "; skipped its Uai definition in " + home + "\n"
2072
+ );
2073
+ }
2074
+ for (const slug of customized) {
2075
+ if (!Object.hasOwn(desired, slug)) {
2076
+ warnings.push(
2077
+ "[uai-mcp] kept customized Codex server " + slug +
2078
+ "; skipped its Uai removal in " + home + "\n"
2079
+ );
2080
+ }
2081
+ }
2082
+ if (missing.length === 0 && rewrites.length === 0 && removals.length === 0) {
2083
+ for (const warning of warnings) process.stderr.write(warning);
2084
+ process.exit(0);
2085
+ }
1885
2086
 
1886
2087
  const newline = original && original.includes("\r\n") ? "\r\n" : "\n";
1887
2088
  const blocks = missing.map(([slug, url]) => [
@@ -1892,8 +2093,15 @@ const blocks = missing.map(([slug, url]) => [
1892
2093
  "",
1893
2094
  ].join(newline)).join(newline);
1894
2095
  let next = original || "";
2096
+ for (const [slug, oldUrl] of removals) {
2097
+ const removed = editManagedRawBlock(next, slug, oldUrl, null);
2098
+ if (removed === null) {
2099
+ throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
2100
+ }
2101
+ next = removed;
2102
+ }
1895
2103
  for (const [slug, oldUrl, desiredUrl] of rewrites) {
1896
- const rewritten = rewriteManagedRawBlock(next, slug, oldUrl, desiredUrl);
2104
+ const rewritten = editManagedRawBlock(next, slug, oldUrl, desiredUrl);
1897
2105
  if (rewritten === null) {
1898
2106
  throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
1899
2107
  }
@@ -1906,8 +2114,26 @@ if (blocks.length > 0) {
1906
2114
  }
1907
2115
 
1908
2116
  const mode = original === null ? 0o644 : fs.statSync(file).mode & 0o777;
2117
+ // Validate and verify the complete candidate before it can replace the real
2118
+ // file. This is the final safety boundary for TOML spellings the conservative
2119
+ // raw ownership checks do not recognize (for example escaped quoted keys).
2120
+ const verified = validateCandidate(next, mode);
2121
+ const verifiedBySlug = new Map(verified.map((entry) => [entry.name, entry]));
2122
+ for (const [slug, url] of Object.entries(desired)) {
2123
+ if (skipped.has(slug)) continue;
2124
+ if (!exactDefinition(verifiedBySlug.get(slug), slug, url) ||
2125
+ !plainRawDefinition(next, slug)) {
2126
+ throw new Error("Codex MCP server " + slug + " failed verification");
2127
+ }
2128
+ }
2129
+ for (const [slug] of removals) {
2130
+ if (verifiedBySlug.has(slug)) {
2131
+ throw new Error("Codex MCP server " + slug + " was not removed");
2132
+ }
2133
+ }
2134
+
1909
2135
  const tmp = path.join(
1910
- home,
2136
+ sourceHome,
1911
2137
  "." + path.basename(file) + "." + process.pid + "." +
1912
2138
  crypto.randomBytes(8).toString("hex") + ".tmp"
1913
2139
  );
@@ -1926,50 +2152,239 @@ try {
1926
2152
  if (current !== original) {
1927
2153
  throw new Error("Codex config changed concurrently");
1928
2154
  }
2155
+ if (fs.realpathSync(home) !== sourceHome) {
2156
+ throw new Error("Codex home changed concurrently");
2157
+ }
1929
2158
  fs.renameSync(tmp, file);
1930
2159
  } finally {
1931
2160
  try { fs.unlinkSync(tmp); } catch {}
1932
2161
  }
2162
+ for (const warning of warnings) process.stderr.write(warning);
2163
+ `.trim();
1933
2164
 
1934
- // Do not memoize a syntactically bad append/rewrite as success. Codex is again
1935
- // the authority, and every changed definition must normalize exactly.
1936
- for (const [slug, url] of [
1937
- ...missing,
1938
- ...rewrites.map(([slug, _oldUrl, url]) => [slug, url]),
1939
- ]) {
1940
- if (!exactDefinition(getDefinition(slug), slug, url)) {
1941
- throw new Error("Codex MCP server " + slug + " failed verification");
2165
+ /** Reconcile one GROK_HOME through Grok's own parser/writer. Compatibility
2166
+ * sources and project scope are disabled for the ownership probe: only an
2167
+ * exact user-scope HTTP entry routed through this task's gateway is removed
2168
+ * or replaced. */
2169
+ export const RECONCILE_GROK_MCP = String.raw`
2170
+ const child = require("child_process");
2171
+ const path = require("path");
2172
+
2173
+ const home = process.argv[1];
2174
+ const desired = JSON.parse(process.argv[2]);
2175
+ const grokBin = process.argv[3];
2176
+ const taskId = process.argv[4];
2177
+ const plainObject = (value) =>
2178
+ value !== null && typeof value === "object" && !Array.isArray(value);
2179
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
2180
+ throw new Error("Grok home must be an absolute path");
2181
+ }
2182
+ if (!plainObject(desired)) {
2183
+ throw new Error("Grok MCP definitions must be an object");
2184
+ }
2185
+ if (typeof grokBin !== "string" || !path.isAbsolute(grokBin)) {
2186
+ throw new Error("Grok binary must be an absolute path");
2187
+ }
2188
+ if (typeof taskId !== "string" || taskId.length === 0) {
2189
+ throw new Error("invalid MCP task id");
2190
+ }
2191
+
2192
+ function gatewayIdentity(value, slug) {
2193
+ let parsed;
2194
+ try { parsed = new URL(value); } catch { return null; }
2195
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
2196
+ parsed.username || parsed.password || parsed.search || parsed.hash) return null;
2197
+ const segments = parsed.pathname.split("/").filter(Boolean);
2198
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
2199
+ parsed.pathname !== "/t/" + segments[1] + "/" + slug) return null;
2200
+ const prefix = taskId + ".";
2201
+ if (!segments[1].startsWith(prefix) ||
2202
+ !/^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length))) return null;
2203
+ if (value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
2204
+ "/t/" + segments[1] + "/" + slug) return null;
2205
+ return { taskId: taskId };
2206
+ }
2207
+
2208
+ for (const [slug, url] of Object.entries(desired)) {
2209
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) ||
2210
+ typeof url !== "string" || gatewayIdentity(url, slug) === null) {
2211
+ throw new Error("invalid desired Grok MCP definition for " + slug);
1942
2212
  }
1943
2213
  }
2214
+
2215
+ function run(args) {
2216
+ return child.spawnSync(grokBin, args, {
2217
+ cwd: "/",
2218
+ encoding: "utf8",
2219
+ env: Object.assign({}, process.env, {
2220
+ GROK_HOME: home,
2221
+ GROK_CURSOR_MCPS_ENABLED: "false",
2222
+ GROK_CLAUDE_MCPS_ENABLED: "false",
2223
+ }),
2224
+ maxBuffer: 1024 * 1024,
2225
+ timeout: 5000,
2226
+ killSignal: "SIGKILL",
2227
+ });
2228
+ }
2229
+
2230
+ function detail(result) {
2231
+ const lines = String(result.stderr || result.stdout || "")
2232
+ .split(/\r?\n/)
2233
+ .map((line) => line.trim())
2234
+ .filter(Boolean);
2235
+ return String(lines.pop() || result.error || result.status);
2236
+ }
2237
+
2238
+ function list() {
2239
+ const result = run(["mcp", "list", "--json"]);
2240
+ if (result.status !== 0) {
2241
+ throw new Error("Grok config is invalid: " + detail(result));
2242
+ }
2243
+ let value;
2244
+ try { value = JSON.parse(result.stdout); }
2245
+ catch { throw new Error("grok mcp list returned invalid JSON"); }
2246
+ if (!Array.isArray(value) ||
2247
+ !value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
2248
+ throw new Error("grok mcp list returned an invalid server list");
2249
+ }
2250
+ return value;
2251
+ }
2252
+
2253
+ function managedUrl(entry, slug) {
2254
+ if (!plainObject(entry)) return null;
2255
+ const keys = Object.keys(entry).sort().join("\0");
2256
+ if (keys !== ["enabled", "name", "scope", "url"].sort().join("\0") ||
2257
+ entry.name !== slug || entry.scope !== "user" || entry.enabled !== true ||
2258
+ typeof entry.url !== "string") return null;
2259
+ return gatewayIdentity(entry.url, slug) === null ? null : entry.url;
2260
+ }
2261
+
2262
+ const current = new Map();
2263
+ const managed = new Map();
2264
+ for (const entry of list()) {
2265
+ if (current.has(entry.name)) {
2266
+ throw new Error("Grok MCP server " + entry.name + " is duplicated - left unchanged");
2267
+ }
2268
+ current.set(entry.name, entry);
2269
+ const url = managedUrl(entry, entry.name);
2270
+ if (url !== null) managed.set(entry.name, url);
2271
+ }
2272
+
2273
+ const additions = [];
2274
+ // A user-scope foreign entry shadows only this slug in this GROK_HOME.
2275
+ const skipped = new Set();
2276
+ const warnings = [];
2277
+ for (const [slug, url] of Object.entries(desired)) {
2278
+ const entry = current.get(slug);
2279
+ if (entry === undefined) {
2280
+ additions.push([slug, url]);
2281
+ } else if (managed.get(slug) === url) {
2282
+ continue;
2283
+ } else if (managed.has(slug)) {
2284
+ additions.push([slug, url]);
2285
+ } else {
2286
+ skipped.add(slug);
2287
+ }
2288
+ }
2289
+ const removals = [...managed.keys()].filter((slug) => !Object.hasOwn(desired, slug));
2290
+ for (const slug of skipped) {
2291
+ warnings.push(
2292
+ "[uai-mcp] kept foreign Grok server " + slug +
2293
+ "; skipped its Uai definition in " + home + "\n"
2294
+ );
2295
+ }
2296
+ if (additions.length === 0 && removals.length === 0) {
2297
+ for (const warning of warnings) process.stderr.write(warning);
2298
+ process.exit(0);
2299
+ }
2300
+
2301
+ for (const [slug, url] of additions) {
2302
+ const result = run(["mcp", "add", slug, url, "-t", "http", "-s", "user"]);
2303
+ if (result.status !== 0) {
2304
+ throw new Error("grok mcp add " + slug + " failed: " + detail(result));
2305
+ }
2306
+ }
2307
+ for (const slug of removals) {
2308
+ const result = run(["mcp", "remove", slug, "-s", "user"]);
2309
+ if (result.status !== 0) {
2310
+ throw new Error("grok mcp remove " + slug + " failed: " + detail(result));
2311
+ }
2312
+ }
2313
+
2314
+ const verified = new Map(list().map((entry) => [entry.name, entry]));
2315
+ for (const [slug, url] of Object.entries(desired)) {
2316
+ if (skipped.has(slug)) continue;
2317
+ if (managedUrl(verified.get(slug), slug) !== url) {
2318
+ throw new Error("Grok MCP server " + slug + " failed verification");
2319
+ }
2320
+ }
2321
+ for (const slug of removals) {
2322
+ if (verified.has(slug)) {
2323
+ throw new Error("Grok MCP server " + slug + " was not removed");
2324
+ }
2325
+ }
2326
+ for (const warning of warnings) process.stderr.write(warning);
1944
2327
  `.trim();
1945
2328
 
1946
2329
  function shellQuote(value: string): string {
1947
2330
  return `'${value.replace(/'/g, `'\\''`)}'`;
1948
2331
  }
1949
2332
 
2333
+ /** Config homes are independent, but every Uai/browser writer must remain
2334
+ * behind one flock. Run a bounded batch at a time so the supported account ×
2335
+ * agent count cannot turn the outer timeout into a deterministic failure. */
2336
+ const MCP_CONFIG_HOME_WRITE_CONCURRENCY = 8;
2337
+
2338
+ function boundedParallelShell(
2339
+ commands: readonly string[],
2340
+ width = MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2341
+ ): string | null {
2342
+ if (commands.length === 0) return null;
2343
+ const batches: string[] = [];
2344
+ for (let offset = 0; offset < commands.length; offset += width) {
2345
+ const batch = commands.slice(offset, offset + width);
2346
+ const launches = batch.map(
2347
+ (command, index) => `(${command}) & uai_mcp_pid_${index}=$!`,
2348
+ );
2349
+ const waits = batch.map(
2350
+ (_command, index) =>
2351
+ `wait "$uai_mcp_pid_${index}" || uai_mcp_status=1`,
2352
+ );
2353
+ batches.push(
2354
+ `{ ${launches.join("; ")}; uai_mcp_status=0; ${waits.join("; ")}; ` +
2355
+ `[ "$uai_mcp_status" -eq 0 ]; }`,
2356
+ );
2357
+ }
2358
+ return batches.join(" && ");
2359
+ }
2360
+
1950
2361
  // The host must never win this timeout race: killing the local `docker exec`
1951
- // client does not kill its in-container child. The full writer (including its
1952
- // 10s lock wait and every engine-specific command) gets 25s before TERM and
1953
- // 5s more before KILL, below the 40s host backstop.
2362
+ // client does not kill its in-container child. The in-runtime deadline scales
2363
+ // with bounded home batches below; the host backstop always follows TERM/KILL.
1954
2364
  /** The grok CLI inside task containers/machines (x.ai installer path). */
1955
2365
  const GROK_BIN = "/home/node/.grok/bin/grok";
1956
- const MCP_CONFIG_WRITE_TIMEOUT_S = 25;
2366
+ const MCP_CONFIG_MIN_WRITE_TIMEOUT_S = 25;
1957
2367
  const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
1958
- const MCP_CONFIG_HOST_TIMEOUT_MS = 40_000;
2368
+ const MCP_CONFIG_HOST_GRACE_S = 10;
2369
+ const MCP_CONFIG_CODEX_BATCH_BUDGET_S = 3;
2370
+ // One home can replace/remove two disjoint maximum-size memberships. Grok's
2371
+ // CLI has no bulk mutation, so give a bounded batch a budget proportional to
2372
+ // the accepted manifest ceiling rather than pretending it is constant work.
2373
+ const MCP_CONFIG_GROK_BATCH_BUDGET_S = 2 + Math.ceil(MAX_MANIFEST_MCP / 4);
1959
2374
 
1960
2375
  /**
1961
2376
  * Write the task's MCP configs inside the container, one shape per engine on
1962
2377
  * the roster — all pointing at the same host gateway URLs:
1963
2378
  * - Claude — /workspace/.mcp.json (merged; coexists with the ADR-053 browser
1964
2379
  * server). Written unconditionally: the adapter passes `--mcp-config`.
1965
- * - Codex — mcp-remote shims appended once per slug to config.toml.
2380
+ * - Codex — reconciled mcp-remote shims in config.toml.
1966
2381
  * - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
1967
2382
  * `--approve-mcps`).
1968
- * - Grok — `grok mcp add -s user` into every GROK_HOME the task may use
1969
- * (idempotent per slug; project scope is refused in an untrusted folder).
2383
+ * - Grok — reconciled through `grok mcp` in every GROK_HOME the task may use
2384
+ * (project scope is refused in an untrusted folder).
1970
2385
  * - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
1971
2386
  * oauth:false so it treats the gateway URL as a plain server; the path
1972
- * token is the auth). Written only when the task has connections.
2387
+ * token is the auth).
1973
2388
  * Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
1974
2389
  */
1975
2390
  export async function setupMcpTaskConfig(
@@ -2032,74 +2447,98 @@ export async function setupMcpTaskConfig(
2032
2447
  oauth: false,
2033
2448
  };
2034
2449
  }
2035
- // Browser migration touches these same Claude/Codex files. Keep every Uai
2036
- // writer in one critical section so neither side can erase the other's
2037
- // update between a read and its replacement/append.
2450
+ const codexWrites = has("codex")
2451
+ ? codexHomes.map(
2452
+ (home) =>
2453
+ `node -e "$uai_codex_mcp_writer" ${shellQuote(home)} ` +
2454
+ `"$uai_mcp_desired" codex "$uai_mcp_task"`,
2455
+ )
2456
+ : [];
2457
+ const grokWrites = has("grok")
2458
+ ? grokHomes
2459
+ .filter((home) => /^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home))
2460
+ .map(
2461
+ (home) =>
2462
+ `node -e "$uai_grok_mcp_writer" ${shellQuote(home)} ` +
2463
+ `"$uai_mcp_desired" ${GROK_BIN} "$uai_mcp_task"`,
2464
+ )
2465
+ : [];
2466
+ const homeWrites = boundedParallelShell([...codexWrites, ...grokWrites]);
2467
+ const codexBatches = Math.ceil(
2468
+ codexWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2469
+ );
2470
+ const grokBatches = Math.ceil(
2471
+ grokWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2472
+ );
2473
+ const writeTimeoutS = Math.max(
2474
+ MCP_CONFIG_MIN_WRITE_TIMEOUT_S,
2475
+ 10 +
2476
+ codexBatches * MCP_CONFIG_CODEX_BATCH_BUDGET_S +
2477
+ grokBatches * MCP_CONFIG_GROK_BATCH_BUDGET_S,
2478
+ );
2479
+ const hostTimeoutMs =
2480
+ (writeTimeoutS +
2481
+ MCP_CONFIG_WRITE_KILL_AFTER_S +
2482
+ MCP_CONFIG_HOST_GRACE_S) *
2483
+ 1_000;
2484
+ // Browser migration touches these same Claude/Codex/Cursor files and
2485
+ // invokes Grok's own read-modify-write CLI. Keep every shared writer in
2486
+ // one critical section so neither side can erase the other's update.
2038
2487
  const lockedWrites = [
2039
2488
  `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
2040
2489
  JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
2041
2490
  )} > /workspace/.claude/settings.json`,
2042
2491
  `node -e ${shellQuote(
2043
2492
  MERGE_MCP_JSON,
2044
- )} /workspace/.mcp.json ${shellQuote(JSON.stringify(claudeEntries))}`,
2045
- // Codex is stdio-only. Ask Codex itself to classify the TOML before
2046
- // atomically appending any missing mcp-remote shims.
2047
- ...(has("codex") && connections.length > 0
2048
- ? codexHomes.map(
2049
- (home) =>
2050
- `node -e ${shellQuote(ENSURE_CODEX_MCP_TOML)} ${shellQuote(home)} ${shellQuote(
2051
- JSON.stringify(codexEntries),
2052
- )}`,
2053
- )
2054
- : []),
2055
- ].join(" && ");
2056
- const writes = [
2057
- "mkdir -p /workspace/.claude",
2058
- `flock -w 10 ${MCP_CONFIG_LOCK_PATH} sh -lc ${shellQuote(lockedWrites)}`,
2059
- // Cursor: reads ~/.cursor/mcp.json (the adapter passes --approve-mcps).
2060
- ...(has("cursor") && connections.length > 0
2493
+ )} /workspace/.mcp.json ${shellQuote(JSON.stringify(claudeEntries))} mcpServers claude ${shellQuote(
2494
+ taskId,
2495
+ )}`,
2496
+ ...(homeWrites === null
2497
+ ? []
2498
+ : [
2499
+ `uai_mcp_desired=${shellQuote(JSON.stringify(codexEntries))}`,
2500
+ `uai_mcp_task=${shellQuote(taskId)}`,
2501
+ ...(codexWrites.length === 0
2502
+ ? []
2503
+ : [`uai_codex_mcp_writer=${shellQuote(ENSURE_CODEX_MCP_TOML)}`]),
2504
+ ...(grokWrites.length === 0
2505
+ ? []
2506
+ : [`uai_grok_mcp_writer=${shellQuote(RECONCILE_GROK_MCP)}`]),
2507
+ ]),
2508
+ // Codex/Grok homes do not share files. Bound their parallelism while
2509
+ // holding the one cross-writer lock; each home still reconciles itself
2510
+ // serially through its engine's parser/writer.
2511
+ ...(homeWrites === null ? [] : [homeWrites]),
2512
+ ...(has("cursor")
2061
2513
  ? [
2062
2514
  `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.cursor/mcp.json ${shellQuote(
2063
2515
  JSON.stringify(cursorEntries),
2064
- )}`,
2516
+ )} mcpServers cursor ${shellQuote(taskId)}`,
2065
2517
  ]
2066
2518
  : []),
2067
- // Grok: USER-scope `grok mcp add` once per GROK_HOME the task may run
2068
- // under (GROK_HOME redirects the user config). Project scope is refused
2069
- // for an untrusted folder and /workspace is no git repo (live
2070
- // 2026-09-08). The binary lives at ~/.grok/bin (x.ai installer); the
2071
- // old ~/.local/bin path failed silently behind `|| true`.
2072
- ...(has("grok")
2073
- ? grokHomes
2074
- .filter((home) => /^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home))
2075
- .flatMap((home) =>
2076
- connections.map(
2077
- (c) =>
2078
- `GROK_HOME=${home} ${GROK_BIN} mcp add ${shellQuote(c.slug)} ${shellQuote(
2079
- urlFor(c.slug),
2080
- )} -t http -s user >/dev/null 2>&1 || true`,
2081
- ),
2082
- )
2083
- : []),
2519
+ ].join(" && ");
2520
+ const writes = [
2521
+ "mkdir -p /workspace/.claude",
2522
+ `flock -w 10 ${MCP_CONFIG_LOCK_PATH} sh -lc ${shellQuote(lockedWrites)}`,
2084
2523
  // OpenCode: global config `mcp` block (auto-discovered regardless of the
2085
2524
  // exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
2086
- ...(has("opencode") && connections.length > 0
2525
+ ...(has("opencode")
2087
2526
  ? [
2088
2527
  `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.config/opencode/opencode.json ${shellQuote(
2089
2528
  JSON.stringify(opencodeEntries),
2090
- )} mcp`,
2529
+ )} mcp opencode ${shellQuote(taskId)}`,
2091
2530
  ]
2092
2531
  : []),
2093
2532
  ].join(" && ");
2094
2533
  const steps =
2095
2534
  `timeout --kill-after=${MCP_CONFIG_WRITE_KILL_AFTER_S} ` +
2096
- `${MCP_CONFIG_WRITE_TIMEOUT_S} sh -lc ${shellQuote(writes)}`;
2535
+ `${writeTimeoutS} sh -lc ${shellQuote(writes)}`;
2097
2536
  const result = environment
2098
2537
  ? await environment.exec({
2099
2538
  argv: ["sh", "-lc", steps],
2100
2539
  env: RUNTIME_AUTHORITY_ENV,
2101
2540
  projectEnvProfile: "maintenance",
2102
- timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS,
2541
+ timeoutMs: hostTimeoutMs,
2103
2542
  maxOutputBytes: 256 * 1024,
2104
2543
  })
2105
2544
  : await taskContainerCli(
@@ -2117,19 +2556,34 @@ export async function setupMcpTaskConfig(
2117
2556
  "-lc",
2118
2557
  steps,
2119
2558
  ],
2120
- { timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS },
2559
+ { timeoutMs: hostTimeoutMs },
2121
2560
  );
2122
2561
  const status = "exitCode" in result ? result.exitCode : result.status;
2123
- const stderr =
2124
- "stderr" in result && result.stderr instanceof Uint8Array
2125
- ? Buffer.from(result.stderr).toString("utf8")
2126
- : result.stderr;
2562
+ const stderrValue = "stderr" in result ? result.stderr : "";
2563
+ const stderr = stderrValue instanceof Uint8Array
2564
+ ? Buffer.from(stderrValue).toString("utf8")
2565
+ : String(stderrValue ?? "");
2127
2566
  if (status !== 0) {
2567
+ // Earlier writers in the && chain may have succeeded with local-shadow
2568
+ // warnings before a later writer failed. Remove only those known soft
2569
+ // lines so the bounded log cannot hide the hard parse/I/O diagnostic.
2570
+ const hardStderr = stderr
2571
+ .split(/\r?\n/)
2572
+ .filter((line) =>
2573
+ !/^\[uai-mcp\] kept (?:foreign|customized) .+; skipped its Uai (?:definition|removal) in /.test(line),
2574
+ )
2575
+ .join("\n")
2576
+ .trim();
2128
2577
  console.warn(
2129
- `[mcp-gateway] task ${taskId}: config write failed: ${stderr.slice(0, 300)}`,
2578
+ `[mcp-gateway] task ${taskId}: config write failed: ${(hardStderr || stderr).slice(0, 300)}`,
2130
2579
  );
2131
2580
  return false;
2132
2581
  }
2582
+ if (stderr.trim().length > 0) {
2583
+ console.warn(
2584
+ `[mcp-gateway] task ${taskId}: config write warning: ${stderr.slice(0, 300)}`,
2585
+ );
2586
+ }
2133
2587
  return true;
2134
2588
  } catch (err) {
2135
2589
  console.warn(