@bike4mind/cli 1.1.0 → 2.0.0
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/README.md +4 -1
- package/bin/bike4mind-cli.mjs +0 -5
- package/dist/AgentHistoryStore-DIMzvf-y.mjs +18200 -0
- package/dist/{ApiClient-BOWpVvTq.mjs → ApiClient-BTI0-m0g.mjs} +44 -5
- package/dist/{BubblewrapRuntime-CkL9-gnG.mjs → BubblewrapRuntime-5bLPTwEC.mjs} +2 -2
- package/dist/{ConfigStore-DdHHCH2t.mjs → ConfigStore-DIs-K9oD.mjs} +3541 -1625
- package/dist/{ProxyManager-C1-lgzEU.mjs → ProxyManager-B0-RuR2w.mjs} +21 -4
- package/dist/{SandboxOrchestrator-CIegJrCj.mjs → SandboxOrchestrator-BcUv9fQ3.mjs} +46 -12
- package/dist/{SandboxRuntimeAdapter-ChGlxSGQ.mjs → SandboxRuntimeAdapter-BgLUVTJL.mjs} +2 -2
- package/dist/{SandboxRuntimeAdapter-CKelGICD.mjs → SandboxRuntimeAdapter-BmHELLuM.mjs} +1 -1
- package/dist/{SeatbeltRuntime-Qqt19cAN.mjs → SeatbeltRuntime-C_Y8q8Mr.mjs} +9 -1
- package/dist/buildAgent-DOvEp92a.mjs +2106 -0
- package/dist/commands/acpCommand.mjs +9 -6
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +27 -16
- package/dist/commands/mcpCommand.mjs +3 -3
- package/dist/commands/pluginCommand.mjs +1 -1
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/index.mjs +954 -122
- package/dist/{package-Bqg2LSnH.mjs → package-Dl_F2Fah.mjs} +1 -1
- package/dist/{serve-DO9Edl5S.mjs → serve-Y2zMbfGs.mjs} +237 -73
- package/package.json +11 -12
- package/dist/AgentHistoryStore-kT9eNMRO.mjs +0 -39471
- package/dist/ProxyManager-B1jFWL7b.mjs +0 -3
- package/dist/SandboxOrchestrator-BbMDgjzr.mjs +0 -3
- package/dist/buildAgent-B_kArQ_Y.mjs +0 -833
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as CustomCommandStore, A as createAgentDelegateTool, B as ALWAYS_DENIED_FOR_AGENTS, C as createTodoStore, D as createCoordinateTaskTool, E as parseAgentConfig, F as PermissionManager, H as DEFAULT_MAX_ITERATIONS, J as setWebSocketToolExecutor, K as clearFeatureModuleTools, L as wrapTools, M as substituteArguments, O as createResumeAgentTool, P as findIterationBoundary, Q as RemoteSkillSource, R as getProcessHooks, S as createFindDefinitionTool, T as createSkillTool, U as DEFAULT_RETRY_CONFIG, V as DEFAULT_AGENT_MODEL, W as DEFAULT_THOROUGHNESS, X as buildSystemPrompt, Y as getPlanModeFilePath, Z as buildSkillsPromptSection, _ as createDecisionLogTool, at as
|
|
2
|
+
import { $ as CustomCommandStore, A as createAgentDelegateTool, B as ALWAYS_DENIED_FOR_AGENTS, C as createTodoStore, D as createCoordinateTaskTool, E as parseAgentConfig, F as PermissionManager, H as DEFAULT_MAX_ITERATIONS, J as setWebSocketToolExecutor, K as clearFeatureModuleTools, L as wrapTools, M as substituteArguments, O as createResumeAgentTool, P as findIterationBoundary, Q as RemoteSkillSource, R as getProcessHooks, S as createFindDefinitionTool, T as createSkillTool, U as DEFAULT_RETRY_CONFIG, V as DEFAULT_AGENT_MODEL, W as DEFAULT_THOROUGHNESS, X as buildSystemPrompt, Y as getPlanModeFilePath, Z as buildSkillsPromptSection, _ as createDecisionLogTool, at as searchCommands, b as createGetFileStructureTool, ct as formatFileSize, d as createReviewGateStore, dt as COMPACTION_SUMMARY_MARKER, et as CheckpointStore, f as createReviewGateTool, g as formatBlockersOutput, h as createBlockerTools, j as FallbackLlmBackend, k as createBackgroundAgentTools, lt as searchFiles, m as createBlockerStore, nt as hasFileReferences, ot as mergeCommands, p as formatReviewGatesOutput, pt as MAX_PASTE_SIZE, q as registerFeatureModuleTools, rt as processFileReferences, s as buildSandbox, st as rewireReservedNames, tt as SessionStore, u as isTransientNetworkError, ut as warmFileCache, v as createDecisionStore, w as createWriteTodosTool, x as createWorkItemTools, y as formatDecisionsOutput } from "./AgentHistoryStore-DIMzvf-y.mjs";
|
|
3
3
|
import { a as selectLiveSubagentTokens, i as selectLiveSubagentCredits, n as selectActiveBackgroundShells, o as useCliStore, r as selectCompletedBackgroundShells, t as selectActiveBackgroundAgents } from "./store-CvjTpQPs.mjs";
|
|
4
4
|
import { r as isFeatureEnabled, t as PluginStore } from "./PluginStore-DwvOJ-G3.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { B as REVIEW_GATE_STATUS_VALUES, D as CREDIT_DEDUCT_TRANSACTION_TYPES, H as SUBQUEST_STATUS_VALUES, O as ChatModels, _ as requireApiUrl, _t as actorKindMarker, d as isReadOnlyTool, et as isEarlyStop, f as ApiEndpointUnconfiguredError, g as parseApiUrl, gt as actorColorIndex, h as getEnvironmentName, ht as ACTOR_COLOR_SLOTS, i as isValidSessionId, m as getCreditsUrl, n as logger, p as LOCAL_DEV_URL, r as SESSION_ID_PATTERN, s as extractCompactInstructions, t as ConfigStore, v as resolveApiEndpoint, vt as actorKindSchema, yt as selfClaimedActorKindSchema } from "./ConfigStore-DIs-K9oD.mjs";
|
|
6
|
+
import { a as deferredToolRegistry, i as createToolSearchTool, n as buildSupportingStores, o as NotifyingLlmBackend, r as buildLlmBackend, t as buildAgent } from "./buildAgent-DOvEp92a.mjs";
|
|
6
7
|
import { t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
|
|
7
|
-
import {
|
|
8
|
+
import { i as OAuthClient, t as ApiClient } from "./ApiClient-BTI0-m0g.mjs";
|
|
9
|
+
import { t as version } from "./package-Dl_F2Fah.mjs";
|
|
8
10
|
import { i as getTokenCounter, n as ConversationContext, r as reconstructTurnBlocks, t as WorkItemsClient } from "./WorkItemsClient-Cow6nXx7.mjs";
|
|
9
|
-
import { a as createToolSearchTool, i as buildLlmBackend, n as buildSupportingStores, o as deferredToolRegistry, r as buildSandbox, s as NotifyingLlmBackend, t as buildAgent } from "./buildAgent-B_kArQ_Y.mjs";
|
|
10
|
-
import { t as version } from "./package-Bqg2LSnH.mjs";
|
|
11
11
|
import { r as checkForUpdate } from "./updateChecker-CQW8bxo6.mjs";
|
|
12
12
|
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
|
13
13
|
import { Box, Static, Text, render, useApp, useInput, usePaste, useStdout } from "ink";
|
|
14
|
-
import { execSync, spawn } from "child_process";
|
|
14
|
+
import { execFile, execSync, spawn } from "child_process";
|
|
15
15
|
import { randomBytes } from "crypto";
|
|
16
|
-
import { existsSync, promises, readFileSync, statSync } from "fs";
|
|
16
|
+
import { constants, existsSync, promises, readFileSync, statSync } from "fs";
|
|
17
17
|
import { homedir } from "os";
|
|
18
18
|
import path, { basename, extname, join } from "path";
|
|
19
19
|
import { v4 } from "uuid";
|
|
@@ -25,6 +25,7 @@ import { z as z$1 } from "zod";
|
|
|
25
25
|
import axios, { isAxiosError } from "axios";
|
|
26
26
|
import { marked } from "marked";
|
|
27
27
|
import { highlight } from "cli-highlight";
|
|
28
|
+
import { promisify } from "util";
|
|
28
29
|
import SelectInput from "ink-select-input";
|
|
29
30
|
import jwt from "jsonwebtoken";
|
|
30
31
|
import open from "open";
|
|
@@ -1084,14 +1085,26 @@ function CompletedGroupNotification() {
|
|
|
1084
1085
|
//#endregion
|
|
1085
1086
|
//#region src/components/PermissionPrompt.tsx
|
|
1086
1087
|
/**
|
|
1087
|
-
* Escape
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1088
|
+
* Escape characters that let untrusted text render differently from what
|
|
1089
|
+
* actually runs, into a visible escape form. Shared by every prompt that shows
|
|
1090
|
+
* untrusted text for the user to decide on: tool previews/arguments here,
|
|
1091
|
+
* repo-authored MCP definitions (McpApprovalPrompt), the folder-trust path
|
|
1092
|
+
* (FolderTrustPrompt), and model-authored ReviewGatePrompt / UserQuestionPrompt text. Display only - never escape the
|
|
1093
|
+
* value that is acted on or sent back.
|
|
1094
|
+
* These ranges are covered, deliberately and no wider:
|
|
1095
|
+
* - C0 controls (minus tab/newline), DEL and C1 controls -> `\xHH`: a raw
|
|
1096
|
+
* `\r`, ESC or 8-bit CSI (U+009B) could rewrite the line being approved.
|
|
1097
|
+
* - Unicode bidi overrides U+202A..U+202E and isolates U+2066..U+2069
|
|
1098
|
+
* -> `\uXXXX`: these reorder displayed glyphs without changing the
|
|
1099
|
+
* underlying code points, spoofing the same way.
|
|
1100
|
+
* Tab and newline are left intact (newline is already the line delimiter).
|
|
1101
|
+
* Widen the scope here, so both call sites gain it at once.
|
|
1092
1102
|
*/
|
|
1093
1103
|
function escapeTerminalControlChars(text) {
|
|
1094
|
-
return text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, (ch) =>
|
|
1104
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
|
|
1105
|
+
const code = ch.charCodeAt(0);
|
|
1106
|
+
return code > 255 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`;
|
|
1107
|
+
});
|
|
1095
1108
|
}
|
|
1096
1109
|
/** Render a diff/preview string, color-coding each line by its prefix (see per-branch comments below). */
|
|
1097
1110
|
function renderDiffPreview(preview) {
|
|
@@ -1222,13 +1235,13 @@ function PermissionPrompt({ toolName, toolDescription, args, preview, canBeTrust
|
|
|
1222
1235
|
}, headerText)), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Tool: "), /* @__PURE__ */ React.createElement(Text, {
|
|
1223
1236
|
bold: true,
|
|
1224
1237
|
color: "cyan"
|
|
1225
|
-
}, toolName)), toolDescription && /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Action: "), /* @__PURE__ */ React.createElement(Text, null, toolDescription)), !hideArgs && /* @__PURE__ */ React.createElement(Box, {
|
|
1238
|
+
}, escapeTerminalControlChars(toolName))), toolDescription && /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Action: "), /* @__PURE__ */ React.createElement(Text, null, escapeTerminalControlChars(toolDescription))), !hideArgs && /* @__PURE__ */ React.createElement(Box, {
|
|
1226
1239
|
marginTop: 1,
|
|
1227
1240
|
flexDirection: "column"
|
|
1228
1241
|
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Arguments:"), /* @__PURE__ */ React.createElement(Box, {
|
|
1229
1242
|
paddingLeft: 2,
|
|
1230
1243
|
flexDirection: "column"
|
|
1231
|
-
}, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, argsString))), preview && /* @__PURE__ */ React.createElement(Box, {
|
|
1244
|
+
}, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(argsString)))), preview && /* @__PURE__ */ React.createElement(Box, {
|
|
1232
1245
|
marginTop: 1,
|
|
1233
1246
|
flexDirection: "column"
|
|
1234
1247
|
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Preview:"), /* @__PURE__ */ React.createElement(Box, {
|
|
@@ -1398,7 +1411,7 @@ function UserQuestionPrompt({ payload, onResponse }) {
|
|
|
1398
1411
|
}, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
|
|
1399
1412
|
bold: true,
|
|
1400
1413
|
color: "cyan"
|
|
1401
|
-
}, "? Question", totalQuestions > 1 ? ` ${questionIndex + 1}/${totalQuestions}` : "")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, currentQuestion.question)), /* @__PURE__ */ React.createElement(Box, {
|
|
1414
|
+
}, "? Question", totalQuestions > 1 ? ` ${questionIndex + 1}/${totalQuestions}` : "")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, escapeTerminalControlChars(currentQuestion.question))), /* @__PURE__ */ React.createElement(Box, {
|
|
1402
1415
|
marginTop: 1,
|
|
1403
1416
|
flexDirection: "column"
|
|
1404
1417
|
}, options.map((opt, idx) => {
|
|
@@ -1408,12 +1421,12 @@ function UserQuestionPrompt({ payload, onResponse }) {
|
|
|
1408
1421
|
return /* @__PURE__ */ React.createElement(Box, { key: idx }, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, idx + 1, "."), /* @__PURE__ */ React.createElement(Text, {
|
|
1409
1422
|
color: isHighlighted ? "cyan" : void 0,
|
|
1410
1423
|
bold: isHighlighted
|
|
1411
|
-
}, " ", prefix, " ", opt.label), idx === otherIndex && isHighlighted ? /* @__PURE__ */ React.createElement(Box, { marginLeft: 1 }, /* @__PURE__ */ React.createElement(TextInput, {
|
|
1424
|
+
}, " ", prefix, " ", escapeTerminalControlChars(opt.label)), idx === otherIndex && isHighlighted ? /* @__PURE__ */ React.createElement(Box, { marginLeft: 1 }, /* @__PURE__ */ React.createElement(TextInput, {
|
|
1412
1425
|
value: otherText,
|
|
1413
1426
|
onChange: setOtherText,
|
|
1414
1427
|
onSubmit: submitOther,
|
|
1415
1428
|
placeholder: "Type your answer..."
|
|
1416
|
-
})) : opt.description && idx !== otherIndex && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " - ", opt.description));
|
|
1429
|
+
})) : opt.description && idx !== otherIndex && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " - ", escapeTerminalControlChars(opt.description)));
|
|
1417
1430
|
})), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, isOnOther ? "Type your answer, Enter to submit, or arrow keys to go back" : isMulti ? "Press 1-" + options.length + ", Space to toggle, Enter to confirm" : "Press 1-" + options.length + ", or arrow keys + Enter")));
|
|
1418
1431
|
}
|
|
1419
1432
|
//#endregion
|
|
@@ -1516,10 +1529,10 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
|
|
|
1516
1529
|
}, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, {
|
|
1517
1530
|
bold: true,
|
|
1518
1531
|
color: "magenta"
|
|
1519
|
-
}, "🛑 Review Gate")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, description)), recommendation && /* @__PURE__ */ React.createElement(Box, {
|
|
1532
|
+
}, "🛑 Review Gate")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, escapeTerminalControlChars(description))), recommendation && /* @__PURE__ */ React.createElement(Box, {
|
|
1520
1533
|
marginTop: 1,
|
|
1521
1534
|
flexDirection: "column"
|
|
1522
|
-
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Recommendation:"), /* @__PURE__ */ React.createElement(Box, { paddingLeft: 2 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, recommendation))), options && options.length > 0 && /* @__PURE__ */ React.createElement(Box, {
|
|
1535
|
+
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Recommendation:"), /* @__PURE__ */ React.createElement(Box, { paddingLeft: 2 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(recommendation)))), options && options.length > 0 && /* @__PURE__ */ React.createElement(Box, {
|
|
1523
1536
|
marginTop: 1,
|
|
1524
1537
|
flexDirection: "column"
|
|
1525
1538
|
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Options:"), /* @__PURE__ */ React.createElement(Box, {
|
|
@@ -1528,7 +1541,7 @@ function ReviewGatePrompt({ description, options, recommendation, onResponse })
|
|
|
1528
1541
|
}, options.map((opt, idx) => /* @__PURE__ */ React.createElement(Text, {
|
|
1529
1542
|
key: idx,
|
|
1530
1543
|
dimColor: true
|
|
1531
|
-
}, "• ", opt)))), /* @__PURE__ */ React.createElement(Box, {
|
|
1544
|
+
}, "• ", escapeTerminalControlChars(opt))))), /* @__PURE__ */ React.createElement(Box, {
|
|
1532
1545
|
marginTop: 1,
|
|
1533
1546
|
flexDirection: "column"
|
|
1534
1547
|
}, ITEMS.map((item, index) => {
|
|
@@ -1806,6 +1819,18 @@ function buildConfigItems(availableModels, pluginDescriptors = []) {
|
|
|
1806
1819
|
enableCoordinatorMode: value
|
|
1807
1820
|
}
|
|
1808
1821
|
})
|
|
1822
|
+
}, {
|
|
1823
|
+
key: "postEditDiagnostics",
|
|
1824
|
+
label: "Post-Edit Diagnostics",
|
|
1825
|
+
type: "boolean",
|
|
1826
|
+
getValue: (config) => config.preferences.postEditDiagnostics ?? false,
|
|
1827
|
+
setValue: (config, value) => ({
|
|
1828
|
+
...config,
|
|
1829
|
+
preferences: {
|
|
1830
|
+
...config.preferences,
|
|
1831
|
+
postEditDiagnostics: value
|
|
1832
|
+
}
|
|
1833
|
+
})
|
|
1809
1834
|
}, {
|
|
1810
1835
|
key: "featuresTavern",
|
|
1811
1836
|
label: "Tavern",
|
|
@@ -2687,7 +2712,7 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
|
|
|
2687
2712
|
}, /* @__PURE__ */ React.createElement(Text, {
|
|
2688
2713
|
bold: true,
|
|
2689
2714
|
color: "yellow"
|
|
2690
|
-
}, "This project ships Bike4Mind config that can run code."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, projectRoot), /* @__PURE__ */ React.createElement(Text, null, "Repo-committed config, MCP servers, agents and skills stay inert until you trust this folder. Only trust folders you would run code from.")), /* @__PURE__ */ React.createElement(SelectInput, {
|
|
2715
|
+
}, "This project ships Bike4Mind config that can run code."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(projectRoot)), /* @__PURE__ */ React.createElement(Text, null, "Repo-committed config, MCP servers, agents and skills stay inert until you trust this folder. Only trust folders you would run code from.")), /* @__PURE__ */ React.createElement(SelectInput, {
|
|
2691
2716
|
items: [{
|
|
2692
2717
|
label: "Not now - keep repo config inert this session",
|
|
2693
2718
|
value: "not-now"
|
|
@@ -2700,6 +2725,52 @@ function FolderTrustPrompt({ projectRoot, onSelect }) {
|
|
|
2700
2725
|
}), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select. You can change this later with /trust folder.")));
|
|
2701
2726
|
}
|
|
2702
2727
|
//#endregion
|
|
2728
|
+
//#region src/components/McpApprovalPrompt.tsx
|
|
2729
|
+
const displaySafe = (s) => escapeTerminalControlChars(s).replace(/[\t\n]/g, (c) => c === "\n" ? "\\x0a" : "\\x09");
|
|
2730
|
+
const displayArg = (a) => {
|
|
2731
|
+
const safe = displaySafe(a);
|
|
2732
|
+
return /[\s"']/.test(a) ? `"${safe.replace(/"/g, "\\\"")}"` : safe;
|
|
2733
|
+
};
|
|
2734
|
+
/**
|
|
2735
|
+
* Startup prompt for repo-discovered MCP servers (trusted project) whose exact
|
|
2736
|
+
* definition hasn't been approved. Shows env/header KEY names; values only for
|
|
2737
|
+
* loader-style env keys (PATH, NODE_OPTIONS, LD_*, ...) that change what runs.
|
|
2738
|
+
* "Approve all" persists each definition's fingerprint (a changed definition asks
|
|
2739
|
+
* again); "Skip" persists nothing, so they stay off and re-prompt next launch.
|
|
2740
|
+
*
|
|
2741
|
+
* "Skip" is listed first so Enter-through never starts a server. Sibling of
|
|
2742
|
+
* FolderTrustPrompt.
|
|
2743
|
+
*/
|
|
2744
|
+
function McpApprovalPrompt({ projectRoot, servers, onSelect }) {
|
|
2745
|
+
return /* @__PURE__ */ React.createElement(Box, {
|
|
2746
|
+
flexDirection: "column",
|
|
2747
|
+
marginY: 1
|
|
2748
|
+
}, /* @__PURE__ */ React.createElement(Box, {
|
|
2749
|
+
marginBottom: 1,
|
|
2750
|
+
flexDirection: "column"
|
|
2751
|
+
}, /* @__PURE__ */ React.createElement(Text, {
|
|
2752
|
+
bold: true,
|
|
2753
|
+
color: "yellow"
|
|
2754
|
+
}, "This project defines MCP servers that will run code on your machine."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, displaySafe(projectRoot))), /* @__PURE__ */ React.createElement(Box, {
|
|
2755
|
+
marginBottom: 1,
|
|
2756
|
+
flexDirection: "column"
|
|
2757
|
+
}, servers.map((s) => /* @__PURE__ */ React.createElement(Box, {
|
|
2758
|
+
key: s.name,
|
|
2759
|
+
flexDirection: "column",
|
|
2760
|
+
marginBottom: 1
|
|
2761
|
+
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, displaySafe(s.name)), /* @__PURE__ */ React.createElement(Text, null, s.url !== void 0 ? displaySafe(s.url) : [s.command, ...s.args ?? []].filter((p) => p !== void 0).map(displayArg).join(" ")), s.envKeys.length > 0 && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "env:", " ", s.envKeys.map((k) => k in s.envValues ? displaySafe(`${k}=${s.envValues[k]}`) : displaySafe(k)).join(", ")), s.headerKeys.length > 0 && /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "headers: ", s.headerKeys.map(displaySafe).join(", "))))), /* @__PURE__ */ React.createElement(SelectInput, {
|
|
2762
|
+
items: [{
|
|
2763
|
+
label: "Skip - don't start these this session",
|
|
2764
|
+
value: "skip"
|
|
2765
|
+
}, {
|
|
2766
|
+
label: "Approve all - start these servers (asks again if they change)",
|
|
2767
|
+
value: "approve"
|
|
2768
|
+
}],
|
|
2769
|
+
onSelect: (item) => onSelect(item.value),
|
|
2770
|
+
itemComponent: ({ isSelected, label }) => /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: isSelected ? "cyan" : void 0 }, isSelected ? "❯ " : " ", label))
|
|
2771
|
+
}), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select.")));
|
|
2772
|
+
}
|
|
2773
|
+
//#endregion
|
|
2703
2774
|
//#region src/components/RewindSelector.tsx
|
|
2704
2775
|
function RewindSelector({ messages, onSelect, onCancel }) {
|
|
2705
2776
|
const [step, setStep] = useState("selection");
|
|
@@ -2942,6 +3013,37 @@ function isBrowserOpenableUrl(raw) {
|
|
|
2942
3013
|
if (url.protocol === "https:") return true;
|
|
2943
3014
|
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
2944
3015
|
}
|
|
3016
|
+
/**
|
|
3017
|
+
* The device-flow response is server-controlled, so a compromised server could point the
|
|
3018
|
+
* verification page at a phishing origin. Only auto-open URLs on the configured API origin.
|
|
3019
|
+
* Strict compare: no subdomain aliasing and no localhost/127.0.0.1 equivalence.
|
|
3020
|
+
*/
|
|
3021
|
+
function isOnApiOrigin(raw, apiUrl) {
|
|
3022
|
+
try {
|
|
3023
|
+
return new URL(raw).origin === new URL(apiUrl).origin;
|
|
3024
|
+
} catch {
|
|
3025
|
+
return false;
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
function originOf(raw) {
|
|
3029
|
+
try {
|
|
3030
|
+
const { origin } = new URL(raw);
|
|
3031
|
+
return origin === "null" ? raw : origin;
|
|
3032
|
+
} catch {
|
|
3033
|
+
return raw;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
/** Strips C0/C1 control chars so a server-supplied string can't inject terminal escape sequences. */
|
|
3037
|
+
function stripControlChars(raw) {
|
|
3038
|
+
return raw === void 0 ? raw : raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
3039
|
+
}
|
|
3040
|
+
function hostOf(raw) {
|
|
3041
|
+
try {
|
|
3042
|
+
return new URL(raw).host;
|
|
3043
|
+
} catch {
|
|
3044
|
+
return raw;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2945
3047
|
function extractErrorMessage(err) {
|
|
2946
3048
|
if (axios.isAxiosError(err)) {
|
|
2947
3049
|
if (err.response?.data) {
|
|
@@ -2963,13 +3065,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
2963
3065
|
const [deviceFlow, setDeviceFlow] = useState(null);
|
|
2964
3066
|
const [statusMessage, setStatusMessage] = useState("Initiating device authorization...");
|
|
2965
3067
|
const [error, setError] = useState(null);
|
|
3068
|
+
const [account, setAccount] = useState(null);
|
|
2966
3069
|
useEffect(() => {
|
|
2967
3070
|
const runLoginFlow = async () => {
|
|
2968
3071
|
const oauth = new OAuthClient(apiUrl);
|
|
2969
3072
|
try {
|
|
2970
3073
|
setStatus("initiating");
|
|
2971
3074
|
const deviceFlowResponse = await oauth.initiateDeviceFlow();
|
|
2972
|
-
setDeviceFlow(
|
|
3075
|
+
setDeviceFlow({
|
|
3076
|
+
...deviceFlowResponse,
|
|
3077
|
+
verification_uri: stripControlChars(deviceFlowResponse.verification_uri),
|
|
3078
|
+
verification_uri_complete: stripControlChars(deviceFlowResponse.verification_uri_complete),
|
|
3079
|
+
user_code: stripControlChars(deviceFlowResponse.user_code)
|
|
3080
|
+
});
|
|
2973
3081
|
setStatus("waiting");
|
|
2974
3082
|
setStatusMessage("Waiting for authorization...");
|
|
2975
3083
|
const tokens = await oauth.waitForAuthorization(deviceFlowResponse.device_code, deviceFlowResponse.interval, (message) => setStatusMessage(message));
|
|
@@ -2981,12 +3089,18 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
2981
3089
|
expiresAt,
|
|
2982
3090
|
userId
|
|
2983
3091
|
});
|
|
3092
|
+
setAccount({ userId });
|
|
2984
3093
|
setStatus("success");
|
|
2985
3094
|
setStatusMessage("Successfully authenticated!");
|
|
2986
3095
|
setTimeout(() => onSuccess(), 1500);
|
|
3096
|
+
new ApiClient(apiUrl, configStore).get("/api/identify").then((res) => setAccount({
|
|
3097
|
+
userId,
|
|
3098
|
+
email: stripControlChars(res?.user?.email),
|
|
3099
|
+
username: stripControlChars(res?.user?.username)
|
|
3100
|
+
})).catch(() => {});
|
|
2987
3101
|
} catch (err) {
|
|
2988
3102
|
setStatus("error");
|
|
2989
|
-
const errorMessage = extractErrorMessage(err);
|
|
3103
|
+
const errorMessage = stripControlChars(extractErrorMessage(err));
|
|
2990
3104
|
setError(errorMessage);
|
|
2991
3105
|
onError(new Error(errorMessage));
|
|
2992
3106
|
}
|
|
@@ -2998,12 +3112,19 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
2998
3112
|
onSuccess,
|
|
2999
3113
|
onError
|
|
3000
3114
|
]);
|
|
3115
|
+
const uris = deviceFlow ? [deviceFlow.verification_uri, deviceFlow.verification_uri_complete] : [];
|
|
3116
|
+
const isTrustedUri = (u) => isBrowserOpenableUrl(u) && isOnApiOrigin(u, apiUrl);
|
|
3117
|
+
const untrustedUri = uris.find((u) => !isTrustedUri(u));
|
|
3118
|
+
const trusted = uris.length > 0 && uris.every(isTrustedUri);
|
|
3001
3119
|
useEffect(() => {
|
|
3002
|
-
if (deviceFlow && status === "waiting"
|
|
3120
|
+
if (deviceFlow && status === "waiting" && trusted) open(deviceFlow.verification_uri_complete).catch((err) => {
|
|
3003
3121
|
console.error("Failed to auto-open browser:", err);
|
|
3004
3122
|
});
|
|
3005
|
-
|
|
3006
|
-
|
|
3123
|
+
}, [
|
|
3124
|
+
deviceFlow,
|
|
3125
|
+
status,
|
|
3126
|
+
trusted
|
|
3127
|
+
]);
|
|
3007
3128
|
if (status === "initiating") return /* @__PURE__ */ React.createElement(Box, {
|
|
3008
3129
|
flexDirection: "column",
|
|
3009
3130
|
padding: 1
|
|
@@ -3021,7 +3142,7 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
3021
3142
|
}, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
|
|
3022
3143
|
color: "green",
|
|
3023
3144
|
bold: true
|
|
3024
|
-
}, "✔ Successfully authenticated!")), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text,
|
|
3145
|
+
}, "✔ Successfully authenticated!")), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, null, "Logged in as ", account?.email || account?.username || `user ${account?.userId}`, " on ", hostOf(apiUrl))));
|
|
3025
3146
|
return /* @__PURE__ */ React.createElement(Box, {
|
|
3026
3147
|
flexDirection: "column",
|
|
3027
3148
|
padding: 1,
|
|
@@ -3030,7 +3151,10 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
3030
3151
|
}, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
|
|
3031
3152
|
color: "cyan",
|
|
3032
3153
|
bold: true
|
|
3033
|
-
}, "🔐 Device Authorization")), /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Opening browser automatically... If it doesn't open, please visit:")), /* @__PURE__ */ React.createElement(Box, {
|
|
3154
|
+
}, "🔐 Device Authorization")), trusted ? /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Opening browser automatically... If it doesn't open, please visit:")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, {
|
|
3155
|
+
color: "red",
|
|
3156
|
+
bold: true
|
|
3157
|
+
}, !untrustedUri ? "Not opening browser: the server did not return a verification URL." : isOnApiOrigin(untrustedUri, apiUrl) ? `Not opening browser: verification URL ${originOf(untrustedUri)} is not https (plain http is only auto-opened on localhost). Only continue if you trust it.` : `Not opening browser: verification URL origin ${originOf(untrustedUri)} does not match the configured server ${originOf(apiUrl)}. Only continue if you trust it.`)), /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Please visit:"))), /* @__PURE__ */ React.createElement(Box, {
|
|
3034
3158
|
marginBottom: 1,
|
|
3035
3159
|
paddingLeft: 2
|
|
3036
3160
|
}, /* @__PURE__ */ React.createElement(Text, {
|
|
@@ -3566,7 +3690,7 @@ function toStringArray(value) {
|
|
|
3566
3690
|
function extractJsonObject(text) {
|
|
3567
3691
|
const trimmed = text.trim();
|
|
3568
3692
|
if (!trimmed) return null;
|
|
3569
|
-
const fenced = trimmed.match(/```(?:json)
|
|
3693
|
+
const fenced = trimmed.match(/```(?:json)?([\s\S]*?)```/i);
|
|
3570
3694
|
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
3571
3695
|
const start = candidate.indexOf("{");
|
|
3572
3696
|
if (start === -1) return null;
|
|
@@ -4507,6 +4631,314 @@ Unlike agent_delegate (which uses pre-defined agents), this tool lets you compos
|
|
|
4507
4631
|
};
|
|
4508
4632
|
}
|
|
4509
4633
|
//#endregion
|
|
4634
|
+
//#region src/diagnostics/checkers.ts
|
|
4635
|
+
const CHECK_TIMEOUT_MS = 12e4;
|
|
4636
|
+
const MAX_OUTPUT_BYTES = 16777216;
|
|
4637
|
+
const TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4638
|
+
".ts",
|
|
4639
|
+
".tsx",
|
|
4640
|
+
".mts",
|
|
4641
|
+
".cts"
|
|
4642
|
+
]);
|
|
4643
|
+
const ESLINT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4644
|
+
...TYPESCRIPT_EXTENSIONS,
|
|
4645
|
+
".js",
|
|
4646
|
+
".jsx",
|
|
4647
|
+
".mjs",
|
|
4648
|
+
".cjs"
|
|
4649
|
+
]);
|
|
4650
|
+
const ESLINT_ERROR_SEVERITY = 2;
|
|
4651
|
+
const TSC_ENTRY = path.join("node_modules", "typescript", "bin", "tsc");
|
|
4652
|
+
const ESLINT_ENTRY = path.join("node_modules", "eslint", "bin", "eslint.js");
|
|
4653
|
+
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
|
4654
|
+
/**
|
|
4655
|
+
* Runs a binary without a shell (paths are passed as argv, never interpolated).
|
|
4656
|
+
* A non-zero exit is a normal outcome - tsc/eslint exit non-zero when they find errors.
|
|
4657
|
+
*/
|
|
4658
|
+
function runProcess(file, args, cwd) {
|
|
4659
|
+
return new Promise((resolve) => {
|
|
4660
|
+
execFile(file, args, {
|
|
4661
|
+
cwd,
|
|
4662
|
+
timeout: CHECK_TIMEOUT_MS,
|
|
4663
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
4664
|
+
killSignal: "SIGKILL",
|
|
4665
|
+
windowsHide: true
|
|
4666
|
+
}, (error, stdout, stderr) => {
|
|
4667
|
+
if (!error) {
|
|
4668
|
+
resolve({
|
|
4669
|
+
kind: "exited",
|
|
4670
|
+
stdout
|
|
4671
|
+
});
|
|
4672
|
+
return;
|
|
4673
|
+
}
|
|
4674
|
+
if (error.killed) {
|
|
4675
|
+
resolve({
|
|
4676
|
+
kind: "failed",
|
|
4677
|
+
reason: `timed out after ${CHECK_TIMEOUT_MS}ms`
|
|
4678
|
+
});
|
|
4679
|
+
return;
|
|
4680
|
+
}
|
|
4681
|
+
if (typeof error.code !== "number") {
|
|
4682
|
+
resolve({
|
|
4683
|
+
kind: "failed",
|
|
4684
|
+
reason: error.message
|
|
4685
|
+
});
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
if (!stdout.trim()) {
|
|
4689
|
+
resolve({
|
|
4690
|
+
kind: "failed",
|
|
4691
|
+
reason: `exit ${error.code}: ${stderr.trim().slice(0, 300)}`
|
|
4692
|
+
});
|
|
4693
|
+
return;
|
|
4694
|
+
}
|
|
4695
|
+
resolve({
|
|
4696
|
+
kind: "exited",
|
|
4697
|
+
stdout
|
|
4698
|
+
});
|
|
4699
|
+
});
|
|
4700
|
+
});
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* Runs a package's JS entry under the current Node. `node_modules/.bin/*` are sh
|
|
4704
|
+
* shims (`.cmd` on Windows) that `execFile` cannot launch without a shell.
|
|
4705
|
+
*/
|
|
4706
|
+
function runNodeScript(script, args, cwd) {
|
|
4707
|
+
return runProcess(process.execPath, [script, ...args], cwd);
|
|
4708
|
+
}
|
|
4709
|
+
/** Nearest `relativePath` at or above `startDir`, or null. */
|
|
4710
|
+
function findUp(startDir, relativePath) {
|
|
4711
|
+
let dir = path.resolve(startDir);
|
|
4712
|
+
for (;;) {
|
|
4713
|
+
const candidate = path.join(dir, relativePath);
|
|
4714
|
+
if (existsSync(candidate)) return candidate;
|
|
4715
|
+
const parent = path.dirname(dir);
|
|
4716
|
+
if (parent === dir) return null;
|
|
4717
|
+
dir = parent;
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
/** Groups files by a key derived from each; files whose key is null are dropped. */
|
|
4721
|
+
function groupBy(filePaths, keyOf) {
|
|
4722
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4723
|
+
for (const filePath of filePaths) {
|
|
4724
|
+
const key = keyOf(filePath);
|
|
4725
|
+
if (!key) continue;
|
|
4726
|
+
groups.set(key, [...groups.get(key) ?? [], filePath]);
|
|
4727
|
+
}
|
|
4728
|
+
return groups;
|
|
4729
|
+
}
|
|
4730
|
+
const hasExtension = (extensions) => (filePath) => extensions.has(path.extname(filePath));
|
|
4731
|
+
function parseTscOutput(stdout, cwd) {
|
|
4732
|
+
return stdout.split("\n").flatMap((line) => {
|
|
4733
|
+
const match = TSC_ERROR_LINE.exec(line.trimEnd());
|
|
4734
|
+
if (!match) return [];
|
|
4735
|
+
const [, file, lineNumber, column, code, message] = match;
|
|
4736
|
+
return [{
|
|
4737
|
+
filePath: path.resolve(cwd, file),
|
|
4738
|
+
line: Number(lineNumber),
|
|
4739
|
+
column: Number(column),
|
|
4740
|
+
code,
|
|
4741
|
+
message
|
|
4742
|
+
}];
|
|
4743
|
+
});
|
|
4744
|
+
}
|
|
4745
|
+
function isEslintReport(value) {
|
|
4746
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && typeof entry.filePath === "string" && Array.isArray(entry.messages));
|
|
4747
|
+
}
|
|
4748
|
+
function parseEslintOutput(stdout) {
|
|
4749
|
+
let report;
|
|
4750
|
+
try {
|
|
4751
|
+
report = JSON.parse(stdout);
|
|
4752
|
+
} catch {
|
|
4753
|
+
logger.debug(`[diagnostics] eslint produced non-JSON output: ${stdout.slice(0, 200)}`);
|
|
4754
|
+
return [];
|
|
4755
|
+
}
|
|
4756
|
+
if (!isEslintReport(report)) return [];
|
|
4757
|
+
return report.flatMap((fileResult) => fileResult.messages.filter((message) => message.severity === ESLINT_ERROR_SEVERITY).map((message) => ({
|
|
4758
|
+
filePath: path.resolve(fileResult.filePath),
|
|
4759
|
+
line: message.line ?? 1,
|
|
4760
|
+
column: message.column ?? 1,
|
|
4761
|
+
code: message.ruleId ?? "eslint",
|
|
4762
|
+
message: message.message
|
|
4763
|
+
})));
|
|
4764
|
+
}
|
|
4765
|
+
/**
|
|
4766
|
+
* Type-checks each changed file's nearest tsconfig project with the project's
|
|
4767
|
+
* own `tsc` (never a global one). Project-wide so path aliases and compiler
|
|
4768
|
+
* options are honored; output is filtered to the changed files by the caller.
|
|
4769
|
+
*/
|
|
4770
|
+
const checkTypeScript = async (filePaths) => {
|
|
4771
|
+
const projects = groupBy(filePaths.filter(hasExtension(TYPESCRIPT_EXTENSIONS)), (filePath) => findUp(path.dirname(filePath), "tsconfig.json"));
|
|
4772
|
+
const diagnostics = [];
|
|
4773
|
+
for (const tsconfigPath of projects.keys()) {
|
|
4774
|
+
const projectDir = path.dirname(tsconfigPath);
|
|
4775
|
+
const tsc = findUp(projectDir, TSC_ENTRY);
|
|
4776
|
+
if (!tsc) {
|
|
4777
|
+
logger.debug(`[diagnostics] no local tsc found for ${tsconfigPath}; skipping type check`);
|
|
4778
|
+
continue;
|
|
4779
|
+
}
|
|
4780
|
+
const outcome = await runNodeScript(tsc, [
|
|
4781
|
+
"--noEmit",
|
|
4782
|
+
"--pretty",
|
|
4783
|
+
"false",
|
|
4784
|
+
"-p",
|
|
4785
|
+
tsconfigPath
|
|
4786
|
+
], projectDir);
|
|
4787
|
+
if (outcome.kind === "failed") {
|
|
4788
|
+
logger.debug(`[diagnostics] tsc skipped for ${tsconfigPath}: ${outcome.reason}`);
|
|
4789
|
+
continue;
|
|
4790
|
+
}
|
|
4791
|
+
diagnostics.push(...parseTscOutput(outcome.stdout, projectDir));
|
|
4792
|
+
}
|
|
4793
|
+
return diagnostics;
|
|
4794
|
+
};
|
|
4795
|
+
/** Lints changed files with the eslint installed nearest to each file, run from that package root. */
|
|
4796
|
+
const checkEslint = async (filePaths) => {
|
|
4797
|
+
const groups = groupBy(filePaths.filter(hasExtension(ESLINT_EXTENSIONS)), (filePath) => findUp(path.dirname(filePath), ESLINT_ENTRY));
|
|
4798
|
+
const diagnostics = [];
|
|
4799
|
+
for (const [eslint, files] of groups) {
|
|
4800
|
+
const packageRoot = path.resolve(path.dirname(eslint), "..", "..", "..");
|
|
4801
|
+
const outcome = await runNodeScript(eslint, [
|
|
4802
|
+
"--format",
|
|
4803
|
+
"json",
|
|
4804
|
+
...files
|
|
4805
|
+
], packageRoot);
|
|
4806
|
+
if (outcome.kind === "failed") {
|
|
4807
|
+
logger.debug(`[diagnostics] eslint skipped under ${packageRoot}: ${outcome.reason}`);
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
diagnostics.push(...parseEslintOutput(outcome.stdout));
|
|
4811
|
+
}
|
|
4812
|
+
return diagnostics;
|
|
4813
|
+
};
|
|
4814
|
+
/** The default checker: tsc and eslint side by side. */
|
|
4815
|
+
const checkTypeScriptAndEslint = async (filePaths) => {
|
|
4816
|
+
const [typeErrors, lintErrors] = await Promise.all([checkTypeScript(filePaths), checkEslint(filePaths)]);
|
|
4817
|
+
return [...typeErrors, ...lintErrors];
|
|
4818
|
+
};
|
|
4819
|
+
//#endregion
|
|
4820
|
+
//#region src/diagnostics/PostEditDiagnostics.ts
|
|
4821
|
+
/**
|
|
4822
|
+
* How long a drain waits for in-flight checks (ms). Between turns the model
|
|
4823
|
+
* will take another turn anyway, so a busy worker just defers to the next
|
|
4824
|
+
* one; at finalization there is no later turn, so it waits longer.
|
|
4825
|
+
*/
|
|
4826
|
+
const DRAIN_BUDGET_MS = {
|
|
4827
|
+
turn: 250,
|
|
4828
|
+
final: 3e4
|
|
4829
|
+
};
|
|
4830
|
+
const REPORT_HEADER = "[Post-edit diagnostics] Errors in files you changed. Fix them before you finish:";
|
|
4831
|
+
const MAX_REPORTED_ERRORS = 20;
|
|
4832
|
+
const MAX_MESSAGE_LENGTH = 300;
|
|
4833
|
+
/**
|
|
4834
|
+
* Background type/lint checking of files the agent edits, drained into the
|
|
4835
|
+
* agent loop via AgentRunOptions.drainFeedback. Edits never wait on a check.
|
|
4836
|
+
*
|
|
4837
|
+
* Queue semantics are re-edit-wins: a file edited again before its check runs
|
|
4838
|
+
* is checked once in its final state, and a file edited again after a check
|
|
4839
|
+
* started (in flight or already completed but not yet reported) has that stale
|
|
4840
|
+
* result discarded in favor of the queued re-check. Each completed result is
|
|
4841
|
+
* reported at most once.
|
|
4842
|
+
*
|
|
4843
|
+
* Only collects between beginTurn() and endTurn(); a no-op otherwise, which is
|
|
4844
|
+
* how the per-turn config gate in turnController switches it off.
|
|
4845
|
+
*/
|
|
4846
|
+
var PostEditDiagnostics = class {
|
|
4847
|
+
constructor({ workspaceRoot, check = checkTypeScriptAndEslint }) {
|
|
4848
|
+
this.pending = /* @__PURE__ */ new Set();
|
|
4849
|
+
this.results = /* @__PURE__ */ new Map();
|
|
4850
|
+
this.worker = null;
|
|
4851
|
+
this.active = false;
|
|
4852
|
+
this.generation = 0;
|
|
4853
|
+
this.workspaceRoot = workspaceRoot;
|
|
4854
|
+
this.check = check;
|
|
4855
|
+
}
|
|
4856
|
+
beginTurn() {
|
|
4857
|
+
this.reset();
|
|
4858
|
+
this.active = true;
|
|
4859
|
+
}
|
|
4860
|
+
endTurn() {
|
|
4861
|
+
this.reset();
|
|
4862
|
+
this.active = false;
|
|
4863
|
+
}
|
|
4864
|
+
enqueue(filePath) {
|
|
4865
|
+
if (!this.active) return;
|
|
4866
|
+
const resolved = path.resolve(this.workspaceRoot, filePath);
|
|
4867
|
+
this.results.delete(resolved);
|
|
4868
|
+
this.pending.add(resolved);
|
|
4869
|
+
this.worker ??= this.processQueue();
|
|
4870
|
+
}
|
|
4871
|
+
/**
|
|
4872
|
+
* Waits up to `budgetMs` (or until `signal` aborts) for queued checks, then
|
|
4873
|
+
* returns the errors found since the last drain, or null when there are none.
|
|
4874
|
+
*/
|
|
4875
|
+
async drain(budgetMs, signal) {
|
|
4876
|
+
if (this.worker) await settleWithin(this.worker, budgetMs, signal);
|
|
4877
|
+
return this.takeReport();
|
|
4878
|
+
}
|
|
4879
|
+
reset() {
|
|
4880
|
+
this.generation++;
|
|
4881
|
+
this.pending.clear();
|
|
4882
|
+
this.results.clear();
|
|
4883
|
+
}
|
|
4884
|
+
async processQueue() {
|
|
4885
|
+
try {
|
|
4886
|
+
while (this.pending.size > 0) {
|
|
4887
|
+
const batch = [...this.pending];
|
|
4888
|
+
this.pending.clear();
|
|
4889
|
+
const generation = this.generation;
|
|
4890
|
+
const diagnostics = await this.runCheck(batch);
|
|
4891
|
+
if (!diagnostics || generation !== this.generation) continue;
|
|
4892
|
+
for (const filePath of batch) {
|
|
4893
|
+
if (this.pending.has(filePath)) continue;
|
|
4894
|
+
this.results.set(filePath, diagnostics.filter((diagnostic) => diagnostic.filePath === filePath));
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
} finally {
|
|
4898
|
+
this.worker = null;
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
async runCheck(batch) {
|
|
4902
|
+
try {
|
|
4903
|
+
return await this.check(batch);
|
|
4904
|
+
} catch (error) {
|
|
4905
|
+
logger.debug(`[diagnostics] check failed; skipping ${batch.length} file(s): ${String(error)}`);
|
|
4906
|
+
return null;
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
takeReport() {
|
|
4910
|
+
const errors = [...this.results.values()].flat();
|
|
4911
|
+
this.results.clear();
|
|
4912
|
+
if (errors.length === 0) return null;
|
|
4913
|
+
const lines = errors.slice(0, MAX_REPORTED_ERRORS).map((error) => this.formatError(error));
|
|
4914
|
+
const omitted = errors.length - lines.length;
|
|
4915
|
+
if (omitted > 0) lines.push(`(+${omitted} more)`);
|
|
4916
|
+
return [REPORT_HEADER, ...lines].join("\n");
|
|
4917
|
+
}
|
|
4918
|
+
formatError({ filePath, line, column, code, message }) {
|
|
4919
|
+
const relativePath = path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
|
|
4920
|
+
const firstLine = message.split("\n")[0];
|
|
4921
|
+
return `${relativePath}:${line}:${column} - ${code}: ${firstLine.length > MAX_MESSAGE_LENGTH ? `${firstLine.slice(0, MAX_MESSAGE_LENGTH)}...` : firstLine}`;
|
|
4922
|
+
}
|
|
4923
|
+
};
|
|
4924
|
+
/** Resolves when `work` settles, `budgetMs` elapses, or `signal` aborts - whichever is first. */
|
|
4925
|
+
async function settleWithin(work, budgetMs, signal) {
|
|
4926
|
+
if (signal?.aborted) return;
|
|
4927
|
+
let timer;
|
|
4928
|
+
let onAbort;
|
|
4929
|
+
const cutoff = new Promise((resolve) => {
|
|
4930
|
+
timer = setTimeout(resolve, budgetMs);
|
|
4931
|
+
onAbort = resolve;
|
|
4932
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
4933
|
+
});
|
|
4934
|
+
try {
|
|
4935
|
+
await Promise.race([work, cutoff]);
|
|
4936
|
+
} finally {
|
|
4937
|
+
clearTimeout(timer);
|
|
4938
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
//#endregion
|
|
4510
4942
|
//#region src/features/FeatureModuleRegistry.ts
|
|
4511
4943
|
/**
|
|
4512
4944
|
* Manages the lifecycle of opt-in CLI feature modules.
|
|
@@ -6758,6 +7190,168 @@ function createBuiltinModules(config, apiClient) {
|
|
|
6758
7190
|
return modules;
|
|
6759
7191
|
}
|
|
6760
7192
|
//#endregion
|
|
7193
|
+
//#region src/features/bridgePresence/peerOwner.ts
|
|
7194
|
+
const execFileAsync = promisify(execFile);
|
|
7195
|
+
const LSOF_TIMEOUT_MS = 2e3;
|
|
7196
|
+
const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof"];
|
|
7197
|
+
/**
|
|
7198
|
+
* Resolve the OS owner of the process holding the LISTEN socket on
|
|
7199
|
+
* `127.0.0.1:<port>`. This is the only pre-transmission trust signal the CLI
|
|
7200
|
+
* has over loopback TCP: the peer is whoever holds the port, so we confirm it
|
|
7201
|
+
* is the same UID as this process BEFORE handing it the bridge secret.
|
|
7202
|
+
*
|
|
7203
|
+
* Pure platform probe: the per-platform helpers classify every EXPECTED failure
|
|
7204
|
+
* into `no-listener`/`unknown` themselves. An UNEXPECTED throw (a bug) is left to
|
|
7205
|
+
* propagate so the CLI-layer caller can log it and fail closed - this module
|
|
7206
|
+
* deliberately does not depend on the CLI logger.
|
|
7207
|
+
*/
|
|
7208
|
+
async function resolveLoopbackListenerOwner(port) {
|
|
7209
|
+
if (process.platform === "linux") return resolveViaProcNet(port);
|
|
7210
|
+
if (process.platform === "darwin") return resolveViaLsof(port);
|
|
7211
|
+
return { kind: "unknown" };
|
|
7212
|
+
}
|
|
7213
|
+
/**
|
|
7214
|
+
* Whether this platform + process can resolve loopback listener ownership at all:
|
|
7215
|
+
* there must be a `getuid` to compare against (Windows has none) AND an owner probe
|
|
7216
|
+
* for the platform. When false, the caller latches presence off rather than spinning
|
|
7217
|
+
* a retry loop forever on a check that can never pass (a `getuid`-bearing platform
|
|
7218
|
+
* with no probe - BSD/SunOS - would otherwise retry indefinitely).
|
|
7219
|
+
* MUST stay in sync with `resolveLoopbackListenerOwner`'s platform dispatch above:
|
|
7220
|
+
* a platform gains a probe there and a `true` here in the same change.
|
|
7221
|
+
*/
|
|
7222
|
+
function canResolveLoopbackOwner() {
|
|
7223
|
+
if (typeof process.getuid !== "function") return false;
|
|
7224
|
+
return process.platform === "linux" || process.platform === "darwin";
|
|
7225
|
+
}
|
|
7226
|
+
async function resolveViaProcNet(port) {
|
|
7227
|
+
const uids = /* @__PURE__ */ new Set();
|
|
7228
|
+
let readAny = false;
|
|
7229
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
7230
|
+
let text;
|
|
7231
|
+
try {
|
|
7232
|
+
text = await promises.readFile(file, "utf8");
|
|
7233
|
+
} catch {
|
|
7234
|
+
continue;
|
|
7235
|
+
}
|
|
7236
|
+
readAny = true;
|
|
7237
|
+
for (const uid of parseProcNetForUid(text, port)) uids.add(uid);
|
|
7238
|
+
}
|
|
7239
|
+
if (!readAny) return { kind: "unknown" };
|
|
7240
|
+
if (uids.size === 0) return { kind: "no-listener" };
|
|
7241
|
+
return uids.size === 1 ? {
|
|
7242
|
+
kind: "owner",
|
|
7243
|
+
uid: [...uids][0]
|
|
7244
|
+
} : { kind: "unknown" };
|
|
7245
|
+
}
|
|
7246
|
+
/**
|
|
7247
|
+
* `local_address` hex (upper-case) of a socket that receives connections to
|
|
7248
|
+
* `127.0.0.1`: loopback itself, the v4/v6 wildcard binds (a same-UID process
|
|
7249
|
+
* may legitimately hold `0.0.0.0`/`::`), and the v4-mapped-in-v6 forms. A row
|
|
7250
|
+
* on any other IP is a different socket that happens to share the port number
|
|
7251
|
+
* and must NOT be attributed to the loopback peer.
|
|
7252
|
+
*
|
|
7253
|
+
* `/proc/net/tcp6` prints each 32-bit word of the address little-endian (no
|
|
7254
|
+
* `ntohl`), which is why `127.0.0.1` reads `0100007F` and the mapped third word
|
|
7255
|
+
* `00 00 ff ff` reads `FFFF0000`.
|
|
7256
|
+
*/
|
|
7257
|
+
const LOOPBACK_OR_WILDCARD_HEX = /* @__PURE__ */ new Set([
|
|
7258
|
+
"00000000",
|
|
7259
|
+
"0100007F",
|
|
7260
|
+
"00000000000000000000000000000000",
|
|
7261
|
+
"00000000000000000000000001000000",
|
|
7262
|
+
"0000000000000000FFFF000000000000",
|
|
7263
|
+
"0000000000000000FFFF00000100007F"
|
|
7264
|
+
]);
|
|
7265
|
+
/**
|
|
7266
|
+
* Parse a `/proc/net/tcp`(6) dump for the UIDs of LISTEN sockets on the
|
|
7267
|
+
* loopback `port`. Row columns are whitespace-separated: sl(0)
|
|
7268
|
+
* local_address(1) rem_address(2) st(3) tx:rx(4) tr:when(5) retrnsmt(6)
|
|
7269
|
+
* uid(7). `local_address` is `HEXIP:HEXPORT`; `st` `0A` is TCP_LISTEN. The IP
|
|
7270
|
+
* must be loopback or wildcard, else a same-port listener on another interface
|
|
7271
|
+
* would be mis-attributed to the loopback peer. Returns every match so the
|
|
7272
|
+
* caller can reject an ambiguous set (two distinct owners) rather than
|
|
7273
|
+
* silently taking the first.
|
|
7274
|
+
*/
|
|
7275
|
+
function parseProcNetForUid(text, port) {
|
|
7276
|
+
const uids = [];
|
|
7277
|
+
for (const line of text.split("\n")) {
|
|
7278
|
+
const cols = line.trim().split(/\s+/);
|
|
7279
|
+
if (cols.length < 8) continue;
|
|
7280
|
+
const local = cols[1];
|
|
7281
|
+
const colon = local.lastIndexOf(":");
|
|
7282
|
+
if (colon < 0) continue;
|
|
7283
|
+
if (parseInt(local.slice(colon + 1), 16) !== port) continue;
|
|
7284
|
+
if (cols[3] !== "0A") continue;
|
|
7285
|
+
if (!LOOPBACK_OR_WILDCARD_HEX.has(local.slice(0, colon).toUpperCase())) continue;
|
|
7286
|
+
const uid = Number(cols[7]);
|
|
7287
|
+
if (Number.isInteger(uid)) uids.push(uid);
|
|
7288
|
+
}
|
|
7289
|
+
return uids;
|
|
7290
|
+
}
|
|
7291
|
+
async function resolveViaLsof(port) {
|
|
7292
|
+
const lsof = await resolveLsofPath();
|
|
7293
|
+
if (!lsof) return { kind: "unknown" };
|
|
7294
|
+
let stdout;
|
|
7295
|
+
try {
|
|
7296
|
+
({stdout} = await execFileAsync(lsof, [
|
|
7297
|
+
"-nP",
|
|
7298
|
+
`-iTCP:${port}`,
|
|
7299
|
+
"-sTCP:LISTEN",
|
|
7300
|
+
"-Fpun"
|
|
7301
|
+
], { timeout: LSOF_TIMEOUT_MS }));
|
|
7302
|
+
} catch (err) {
|
|
7303
|
+
const e = err;
|
|
7304
|
+
if (!e.killed && e.code === 1 && !e.stdout) return { kind: "no-listener" };
|
|
7305
|
+
if (e.killed || typeof e.stdout !== "string") return { kind: "unknown" };
|
|
7306
|
+
stdout = e.stdout;
|
|
7307
|
+
}
|
|
7308
|
+
const uids = new Set(parseLsofForUid(stdout));
|
|
7309
|
+
if (uids.size === 0) return { kind: "no-listener" };
|
|
7310
|
+
return uids.size === 1 ? {
|
|
7311
|
+
kind: "owner",
|
|
7312
|
+
uid: [...uids][0]
|
|
7313
|
+
} : { kind: "unknown" };
|
|
7314
|
+
}
|
|
7315
|
+
let lsofPathCache;
|
|
7316
|
+
async function resolveLsofPath() {
|
|
7317
|
+
if (lsofPathCache !== void 0) return lsofPathCache;
|
|
7318
|
+
for (const candidate of LSOF_CANDIDATES) try {
|
|
7319
|
+
await promises.access(candidate, constants.X_OK);
|
|
7320
|
+
lsofPathCache = candidate;
|
|
7321
|
+
return candidate;
|
|
7322
|
+
} catch {}
|
|
7323
|
+
lsofPathCache = null;
|
|
7324
|
+
return null;
|
|
7325
|
+
}
|
|
7326
|
+
/** Does an `lsof -nP` numeric socket name (`n` field, host:port) receive
|
|
7327
|
+
* connections to `127.0.0.1`? Loopback, wildcard, or v4-mapped loopback -
|
|
7328
|
+
* exact, to stay symmetric with the linux matcher (the CLI only dials
|
|
7329
|
+
* `127.0.0.1`). */
|
|
7330
|
+
function isLoopbackLsofName(name) {
|
|
7331
|
+
return name.startsWith("127.0.0.1:") || name.startsWith("*:") || name.startsWith("[::1]:") || name.startsWith("[::]:") || name.startsWith("[::ffff:127.0.0.1]:");
|
|
7332
|
+
}
|
|
7333
|
+
/**
|
|
7334
|
+
* Parse `lsof -Fpun` output for the UIDs of loopback listeners. `-F` prints
|
|
7335
|
+
* one field per line prefixed by its type char; `u<uid>` is a process-level
|
|
7336
|
+
* field, `n<host:port>` a file-level one. `uid` resets on each `p<pid>` block
|
|
7337
|
+
* so a block without a `u` field can't inherit the prior block's owner. We
|
|
7338
|
+
* collect the UID for every loopback/wildcard name so the caller can reject an
|
|
7339
|
+
* ambiguous set rather than taking the first.
|
|
7340
|
+
*/
|
|
7341
|
+
function parseLsofForUid(text) {
|
|
7342
|
+
const uids = [];
|
|
7343
|
+
let uid = null;
|
|
7344
|
+
for (const line of text.split("\n")) {
|
|
7345
|
+
const tag = line[0];
|
|
7346
|
+
if (tag === "p") uid = null;
|
|
7347
|
+
else if (tag === "u") {
|
|
7348
|
+
const parsed = Number(line.slice(1));
|
|
7349
|
+
uid = Number.isInteger(parsed) ? parsed : null;
|
|
7350
|
+
} else if (tag === "n" && uid !== null && isLoopbackLsofName(line.slice(1))) uids.push(uid);
|
|
7351
|
+
}
|
|
7352
|
+
return uids;
|
|
7353
|
+
}
|
|
7354
|
+
//#endregion
|
|
6761
7355
|
//#region src/features/bridgePresence/BridgePresence.ts
|
|
6762
7356
|
const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
|
|
6763
7357
|
const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
|
|
@@ -6783,11 +7377,15 @@ var BridgePresence = class {
|
|
|
6783
7377
|
this.callbacks = {};
|
|
6784
7378
|
this.started = false;
|
|
6785
7379
|
this.stopped = false;
|
|
7380
|
+
this.generation = 0;
|
|
7381
|
+
this.trusted = false;
|
|
7382
|
+
this.wsConnectingGen = null;
|
|
6786
7383
|
this.reconnectTimer = null;
|
|
6787
7384
|
this.reconnectAttempts = 0;
|
|
6788
7385
|
this.announceRetryTimer = null;
|
|
6789
7386
|
this.announceAttempts = 0;
|
|
6790
7387
|
this.startOpts = null;
|
|
7388
|
+
this.peerWarned = false;
|
|
6791
7389
|
this.pendingWorkspaceName = null;
|
|
6792
7390
|
this.pendingCapabilities = null;
|
|
6793
7391
|
this.pendingSource = null;
|
|
@@ -6796,6 +7394,19 @@ var BridgePresence = class {
|
|
|
6796
7394
|
setCallbacks(cbs) {
|
|
6797
7395
|
this.callbacks = cbs;
|
|
6798
7396
|
}
|
|
7397
|
+
/** True iff `gen` is still the live connection generation - the single spelling
|
|
7398
|
+
* of the ABA guard applied after every await (see `generation`). */
|
|
7399
|
+
isCurrent(gen) {
|
|
7400
|
+
return gen === this.generation;
|
|
7401
|
+
}
|
|
7402
|
+
/** Test-only: the generation currently holding the connectCommandWs in-flight
|
|
7403
|
+
* latch, or null when free. Exposed to pin the `finally` identity guard - a
|
|
7404
|
+
* stale generation's finally must not clear a live generation's latch (which
|
|
7405
|
+
* would let a second overlapping connect build an untracked socket). Not part
|
|
7406
|
+
* of the runtime contract. */
|
|
7407
|
+
get __wsConnectingGenForTests() {
|
|
7408
|
+
return this.wsConnectingGen;
|
|
7409
|
+
}
|
|
6799
7410
|
/**
|
|
6800
7411
|
* Probe the local bridge and, if present, announce this CLI session.
|
|
6801
7412
|
* Returns true iff the announce succeeded (tavern presence is active).
|
|
@@ -6809,7 +7420,10 @@ var BridgePresence = class {
|
|
|
6809
7420
|
if (this.started) return this.instanceId !== null;
|
|
6810
7421
|
this.stopped = false;
|
|
6811
7422
|
this.started = true;
|
|
7423
|
+
this.generation += 1;
|
|
7424
|
+
const gen = this.generation;
|
|
6812
7425
|
const config = await readBridgeConfig();
|
|
7426
|
+
if (!this.isCurrent(gen)) return false;
|
|
6813
7427
|
if (!config) {
|
|
6814
7428
|
logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
|
|
6815
7429
|
return false;
|
|
@@ -6821,34 +7435,119 @@ var BridgePresence = class {
|
|
|
6821
7435
|
this.pendingSource = opts.source ?? "b4m-cli";
|
|
6822
7436
|
return this.attemptAnnounce();
|
|
6823
7437
|
}
|
|
7438
|
+
/**
|
|
7439
|
+
* Classify the process holding the bridge port relative to this CLI, BEFORE
|
|
7440
|
+
* any secret leaves the process. Over loopback TCP the peer is whoever holds
|
|
7441
|
+
* the port; the secret file is plaintext and per-user, so a same-UID peer is
|
|
7442
|
+
* already trusted (it could read the file directly), and a different-UID (or
|
|
7443
|
+
* sandboxed) peer is the adversary this gate stops.
|
|
7444
|
+
*
|
|
7445
|
+
* Four outcomes, so the caller warns only on a real trust failure (a foreign
|
|
7446
|
+
* owner) and stays quiet on the benign ones:
|
|
7447
|
+
* - `trusted` - same-UID owner; disclosure is safe.
|
|
7448
|
+
* - `absent` - no loopback listener yet (cc-bridge simply not
|
|
7449
|
+
* started); fail-closed but quiet - just retry.
|
|
7450
|
+
* - `foreign` - a different-UID owner holds the port; fail-closed AND
|
|
7451
|
+
* log the security-worded warning (the real adversary).
|
|
7452
|
+
* - `undeterminable` - ownership can't be resolved: lookup tool missing/hung,
|
|
7453
|
+
* a read/spawn error, or an ambiguous owner set.
|
|
7454
|
+
* Fail-closed, but a host that can't run the probe is not
|
|
7455
|
+
* an attacker - log at debug, not as the security warning.
|
|
7456
|
+
*
|
|
7457
|
+
* A platform that can't resolve ownership (see `canResolveLoopbackOwner`) is
|
|
7458
|
+
* latched off earlier, in `attemptAnnounce`, so this method is only reached
|
|
7459
|
+
* where `getuid` exists and the platform has an owner probe.
|
|
7460
|
+
*/
|
|
7461
|
+
async checkPeerTrust() {
|
|
7462
|
+
const port = this.config?.port ?? DEFAULT_PORT;
|
|
7463
|
+
let owner;
|
|
7464
|
+
try {
|
|
7465
|
+
owner = await resolveLoopbackListenerOwner(port);
|
|
7466
|
+
} catch (err) {
|
|
7467
|
+
logger.debug(`[tavern] loopback owner lookup threw: ${err.message}`);
|
|
7468
|
+
return "undeterminable";
|
|
7469
|
+
}
|
|
7470
|
+
if (owner.kind === "no-listener") return "absent";
|
|
7471
|
+
if (owner.kind === "unknown") return "undeterminable";
|
|
7472
|
+
if (owner.uid !== process.getuid()) return "foreign";
|
|
7473
|
+
this.peerWarned = false;
|
|
7474
|
+
return "trusted";
|
|
7475
|
+
}
|
|
7476
|
+
/** Log the foreign-owner security warning once per untrusted state. */
|
|
7477
|
+
warnUntrustedPeerOnce() {
|
|
7478
|
+
if (this.peerWarned) return;
|
|
7479
|
+
this.peerWarned = true;
|
|
7480
|
+
logger.warn("[tavern] bridge port is held by a different user; not disclosing secret or handling commands");
|
|
7481
|
+
}
|
|
6824
7482
|
/** One announce attempt. Schedules a retry on failure; wires up the
|
|
6825
7483
|
* command WS + initial status on success. Idempotent: re-entering after
|
|
6826
7484
|
* a successful announce short-circuits at the instanceId guard. */
|
|
6827
7485
|
async attemptAnnounce() {
|
|
6828
7486
|
if (this.stopped || !this.config || !this.startOpts) return false;
|
|
6829
7487
|
if (this.instanceId) return true;
|
|
7488
|
+
if (!canResolveLoopbackOwner()) {
|
|
7489
|
+
logger.debug("[tavern] ownership unresolvable on this platform; tavern presence disabled");
|
|
7490
|
+
return false;
|
|
7491
|
+
}
|
|
7492
|
+
const gen = this.generation;
|
|
7493
|
+
const startOpts = this.startOpts;
|
|
7494
|
+
if (!await this.gateEgress(gen, "announce")) return false;
|
|
6830
7495
|
const instanceId = v4();
|
|
6831
7496
|
const workspaceName = this.pendingWorkspaceName;
|
|
6832
7497
|
const capabilities = this.pendingCapabilities;
|
|
6833
7498
|
const source = this.pendingSource;
|
|
6834
|
-
|
|
7499
|
+
const announced = await this.announce({
|
|
6835
7500
|
instanceId,
|
|
6836
7501
|
source,
|
|
6837
7502
|
workspaceName,
|
|
6838
|
-
workspacePath:
|
|
7503
|
+
workspacePath: startOpts.workspacePath,
|
|
6839
7504
|
capabilities
|
|
6840
|
-
})
|
|
7505
|
+
});
|
|
7506
|
+
if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
|
|
7507
|
+
if (!announced) {
|
|
7508
|
+
this.trusted = false;
|
|
6841
7509
|
this.scheduleAnnounceRetry();
|
|
6842
7510
|
return false;
|
|
6843
7511
|
}
|
|
6844
7512
|
this.instanceId = instanceId;
|
|
6845
7513
|
this.announceAttempts = 0;
|
|
6846
7514
|
logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
|
|
6847
|
-
this.connectCommandWs();
|
|
7515
|
+
this.connectCommandWs().catch((err) => logger.debug(`[tavern] connectCommandWs threw: ${err.message}`));
|
|
6848
7516
|
this.emitEvent({
|
|
6849
7517
|
type: "status",
|
|
6850
7518
|
status: "idle"
|
|
6851
|
-
});
|
|
7519
|
+
}).catch(() => {});
|
|
7520
|
+
return true;
|
|
7521
|
+
}
|
|
7522
|
+
/** Signal the untrusted condition at the right volume: the security-worded
|
|
7523
|
+
* warning only for a real foreign owner, and a quiet debug line for the
|
|
7524
|
+
* benign cases (bridge not started yet, or ownership undeterminable) so we
|
|
7525
|
+
* don't cry wolf on every backed-off retry. */
|
|
7526
|
+
signalUntrusted(trust, phase) {
|
|
7527
|
+
if (trust === "foreign") this.warnUntrustedPeerOnce();
|
|
7528
|
+
else if (trust === "undeterminable") logger.debug(`[tavern] bridge port owner undeterminable; retrying ${phase}`);
|
|
7529
|
+
else logger.debug(`[tavern] cc-bridge not reachable yet; retrying ${phase}`);
|
|
7530
|
+
}
|
|
7531
|
+
/**
|
|
7532
|
+
* Run the pre-disclosure ownership gate for one egress phase and open or keep
|
|
7533
|
+
* the trust latch closed. The caller passes the connection `generation` it
|
|
7534
|
+
* captured BEFORE this await, so a stop()+start() cycle (or a bare stop())
|
|
7535
|
+
* landing during the probe is detected and this stale continuation bails.
|
|
7536
|
+
* Returns true iff the gate is open (secret disclosure is now permitted for
|
|
7537
|
+
* this generation); false means the caller must return - either the owner is
|
|
7538
|
+
* untrusted (a retry has been scheduled) or the generation moved.
|
|
7539
|
+
*/
|
|
7540
|
+
async gateEgress(gen, phase) {
|
|
7541
|
+
const trust = await this.checkPeerTrust();
|
|
7542
|
+
if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
|
|
7543
|
+
if (trust !== "trusted") {
|
|
7544
|
+
this.trusted = false;
|
|
7545
|
+
this.signalUntrusted(trust, phase);
|
|
7546
|
+
if (phase === "announce") this.scheduleAnnounceRetry();
|
|
7547
|
+
else this.scheduleReconnect();
|
|
7548
|
+
return false;
|
|
7549
|
+
}
|
|
7550
|
+
this.trusted = true;
|
|
6852
7551
|
return true;
|
|
6853
7552
|
}
|
|
6854
7553
|
scheduleAnnounceRetry() {
|
|
@@ -6857,17 +7556,22 @@ var BridgePresence = class {
|
|
|
6857
7556
|
const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
|
|
6858
7557
|
this.announceRetryTimer = setTimeout(() => {
|
|
6859
7558
|
this.announceRetryTimer = null;
|
|
6860
|
-
this.attemptAnnounce();
|
|
7559
|
+
this.attemptAnnounce().catch((err) => logger.debug(`[tavern] announce retry threw: ${err.message}`));
|
|
6861
7560
|
}, delay);
|
|
6862
7561
|
}
|
|
6863
7562
|
/** Emit an event for this session. No-op if the bridge isn't up. Events
|
|
6864
7563
|
* leave in strict order - see `emitQueue` comment. */
|
|
6865
7564
|
async emitEvent(event) {
|
|
6866
7565
|
if (!this.config || !this.instanceId) return;
|
|
6867
|
-
const
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
7566
|
+
const instanceId = this.instanceId;
|
|
7567
|
+
const gen = this.generation;
|
|
7568
|
+
const task = () => {
|
|
7569
|
+
if (!this.isCurrent(gen)) return;
|
|
7570
|
+
return this.post("/event", {
|
|
7571
|
+
instanceId,
|
|
7572
|
+
event
|
|
7573
|
+
}).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
|
|
7574
|
+
};
|
|
6871
7575
|
this.emitQueue = this.emitQueue.then(task, task);
|
|
6872
7576
|
return this.emitQueue;
|
|
6873
7577
|
}
|
|
@@ -6885,6 +7589,7 @@ var BridgePresence = class {
|
|
|
6885
7589
|
async stop(reason = "cli_exit") {
|
|
6886
7590
|
if (this.stopped || !this.started) return;
|
|
6887
7591
|
this.stopped = true;
|
|
7592
|
+
this.generation += 1;
|
|
6888
7593
|
if (this.reconnectTimer) {
|
|
6889
7594
|
clearTimeout(this.reconnectTimer);
|
|
6890
7595
|
this.reconnectTimer = null;
|
|
@@ -6893,13 +7598,14 @@ var BridgePresence = class {
|
|
|
6893
7598
|
clearTimeout(this.announceRetryTimer);
|
|
6894
7599
|
this.announceRetryTimer = null;
|
|
6895
7600
|
}
|
|
7601
|
+
const wsWasLive = this.ws !== null;
|
|
6896
7602
|
if (this.ws) {
|
|
6897
7603
|
try {
|
|
6898
7604
|
this.ws.close();
|
|
6899
7605
|
} catch {}
|
|
6900
7606
|
this.ws = null;
|
|
6901
7607
|
}
|
|
6902
|
-
if (this.config && this.instanceId) await this.post("/disconnect", {
|
|
7608
|
+
if (this.config && this.instanceId && wsWasLive) await this.post("/disconnect", {
|
|
6903
7609
|
instanceId: this.instanceId,
|
|
6904
7610
|
reason
|
|
6905
7611
|
}).catch(() => {});
|
|
@@ -6912,6 +7618,8 @@ var BridgePresence = class {
|
|
|
6912
7618
|
this.pendingSource = null;
|
|
6913
7619
|
this.announceAttempts = 0;
|
|
6914
7620
|
this.reconnectAttempts = 0;
|
|
7621
|
+
this.peerWarned = false;
|
|
7622
|
+
this.trusted = false;
|
|
6915
7623
|
this.emitQueue = Promise.resolve();
|
|
6916
7624
|
}
|
|
6917
7625
|
async announce(body) {
|
|
@@ -6925,6 +7633,7 @@ var BridgePresence = class {
|
|
|
6925
7633
|
}
|
|
6926
7634
|
async post(path, body) {
|
|
6927
7635
|
if (!this.config) throw new Error("bridge config not loaded");
|
|
7636
|
+
if (!this.trusted) throw new Error("bridge peer not trusted; refusing to disclose secret");
|
|
6928
7637
|
const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
|
|
6929
7638
|
const res = await fetch(url, {
|
|
6930
7639
|
method: "POST",
|
|
@@ -6934,42 +7643,58 @@ var BridgePresence = class {
|
|
|
6934
7643
|
});
|
|
6935
7644
|
if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
|
|
6936
7645
|
}
|
|
6937
|
-
connectCommandWs() {
|
|
7646
|
+
async connectCommandWs() {
|
|
6938
7647
|
if (this.stopped || !this.config || !this.instanceId) return;
|
|
6939
|
-
const
|
|
6940
|
-
|
|
7648
|
+
const gen = this.generation;
|
|
7649
|
+
if (this.ws || this.wsConnectingGen === gen) return;
|
|
7650
|
+
this.wsConnectingGen = gen;
|
|
6941
7651
|
try {
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
this.ws = ws;
|
|
6949
|
-
ws.on("open", () => {
|
|
6950
|
-
this.reconnectAttempts = 0;
|
|
6951
|
-
logger.debug("[tavern] command WS open");
|
|
6952
|
-
});
|
|
6953
|
-
ws.on("message", (raw) => {
|
|
6954
|
-
let frame = null;
|
|
7652
|
+
if (!await this.gateEgress(gen, "command WS")) return;
|
|
7653
|
+
const config = this.config;
|
|
7654
|
+
const instanceId = this.instanceId;
|
|
7655
|
+
if (!config || !instanceId) return;
|
|
7656
|
+
const url = `ws://127.0.0.1:${config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(instanceId)}&secret=${encodeURIComponent(config.hookSecret)}`;
|
|
7657
|
+
let ws;
|
|
6955
7658
|
try {
|
|
6956
|
-
|
|
6957
|
-
} catch {
|
|
6958
|
-
logger.debug(
|
|
7659
|
+
ws = new WebSocket(url);
|
|
7660
|
+
} catch (err) {
|
|
7661
|
+
logger.debug(`[tavern] command WS construct failed: ${err.message}`);
|
|
7662
|
+
this.trusted = false;
|
|
7663
|
+
this.scheduleReconnect();
|
|
6959
7664
|
return;
|
|
6960
7665
|
}
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
7666
|
+
this.ws = ws;
|
|
7667
|
+
ws.on("open", () => {
|
|
7668
|
+
if (this.ws !== ws) return;
|
|
7669
|
+
this.reconnectAttempts = 0;
|
|
7670
|
+
logger.debug("[tavern] command WS open");
|
|
7671
|
+
});
|
|
7672
|
+
ws.on("message", (raw) => {
|
|
7673
|
+
if (this.ws !== ws) return;
|
|
7674
|
+
let frame = null;
|
|
7675
|
+
try {
|
|
7676
|
+
frame = JSON.parse(raw.toString());
|
|
7677
|
+
} catch {
|
|
7678
|
+
logger.debug("[tavern] malformed command frame; ignored");
|
|
7679
|
+
return;
|
|
7680
|
+
}
|
|
7681
|
+
if (!frame?.command) return;
|
|
7682
|
+
this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
|
|
7683
|
+
});
|
|
7684
|
+
ws.on("close", () => {
|
|
7685
|
+
if (this.ws !== ws) return;
|
|
7686
|
+
this.ws = null;
|
|
7687
|
+
this.trusted = false;
|
|
7688
|
+
if (this.stopped) return;
|
|
7689
|
+
logger.debug("[tavern] command WS closed; reconnecting");
|
|
7690
|
+
this.scheduleReconnect();
|
|
7691
|
+
});
|
|
7692
|
+
ws.on("error", (err) => {
|
|
7693
|
+
logger.debug(`[tavern] command WS error: ${err.message}`);
|
|
7694
|
+
});
|
|
7695
|
+
} finally {
|
|
7696
|
+
if (this.wsConnectingGen === gen) this.wsConnectingGen = null;
|
|
7697
|
+
}
|
|
6973
7698
|
}
|
|
6974
7699
|
scheduleReconnect() {
|
|
6975
7700
|
if (this.stopped || this.reconnectTimer) return;
|
|
@@ -6977,10 +7702,11 @@ var BridgePresence = class {
|
|
|
6977
7702
|
const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
|
|
6978
7703
|
this.reconnectTimer = setTimeout(() => {
|
|
6979
7704
|
this.reconnectTimer = null;
|
|
6980
|
-
this.connectCommandWs();
|
|
7705
|
+
this.connectCommandWs().catch((err) => logger.debug(`[tavern] command WS reconnect threw: ${err.message}`));
|
|
6981
7706
|
}, delay);
|
|
6982
7707
|
}
|
|
6983
7708
|
async dispatchCommand(command) {
|
|
7709
|
+
if (this.stopped) return;
|
|
6984
7710
|
switch (command.type) {
|
|
6985
7711
|
case "send_prompt":
|
|
6986
7712
|
if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
|
|
@@ -7130,7 +7856,7 @@ const infoCommands = [
|
|
|
7130
7856
|
{
|
|
7131
7857
|
name: "help",
|
|
7132
7858
|
run: (_args, ctx) => {
|
|
7133
|
-
const customCommands = ctx.customCommandStore.
|
|
7859
|
+
const customCommands = ctx.customCommandStore.getModelReachableCommands();
|
|
7134
7860
|
const hasCustomCommands = customCommands.length > 0;
|
|
7135
7861
|
console.log(`
|
|
7136
7862
|
Available commands:
|
|
@@ -7321,6 +8047,48 @@ async function dispatch(name, args, ctx) {
|
|
|
7321
8047
|
return true;
|
|
7322
8048
|
}
|
|
7323
8049
|
//#endregion
|
|
8050
|
+
//#region src/commands/sandboxNetworkCommand.ts
|
|
8051
|
+
async function handleSandboxNetworkCommand(deps, arg) {
|
|
8052
|
+
const { orchestrator, configStore, permissionManager } = deps;
|
|
8053
|
+
if (!orchestrator) return "Sandbox not initialized";
|
|
8054
|
+
if (arg !== "on" && arg !== "off") return "Usage: /sandbox:network <on|off>";
|
|
8055
|
+
const enable = arg === "on";
|
|
8056
|
+
if (enable && !orchestrator.isAvailable()) return "Sandbox runtime not available on this platform";
|
|
8057
|
+
const active = await orchestrator.setNetworkEnabled(enable);
|
|
8058
|
+
if (enable && !active) {
|
|
8059
|
+
permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
|
|
8060
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
8061
|
+
return "Failed to start the network proxy; network stays disabled (fail-closed).";
|
|
8062
|
+
}
|
|
8063
|
+
permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
|
|
8064
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
8065
|
+
if (!enable) return "Sandbox network disabled (fail-closed)";
|
|
8066
|
+
const port = orchestrator.getProxyManager()?.getPort();
|
|
8067
|
+
return `Sandbox network egress enabled${port ? ` (proxy-aware clients filtered on port ${port})` : ""}`;
|
|
8068
|
+
}
|
|
8069
|
+
//#endregion
|
|
8070
|
+
//#region src/commands/sandboxTrustDomainCommand.ts
|
|
8071
|
+
async function handleSandboxTrustDomainCommand(deps, domains) {
|
|
8072
|
+
const { orchestrator, configStore } = deps;
|
|
8073
|
+
if (!orchestrator) return "Sandbox not initialized";
|
|
8074
|
+
const clean = domains.map((d) => d.trim()).filter(Boolean);
|
|
8075
|
+
if (clean.length === 0) return "Usage: /sandbox:trust-domain <domain> [...]";
|
|
8076
|
+
if (!orchestrator.getProxyManager()) return "Network proxy not initialized";
|
|
8077
|
+
const lines = [];
|
|
8078
|
+
for (const domain of clean) {
|
|
8079
|
+
orchestrator.addAllowedDomain(domain);
|
|
8080
|
+
lines.push(` Added: ${domain}`);
|
|
8081
|
+
}
|
|
8082
|
+
try {
|
|
8083
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
8084
|
+
} catch (err) {
|
|
8085
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
8086
|
+
return `${lines.join("\n")}\nWarning: failed to persist the trusted domains (${msg}); they apply for this session only.`;
|
|
8087
|
+
}
|
|
8088
|
+
lines.push(`Trusted ${clean.length} domain(s)`);
|
|
8089
|
+
return lines.join("\n");
|
|
8090
|
+
}
|
|
8091
|
+
//#endregion
|
|
7324
8092
|
//#region src/session/turnController.ts
|
|
7325
8093
|
/**
|
|
7326
8094
|
* Turn lifecycle (issue #228, phase 2).
|
|
@@ -7339,7 +8107,7 @@ async function dispatch(name, args, ctx) {
|
|
|
7339
8107
|
* recurses with the same context for messages submitted mid-turn.
|
|
7340
8108
|
*/
|
|
7341
8109
|
async function runTurn(message, ctx) {
|
|
7342
|
-
const { agent, sessionStore, configStore, commandHistoryStore, customCommandStore, messageBuilder, config, availableModels, agentStore, contextContent, additionalDirectories, featureRegistry, backgroundManager, todoStore, decisionStore, blockerStore, workflowStores, setCommandHistory, setAbortController } = ctx;
|
|
8110
|
+
const { agent, sessionStore, configStore, commandHistoryStore, customCommandStore, messageBuilder, config, availableModels, agentStore, contextContent, additionalDirectories, featureRegistry, backgroundManager, todoStore, decisionStore, blockerStore, postEditDiagnostics, workflowStores, setCommandHistory, setAbortController } = ctx;
|
|
7343
8111
|
const storeSession = useCliStore.getState().session;
|
|
7344
8112
|
if (!agent || !storeSession) {
|
|
7345
8113
|
console.error("❌ CLI failed to initialize. Try restarting b4m.\n");
|
|
@@ -7365,7 +8133,7 @@ async function runTurn(message, ctx) {
|
|
|
7365
8133
|
const systemPrompt = buildSystemPrompt(config?.preferences.promptVariant ?? "current", {
|
|
7366
8134
|
contextContent,
|
|
7367
8135
|
agentStore: agentStore || void 0,
|
|
7368
|
-
customCommands: customCommandStore.
|
|
8136
|
+
customCommands: customCommandStore.getModelReachableCommands(),
|
|
7369
8137
|
enableSkillTool: config?.preferences.enableSkillTool !== false,
|
|
7370
8138
|
additionalDirectories,
|
|
7371
8139
|
featureModulePrompts: featureRegistry?.getSystemPromptSections() || void 0,
|
|
@@ -7444,6 +8212,8 @@ async function runTurn(message, ctx) {
|
|
|
7444
8212
|
if (elided > 0) logger.debug(`[workflowReminder] elided ${elided} item(s) to fit the token cap`);
|
|
7445
8213
|
return text;
|
|
7446
8214
|
} : void 0;
|
|
8215
|
+
const diagnostics = cliConfig.preferences.postEditDiagnostics === true ? postEditDiagnostics : null;
|
|
8216
|
+
diagnostics?.beginTurn();
|
|
7447
8217
|
let result;
|
|
7448
8218
|
try {
|
|
7449
8219
|
result = await agent.run(messageContent, {
|
|
@@ -7452,9 +8222,11 @@ async function runTurn(message, ctx) {
|
|
|
7452
8222
|
parallelExecution: cliConfig.preferences.enableParallelToolExecution === true,
|
|
7453
8223
|
isReadOnlyTool,
|
|
7454
8224
|
workflowReminder,
|
|
8225
|
+
drainFeedback: diagnostics ? (phase) => diagnostics.drain(DRAIN_BUDGET_MS[phase], abortController.signal) : void 0,
|
|
7455
8226
|
onContextLimit: createReactiveCompactionHandler(agent, activeSession, 1 + previousMessages.length + 1)
|
|
7456
8227
|
});
|
|
7457
8228
|
} finally {
|
|
8229
|
+
diagnostics?.endTurn();
|
|
7458
8230
|
backgroundManager?.setCurrentTurn(null);
|
|
7459
8231
|
}
|
|
7460
8232
|
const permissionDenied = result.finalAnswer.startsWith("Permission denied for tool");
|
|
@@ -7755,6 +8527,22 @@ async function rewindSession(ctx, messageIndex) {
|
|
|
7755
8527
|
return { prefill: prefillContent };
|
|
7756
8528
|
}
|
|
7757
8529
|
//#endregion
|
|
8530
|
+
//#region src/session/modelOverride.ts
|
|
8531
|
+
async function runWithModelOverride(ctx, overrideModel, run) {
|
|
8532
|
+
const originalModel = useCliStore.getState().session?.model;
|
|
8533
|
+
ctx.applyModel(overrideModel);
|
|
8534
|
+
try {
|
|
8535
|
+
return await run();
|
|
8536
|
+
} finally {
|
|
8537
|
+
if (originalModel) await restoreModel(ctx, originalModel);
|
|
8538
|
+
}
|
|
8539
|
+
}
|
|
8540
|
+
async function restoreModel(ctx, originalModel) {
|
|
8541
|
+
ctx.applyModel(originalModel);
|
|
8542
|
+
const restoredSession = useCliStore.getState().session;
|
|
8543
|
+
if (restoredSession) await ctx.sessionStore.save(restoredSession);
|
|
8544
|
+
}
|
|
8545
|
+
//#endregion
|
|
7758
8546
|
//#region src/index.tsx
|
|
7759
8547
|
process.removeAllListeners("warning");
|
|
7760
8548
|
process.on("warning", (warning) => {
|
|
@@ -7854,6 +8642,7 @@ function CliApp() {
|
|
|
7854
8642
|
permissionPrompt: null,
|
|
7855
8643
|
trustLocationSelector: null,
|
|
7856
8644
|
folderTrustPrompt: null,
|
|
8645
|
+
mcpApprovalPrompt: null,
|
|
7857
8646
|
rewindSelector: null,
|
|
7858
8647
|
sessionSelector: null,
|
|
7859
8648
|
orchestrator: null,
|
|
@@ -7873,10 +8662,12 @@ function CliApp() {
|
|
|
7873
8662
|
const [commandHistory, setCommandHistory] = useState([]);
|
|
7874
8663
|
const imageStoreInitPromise = useRef(null);
|
|
7875
8664
|
const folderTrustResolvedRef = useRef(false);
|
|
8665
|
+
const mcpApprovalResolvedRef = useRef(false);
|
|
7876
8666
|
const todoStoreRef = useRef(createTodoStore());
|
|
7877
8667
|
const decisionStoreRef = useRef(createDecisionStore());
|
|
7878
8668
|
const blockerStoreRef = useRef(createBlockerStore());
|
|
7879
8669
|
const reviewGateStoreRef = useRef(createReviewGateStore());
|
|
8670
|
+
const postEditDiagnosticsRef = useRef(new PostEditDiagnostics({ workspaceRoot: process.cwd() }));
|
|
7880
8671
|
const setStoreSession = useCliStore((state) => state.setSession);
|
|
7881
8672
|
const enqueuePermissionPrompt = useCliStore((state) => state.enqueuePermissionPrompt);
|
|
7882
8673
|
const enqueueUserQuestionPrompt = useCliStore((state) => state.enqueueUserQuestionPrompt);
|
|
@@ -7988,6 +8779,20 @@ function CliApp() {
|
|
|
7988
8779
|
}));
|
|
7989
8780
|
return;
|
|
7990
8781
|
}
|
|
8782
|
+
const pendingMcp = state.configStore.getPendingMcpApprovals();
|
|
8783
|
+
if (pendingMcp.length > 0 && !mcpApprovalResolvedRef.current && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
|
|
8784
|
+
mcpApprovalResolvedRef.current = true;
|
|
8785
|
+
setState((prev) => ({
|
|
8786
|
+
...prev,
|
|
8787
|
+
mcpApprovalPrompt: {
|
|
8788
|
+
projectRoot: state.configStore.getProjectRealPath() ?? process.cwd(),
|
|
8789
|
+
servers: pendingMcp
|
|
8790
|
+
},
|
|
8791
|
+
config
|
|
8792
|
+
}));
|
|
8793
|
+
return;
|
|
8794
|
+
}
|
|
8795
|
+
if (!mcpApprovalResolvedRef.current) state.configStore.warnPendingMcpApprovals();
|
|
7991
8796
|
const configDirs = await state.configStore.getAdditionalDirectories();
|
|
7992
8797
|
const flagDirs = process.env.B4M_ADDITIONAL_DIRS ? JSON.parse(process.env.B4M_ADDITIONAL_DIRS) : [];
|
|
7993
8798
|
const additionalDirectories = [.../* @__PURE__ */ new Set([...configDirs, ...flagDirs])];
|
|
@@ -8050,6 +8855,14 @@ function CliApp() {
|
|
|
8050
8855
|
});
|
|
8051
8856
|
const pinnedSessionId = process.env.B4M_SESSION_ID;
|
|
8052
8857
|
const resumeSessionId = process.env.B4M_RESUME_ID;
|
|
8858
|
+
if (pinnedSessionId && !isValidSessionId(pinnedSessionId)) {
|
|
8859
|
+
console.error(`Invalid session id (--session-id / B4M_SESSION_ID): must match ${SESSION_ID_PATTERN.source}`);
|
|
8860
|
+
process.exit(1);
|
|
8861
|
+
}
|
|
8862
|
+
if (resumeSessionId && !isValidSessionId(resumeSessionId)) {
|
|
8863
|
+
console.error(`Invalid session id (--resume / B4M_RESUME_ID): must match ${SESSION_ID_PATTERN.source}`);
|
|
8864
|
+
process.exit(1);
|
|
8865
|
+
}
|
|
8053
8866
|
let newSession;
|
|
8054
8867
|
if (resumeSessionId) {
|
|
8055
8868
|
const resumed = await state.sessionStore.load(resumeSessionId);
|
|
@@ -8164,7 +8977,8 @@ function CliApp() {
|
|
|
8164
8977
|
};
|
|
8165
8978
|
const agentContext = {
|
|
8166
8979
|
currentAgent: null,
|
|
8167
|
-
observationQueue: []
|
|
8980
|
+
observationQueue: [],
|
|
8981
|
+
onFileChanged: (filePath) => postEditDiagnosticsRef.current.enqueue(filePath)
|
|
8168
8982
|
};
|
|
8169
8983
|
const { mcpManager, agentStore, contextResult, mcpTools, loadedB4mTools, deferredB4mTools, orchestrator, backgroundManager, historyStore } = await buildSupportingStores({
|
|
8170
8984
|
config,
|
|
@@ -8234,8 +9048,10 @@ function CliApp() {
|
|
|
8234
9048
|
customCommandStore: state.customCommandStore,
|
|
8235
9049
|
subagentOrchestrator: orchestrator,
|
|
8236
9050
|
sessionId: newSession.id,
|
|
8237
|
-
|
|
8238
|
-
|
|
9051
|
+
permission: {
|
|
9052
|
+
permissionManager,
|
|
9053
|
+
promptFn
|
|
9054
|
+
},
|
|
8239
9055
|
allowedDirectories: additionalDirectories
|
|
8240
9056
|
})], cliWrapDeps)[0] : null;
|
|
8241
9057
|
const findDefinitionTool = wrapTools([createFindDefinitionTool(additionalDirectories)], cliWrapDeps)[0];
|
|
@@ -8267,6 +9083,7 @@ function CliApp() {
|
|
|
8267
9083
|
logger,
|
|
8268
9084
|
reservedToolNames: [...loadedB4mTools, ...cliTools].map((t) => t.toolSchema.name).concat("tool_search")
|
|
8269
9085
|
});
|
|
9086
|
+
rewireReservedNames(state, featureRegistry);
|
|
8270
9087
|
const featureModuleToolNames = featureRegistry.getAllToolNames();
|
|
8271
9088
|
if (featureModuleToolNames.length > 0) registerFeatureModuleTools(featureModuleToolNames);
|
|
8272
9089
|
if (wsManager && featureRegistry.hasModules) featureRegistry.registerAllWsHandlers(wsManager);
|
|
@@ -8671,6 +9488,7 @@ function CliApp() {
|
|
|
8671
9488
|
todoStore: todoStoreRef.current,
|
|
8672
9489
|
decisionStore: decisionStoreRef.current,
|
|
8673
9490
|
blockerStore: blockerStoreRef.current,
|
|
9491
|
+
postEditDiagnostics: postEditDiagnosticsRef.current,
|
|
8674
9492
|
workflowStores: {
|
|
8675
9493
|
decisionStore: decisionStoreRef.current,
|
|
8676
9494
|
blockerStore: blockerStoreRef.current,
|
|
@@ -9109,7 +9927,7 @@ function CliApp() {
|
|
|
9109
9927
|
console.log(fellBack ? "✅ Session saved with local fallback handoff" : "✅ Session saved with refreshed handoff");
|
|
9110
9928
|
};
|
|
9111
9929
|
const handleCommand = async (command, args) => {
|
|
9112
|
-
const customCommand = state.customCommandStore.
|
|
9930
|
+
const customCommand = state.customCommandStore.getModelReachableCommand(command);
|
|
9113
9931
|
if (customCommand) try {
|
|
9114
9932
|
const sourceIcon = customCommand.source === "global" ? "🏠" : "📁";
|
|
9115
9933
|
console.log(`${sourceIcon} Executing custom command: /${command}`);
|
|
@@ -9140,11 +9958,10 @@ function CliApp() {
|
|
|
9140
9958
|
}
|
|
9141
9959
|
if (customCommand.model && state.agent) {
|
|
9142
9960
|
console.log(`🔄 Using model override: ${customCommand.model}`);
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
if (overrideSession && originalModel) overrideSession.model = originalModel;
|
|
9961
|
+
await runWithModelOverride({
|
|
9962
|
+
applyModel: applyModelToSession,
|
|
9963
|
+
sessionStore: state.sessionStore
|
|
9964
|
+
}, customCommand.model, () => handleCustomCommandMessage(substitutedBody, displayMessage));
|
|
9148
9965
|
} else {
|
|
9149
9966
|
console.log("🤖 Sending to agent...\n");
|
|
9150
9967
|
await handleCustomCommandMessage(substitutedBody, displayMessage);
|
|
@@ -9661,7 +10478,7 @@ function CliApp() {
|
|
|
9661
10478
|
const variantForCount = state.config?.preferences.promptVariant ?? "current";
|
|
9662
10479
|
const corePromptTokens = tokenCounter.countTokens(buildSystemPrompt(variantForCount));
|
|
9663
10480
|
const projectContextTokens = state.contextContent ? tokenCounter.countTokens(state.contextContent) : 0;
|
|
9664
|
-
const commands = state.customCommandStore.
|
|
10481
|
+
const commands = state.customCommandStore.getModelReachableCommands();
|
|
9665
10482
|
const skillsSection = buildSkillsPromptSection(commands);
|
|
9666
10483
|
const skillsTokens = skillsSection ? tokenCounter.countTokens(skillsSection) : 0;
|
|
9667
10484
|
const agentDirectoryTokens = state.agentStore ? tokenCounter.countTokens(state.agentStore.getDirectoryContext()) : 0;
|
|
@@ -9753,9 +10570,9 @@ function CliApp() {
|
|
|
9753
10570
|
break;
|
|
9754
10571
|
}
|
|
9755
10572
|
case "commands": {
|
|
9756
|
-
const customCommands = state.customCommandStore.
|
|
9757
|
-
const globalCommands =
|
|
9758
|
-
const projectCommands =
|
|
10573
|
+
const customCommands = state.customCommandStore.getModelReachableCommands();
|
|
10574
|
+
const globalCommands = customCommands.filter((cmd) => cmd.source === "global");
|
|
10575
|
+
const projectCommands = customCommands.filter((cmd) => cmd.source === "project");
|
|
9759
10576
|
console.log("\n📝 Custom Commands:\n");
|
|
9760
10577
|
if (customCommands.length === 0) {
|
|
9761
10578
|
console.log("No custom commands found.");
|
|
@@ -9937,9 +10754,10 @@ function CliApp() {
|
|
|
9937
10754
|
state.sandboxOrchestrator.setMode("auto-allow");
|
|
9938
10755
|
state.permissionManager?.setSandboxState("auto-allow", state.sandboxOrchestrator.isActive());
|
|
9939
10756
|
if (state.sandboxOrchestrator.getConfig().network.enabled) {
|
|
9940
|
-
await state.sandboxOrchestrator.
|
|
10757
|
+
const on = await state.sandboxOrchestrator.setNetworkEnabled(true);
|
|
9941
10758
|
const pm = state.sandboxOrchestrator.getProxyManager();
|
|
9942
|
-
if (pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
|
|
10759
|
+
if (on && pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
|
|
10760
|
+
else console.log("⚠️ Network filtering requested but the proxy failed to start; egress disabled");
|
|
9943
10761
|
}
|
|
9944
10762
|
await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
|
|
9945
10763
|
console.log("Sandbox enabled (auto-allow mode)");
|
|
@@ -9949,7 +10767,7 @@ function CliApp() {
|
|
|
9949
10767
|
console.log("Sandbox not initialized");
|
|
9950
10768
|
break;
|
|
9951
10769
|
}
|
|
9952
|
-
await state.sandboxOrchestrator.
|
|
10770
|
+
await state.sandboxOrchestrator.setNetworkEnabled(false);
|
|
9953
10771
|
state.sandboxOrchestrator.setMode("disabled");
|
|
9954
10772
|
state.permissionManager?.setSandboxState("disabled", false);
|
|
9955
10773
|
await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
|
|
@@ -9975,35 +10793,19 @@ function CliApp() {
|
|
|
9975
10793
|
console.log(`Sandbox mode set to: ${modeArg}`);
|
|
9976
10794
|
break;
|
|
9977
10795
|
}
|
|
9978
|
-
case "sandbox:
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
break;
|
|
9991
|
-
}
|
|
9992
|
-
for (const domain of args) {
|
|
9993
|
-
proxyMgr.addAllowedDomain(domain);
|
|
9994
|
-
console.log(` Added: ${domain}`);
|
|
9995
|
-
}
|
|
9996
|
-
const currentSandboxConfig = state.sandboxOrchestrator.getConfig();
|
|
9997
|
-
await state.configStore.saveSandboxConfig({
|
|
9998
|
-
...currentSandboxConfig,
|
|
9999
|
-
network: {
|
|
10000
|
-
...currentSandboxConfig.network,
|
|
10001
|
-
allowedDomains: proxyMgr.getAllowedDomains()
|
|
10002
|
-
}
|
|
10003
|
-
});
|
|
10004
|
-
console.log(`Trusted ${args.length} domain(s)`);
|
|
10796
|
+
case "sandbox:network":
|
|
10797
|
+
console.log(await handleSandboxNetworkCommand({
|
|
10798
|
+
orchestrator: state.sandboxOrchestrator,
|
|
10799
|
+
configStore: state.configStore,
|
|
10800
|
+
permissionManager: state.permissionManager
|
|
10801
|
+
}, args[0]));
|
|
10802
|
+
break;
|
|
10803
|
+
case "sandbox:trust-domain":
|
|
10804
|
+
console.log(await handleSandboxTrustDomainCommand({
|
|
10805
|
+
orchestrator: state.sandboxOrchestrator,
|
|
10806
|
+
configStore: state.configStore
|
|
10807
|
+
}, args));
|
|
10005
10808
|
break;
|
|
10006
|
-
}
|
|
10007
10809
|
case "sandbox:domains": {
|
|
10008
10810
|
if (!state.sandboxOrchestrator) {
|
|
10009
10811
|
console.log("Sandbox not initialized");
|
|
@@ -10195,7 +10997,9 @@ function CliApp() {
|
|
|
10195
10997
|
logger,
|
|
10196
10998
|
reservedToolNames: baseTools.map((t) => t.toolSchema.name)
|
|
10197
10999
|
});
|
|
10198
|
-
|
|
11000
|
+
const rebuiltRegistry = rebuilt.registry;
|
|
11001
|
+
newFeatureRegistry = rebuiltRegistry;
|
|
11002
|
+
rewireReservedNames(state, rebuiltRegistry);
|
|
10199
11003
|
for (const skippedPlugin of rebuilt.skipped) console.error(`\n\x1b[33m⚠️ Plugin ${skippedPlugin.name} skipped: ${skippedPlugin.reason}\x1b[0m`);
|
|
10200
11004
|
const newToolNames = newFeatureRegistry.getAllToolNames();
|
|
10201
11005
|
if (newToolNames.length > 0) registerFeatureModuleTools(newToolNames);
|
|
@@ -10209,7 +11013,7 @@ function CliApp() {
|
|
|
10209
11013
|
state.agent.setSystemPrompt(buildSystemPrompt(updatedConfig.preferences.promptVariant ?? "current", {
|
|
10210
11014
|
contextContent: state.contextContent,
|
|
10211
11015
|
agentStore: state.agentStore || void 0,
|
|
10212
|
-
customCommands: state.customCommandStore.
|
|
11016
|
+
customCommands: state.customCommandStore.getModelReachableCommands(),
|
|
10213
11017
|
enableSkillTool: updatedConfig.preferences.enableSkillTool !== false,
|
|
10214
11018
|
enableDynamicAgentCreation: updatedConfig.preferences.enableDynamicAgentCreation === true,
|
|
10215
11019
|
additionalDirectories: state.additionalDirectories,
|
|
@@ -10320,6 +11124,34 @@ function CliApp() {
|
|
|
10320
11124
|
})();
|
|
10321
11125
|
}
|
|
10322
11126
|
});
|
|
11127
|
+
if (state.mcpApprovalPrompt) {
|
|
11128
|
+
const { projectRoot, servers } = state.mcpApprovalPrompt;
|
|
11129
|
+
return /* @__PURE__ */ React.createElement(McpApprovalPrompt, {
|
|
11130
|
+
projectRoot,
|
|
11131
|
+
servers,
|
|
11132
|
+
onSelect: (choice) => {
|
|
11133
|
+
(async () => {
|
|
11134
|
+
try {
|
|
11135
|
+
if (choice === "approve") {
|
|
11136
|
+
await state.configStore.approveMcpServers(servers);
|
|
11137
|
+
console.log("\nApproved. Starting these MCP servers; a changed definition will ask again.\n");
|
|
11138
|
+
} else console.log("\nThese MCP servers stay off this session. You will be asked again next launch.\n");
|
|
11139
|
+
} catch (err) {
|
|
11140
|
+
console.error("\nCould not save MCP approval:", err instanceof Error ? err.message : String(err), "\n");
|
|
11141
|
+
} finally {
|
|
11142
|
+
setState((prev) => ({
|
|
11143
|
+
...prev,
|
|
11144
|
+
mcpApprovalPrompt: null
|
|
11145
|
+
}));
|
|
11146
|
+
init().catch((err) => {
|
|
11147
|
+
console.error("\nInitialization failed:", err instanceof Error ? err.message : String(err), "\n");
|
|
11148
|
+
exit();
|
|
11149
|
+
});
|
|
11150
|
+
}
|
|
11151
|
+
})();
|
|
11152
|
+
}
|
|
11153
|
+
});
|
|
11154
|
+
}
|
|
10323
11155
|
const currentSession = useCliStore.getState().session;
|
|
10324
11156
|
if (state.rewindSelector && currentSession) return /* @__PURE__ */ React.createElement(RewindSelector, {
|
|
10325
11157
|
messages: currentSession.messages,
|