@runuai/host 0.9.152 → 0.9.154
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/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +10 -0
- package/lib/agent.ts +4 -0
- package/lib/browser-testing.ts +8 -3
- package/lib/cloud-credential-leases.ts +24 -4
- package/lib/cloud-credential-runtime.ts +322 -17
- package/lib/credential-cleanup-inventory.ts +2 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +140 -32
- package/lib/credential-consumers/mcp.ts +6 -1
- package/lib/mcp-gateway.ts +576 -122
- package/lib/orchestrator.ts +91 -9
- package/lib/runtime-state.ts +88 -0
- package/lib/shared-files-s3.ts +743 -3
- package/lib/sidecar-resume.ts +9 -0
- package/lib/task-credential-cleanup.ts +36 -5
- package/lib/task-environment/machine-task-up.ts +35 -19
- package/lib/task-files-machine-retirement.ts +88 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-person-credentials.ts +76 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +10 -0
- package/src/index.ts +40 -7
- package/src/main.ts +49 -2
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
-
/**
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1612
|
-
|
|
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
|
|
1650
|
-
* malformed configs fail without
|
|
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
|
-
|
|
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:
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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_-]
|
|
1761
|
-
if (!token
|
|
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
|
|
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
|
|
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 =
|
|
1828
|
-
|
|
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
|
-
|
|
1835
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
|
1869
|
-
if (
|
|
2051
|
+
const definition = bySlug.get(slug);
|
|
2052
|
+
if (definition === undefined) {
|
|
1870
2053
|
missing.push([slug, url]);
|
|
1871
2054
|
continue;
|
|
1872
2055
|
}
|
|
1873
|
-
if (
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
|
1952
|
-
//
|
|
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
|
|
2366
|
+
const MCP_CONFIG_MIN_WRITE_TIMEOUT_S = 25;
|
|
1957
2367
|
const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
|
|
1958
|
-
const
|
|
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
|
|
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
|
|
1969
|
-
* (
|
|
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).
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
...(
|
|
2048
|
-
?
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
//
|
|
2060
|
-
|
|
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
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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")
|
|
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
|
-
`${
|
|
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:
|
|
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:
|
|
2559
|
+
{ timeoutMs: hostTimeoutMs },
|
|
2121
2560
|
);
|
|
2122
2561
|
const status = "exitCode" in result ? result.exitCode : result.status;
|
|
2123
|
-
const stderr
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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(
|