@bike4mind/cli 1.0.0 → 1.2.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 +1 -0
- package/bin/bike4mind-cli.mjs +0 -5
- package/dist/AgentHistoryStore-B2NEOvSW.mjs +18014 -0
- package/dist/{ApiClient-EUTyn5yu.mjs → ApiClient-D0fQ2FT6.mjs} +2 -2
- package/dist/{BubblewrapRuntime-CkL9-gnG.mjs → BubblewrapRuntime-5bLPTwEC.mjs} +2 -2
- package/dist/{ConfigStore-DHNgFdOu.mjs → ConfigStore-qV7NrCgZ.mjs} +2259 -1283
- package/dist/{ProxyManager-C1-lgzEU.mjs → ProxyManager-B0-RuR2w.mjs} +21 -4
- package/dist/{SandboxOrchestrator-C8uleDn2.mjs → SandboxOrchestrator-BcUv9fQ3.mjs} +47 -13
- 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-C-C-VGff.mjs +2001 -0
- package/dist/commands/acpCommand.mjs +36 -13
- 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 +92 -42
- package/dist/commands/mcpCommand.mjs +8 -13
- package/dist/commands/pluginCommand.mjs +9 -15
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/{createFile-DPv180yF-BnWFIxey.mjs → createFile-B8bur5Rb-CVzCarEA.mjs} +2 -2
- package/dist/{deleteFile-BdjUwUQF-B3XOJmg3.mjs → deleteFile-9B3gW_Nb-DG2sovIl.mjs} +2 -2
- package/dist/{globFiles-DjfDGaUK-CNR8pMRC.mjs → globFiles-CwJ8qmYo-BR5b2KvO.mjs} +3 -2
- package/dist/{grepSearch-BaYUfIYs-n0XKoGnL.mjs → grepSearch-BgoOOwGe-DtlV8Gn-.mjs} +3 -3
- package/dist/index.mjs +638 -129
- package/dist/{package-7a45-Svr.mjs → package-CGZIoxcs.mjs} +1 -1
- package/dist/{pathValidation-D8tjkQXE-1HwvsuYT.mjs → pathValidation-BRqf4HFX-CHwtwp3O.mjs} +7 -3
- package/dist/{serve-CavAHPdQ.mjs → serve-CPXqcEZr.mjs} +2 -2
- package/dist/types-CdIKgWWe.mjs +3 -0
- package/dist/{types-LyRNHOiS.mjs → types-F61_hxmG.mjs} +2 -0
- package/package.json +28 -29
- package/dist/AgentHistoryStore-CrRb8cMt.mjs +0 -38586
- package/dist/ProxyManager-B1jFWL7b.mjs +0 -3
- package/dist/SandboxOrchestrator-BFPVpmB5.mjs +0 -3
- package/dist/buildAgent-CJrkEG0M.mjs +0 -824
- package/dist/types-CqscS34o.mjs +0 -3
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ 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-B2NEOvSW.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 { C as CREDIT_DEDUCT_TRANSACTION_TYPES, F as REVIEW_GATE_STATUS_VALUES, K as isEarlyStop, L as SUBQUEST_STATUS_VALUES, c as SESSION_ID_PATTERN, d as LOCAL_DEV_URL, et as ACTOR_COLOR_SLOTS, f as getCreditsUrl, g as resolveApiEndpoint, h as requireApiUrl, it as selfClaimedActorKindSchema, l as isValidSessionId, m as parseApiUrl, n as logger, nt as actorKindMarker, p as getEnvironmentName, r as extractCompactInstructions, rt as actorKindSchema, s as isReadOnlyTool, t as ConfigStore, tt as actorColorIndex, u as ApiEndpointUnconfiguredError, w as ChatModels } from "./ConfigStore-qV7NrCgZ.mjs";
|
|
6
|
+
import { a as deferredToolRegistry, i as createToolSearchTool, n as buildSupportingStores, o as NotifyingLlmBackend, r as buildLlmBackend, t as buildAgent } from "./buildAgent-C-C-VGff.mjs";
|
|
6
7
|
import { t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
|
|
7
|
-
import { n as OAuthClient, t as ApiClient } from "./ApiClient-
|
|
8
|
+
import { n as OAuthClient, t as ApiClient } from "./ApiClient-D0fQ2FT6.mjs";
|
|
8
9
|
import { i as getTokenCounter, n as ConversationContext, r as reconstructTurnBlocks, t as WorkItemsClient } from "./WorkItemsClient-Cow6nXx7.mjs";
|
|
9
|
-
import {
|
|
10
|
-
import { t as version } from "./package-7a45-Svr.mjs";
|
|
10
|
+
import { t as version } from "./package-CGZIoxcs.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";
|
|
@@ -1083,9 +1084,28 @@ function CompletedGroupNotification() {
|
|
|
1083
1084
|
}
|
|
1084
1085
|
//#endregion
|
|
1085
1086
|
//#region src/components/PermissionPrompt.tsx
|
|
1087
|
+
/**
|
|
1088
|
+
* Escape characters that let model-authored text render differently from what
|
|
1089
|
+
* actually runs, into a visible escape form. The preview and arguments are
|
|
1090
|
+
* model-authored, so the approved text could otherwise differ from what runs.
|
|
1091
|
+
* Two ranges are covered, deliberately and no wider:
|
|
1092
|
+
* - C0 controls (minus tab/newline) and DEL -> `\xHH`: a raw `\r` or ESC
|
|
1093
|
+
* sequence could rewrite the line the user is approving.
|
|
1094
|
+
* - Unicode bidi overrides U+202A..U+202E and isolates U+2066..U+2069
|
|
1095
|
+
* -> `\uXXXX`: these reorder displayed glyphs without changing the
|
|
1096
|
+
* underlying code points, spoofing the same way.
|
|
1097
|
+
* Tab and newline are left intact (newline is already the line delimiter).
|
|
1098
|
+
* Widen the scope here, so both call sites gain it at once.
|
|
1099
|
+
*/
|
|
1100
|
+
function escapeTerminalControlChars(text) {
|
|
1101
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, (ch) => {
|
|
1102
|
+
const code = ch.charCodeAt(0);
|
|
1103
|
+
return code > 255 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`;
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1086
1106
|
/** Render a diff/preview string, color-coding each line by its prefix (see per-branch comments below). */
|
|
1087
1107
|
function renderDiffPreview(preview) {
|
|
1088
|
-
return preview.split("\n").map((line, index) => {
|
|
1108
|
+
return escapeTerminalControlChars(preview).split("\n").map((line, index) => {
|
|
1089
1109
|
if (line.startsWith("🛑")) return /* @__PURE__ */ React.createElement(Text, {
|
|
1090
1110
|
key: index,
|
|
1091
1111
|
bold: true,
|
|
@@ -1218,7 +1238,7 @@ function PermissionPrompt({ toolName, toolDescription, args, preview, canBeTrust
|
|
|
1218
1238
|
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Arguments:"), /* @__PURE__ */ React.createElement(Box, {
|
|
1219
1239
|
paddingLeft: 2,
|
|
1220
1240
|
flexDirection: "column"
|
|
1221
|
-
}, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, argsString))), preview && /* @__PURE__ */ React.createElement(Box, {
|
|
1241
|
+
}, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, escapeTerminalControlChars(argsString)))), preview && /* @__PURE__ */ React.createElement(Box, {
|
|
1222
1242
|
marginTop: 1,
|
|
1223
1243
|
flexDirection: "column"
|
|
1224
1244
|
}, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Preview:"), /* @__PURE__ */ React.createElement(Box, {
|
|
@@ -2656,6 +2676,40 @@ function TrustLocationSelector({ inProject, onSelect, onCancel }) {
|
|
|
2656
2676
|
}), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Use ↑↓ arrows to navigate, Enter to select, Ctrl+C to cancel")));
|
|
2657
2677
|
}
|
|
2658
2678
|
//#endregion
|
|
2679
|
+
//#region src/components/FolderTrustPrompt.tsx
|
|
2680
|
+
/**
|
|
2681
|
+
* One-time startup prompt shown when the current project root ships repo-committed
|
|
2682
|
+
* b4m files (config, .mcp.json, agents, skills, commands) but has not been
|
|
2683
|
+
* trusted. Until trusted, those files are inert - no config merge, no MCP spawn,
|
|
2684
|
+
* no agent/skill/command load. "Trust this folder" persists the decision; "Not
|
|
2685
|
+
* now" keeps the project untrusted for this session (re-prompts next launch).
|
|
2686
|
+
*
|
|
2687
|
+
* "Not now" is listed first so it is the preselected default: Enter-through on a
|
|
2688
|
+
* freshly cloned repo must not grant trust.
|
|
2689
|
+
*/
|
|
2690
|
+
function FolderTrustPrompt({ projectRoot, onSelect }) {
|
|
2691
|
+
return /* @__PURE__ */ React.createElement(Box, {
|
|
2692
|
+
flexDirection: "column",
|
|
2693
|
+
marginY: 1
|
|
2694
|
+
}, /* @__PURE__ */ React.createElement(Box, {
|
|
2695
|
+
marginBottom: 1,
|
|
2696
|
+
flexDirection: "column"
|
|
2697
|
+
}, /* @__PURE__ */ React.createElement(Text, {
|
|
2698
|
+
bold: true,
|
|
2699
|
+
color: "yellow"
|
|
2700
|
+
}, "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, {
|
|
2701
|
+
items: [{
|
|
2702
|
+
label: "Not now - keep repo config inert this session",
|
|
2703
|
+
value: "not-now"
|
|
2704
|
+
}, {
|
|
2705
|
+
label: "Trust this folder - load its config, agents, skills and MCP servers",
|
|
2706
|
+
value: "trust"
|
|
2707
|
+
}],
|
|
2708
|
+
onSelect: (item) => onSelect(item.value),
|
|
2709
|
+
itemComponent: ({ isSelected, label }) => /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: isSelected ? "cyan" : void 0 }, isSelected ? "❯ " : " ", label))
|
|
2710
|
+
}), /* @__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.")));
|
|
2711
|
+
}
|
|
2712
|
+
//#endregion
|
|
2659
2713
|
//#region src/components/RewindSelector.tsx
|
|
2660
2714
|
function RewindSelector({ messages, onSelect, onCancel }) {
|
|
2661
2715
|
const [step, setStep] = useState("selection");
|
|
@@ -2882,6 +2936,22 @@ function EnvironmentPicker({ onSelect }) {
|
|
|
2882
2936
|
}
|
|
2883
2937
|
//#endregion
|
|
2884
2938
|
//#region src/components/LoginFlow.tsx
|
|
2939
|
+
/**
|
|
2940
|
+
* Only https URLs (and http on localhost for dev) may be handed to the OS opener.
|
|
2941
|
+
* The device-flow response is server-controlled, so a hostile/compromised server
|
|
2942
|
+
* could return `file:///...`, `javascript:...` or similar and have the OS launch
|
|
2943
|
+
* it. Reject anything that isn't a plain web URL.
|
|
2944
|
+
*/
|
|
2945
|
+
function isBrowserOpenableUrl(raw) {
|
|
2946
|
+
let url;
|
|
2947
|
+
try {
|
|
2948
|
+
url = new URL(raw);
|
|
2949
|
+
} catch {
|
|
2950
|
+
return false;
|
|
2951
|
+
}
|
|
2952
|
+
if (url.protocol === "https:") return true;
|
|
2953
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
2954
|
+
}
|
|
2885
2955
|
function extractErrorMessage(err) {
|
|
2886
2956
|
if (axios.isAxiosError(err)) {
|
|
2887
2957
|
if (err.response?.data) {
|
|
@@ -2939,9 +3009,10 @@ function LoginFlow({ apiUrl = "http://localhost:3000", configStore, onSuccess, o
|
|
|
2939
3009
|
onError
|
|
2940
3010
|
]);
|
|
2941
3011
|
useEffect(() => {
|
|
2942
|
-
if (deviceFlow && status === "waiting") open(deviceFlow.verification_uri_complete).catch((err) => {
|
|
3012
|
+
if (deviceFlow && status === "waiting") if (isBrowserOpenableUrl(deviceFlow.verification_uri_complete)) open(deviceFlow.verification_uri_complete).catch((err) => {
|
|
2943
3013
|
console.error("Failed to auto-open browser:", err);
|
|
2944
3014
|
});
|
|
3015
|
+
else console.error("Refusing to auto-open untrusted verification URL:", deviceFlow.verification_uri_complete);
|
|
2945
3016
|
}, [deviceFlow, status]);
|
|
2946
3017
|
if (status === "initiating") return /* @__PURE__ */ React.createElement(Box, {
|
|
2947
3018
|
flexDirection: "column",
|
|
@@ -3087,6 +3158,35 @@ var CommandHistoryStore = class {
|
|
|
3087
3158
|
}
|
|
3088
3159
|
};
|
|
3089
3160
|
//#endregion
|
|
3161
|
+
//#region src/storage/buildGlobalConfigPatch.ts
|
|
3162
|
+
/**
|
|
3163
|
+
* Compute the minimal global-config patch for a /config editor save: only the
|
|
3164
|
+
* editor-owned fields (defaultModel, preferences, features) that the user
|
|
3165
|
+
* actually changed vs the current effective (merged) config. Unchanged fields
|
|
3166
|
+
* are omitted, so a repo-injected value the user never touched is never
|
|
3167
|
+
* laundered into the global layer by a save() that spreads the merged config.
|
|
3168
|
+
*
|
|
3169
|
+
* defaultModel and preferences diff per key. features stays whole-object when
|
|
3170
|
+
* anything changed, on purpose: ConfigStore.save() feeds config.features to
|
|
3171
|
+
* mergeFeatures as the caller's COMPLETE desired set (absence of a key means
|
|
3172
|
+
* "removed"), so a per-key diff would make save() delete untouched keys. That
|
|
3173
|
+
* whole-object features path is only latently launderable today (no repo layer
|
|
3174
|
+
* populates features); tightening it needs a coordinated change to save()'s
|
|
3175
|
+
* concurrent-writer merge and is tracked separately.
|
|
3176
|
+
*/
|
|
3177
|
+
function buildGlobalConfigPatch(prev, next) {
|
|
3178
|
+
const patch = {};
|
|
3179
|
+
if (next.defaultModel !== prev?.defaultModel) patch.defaultModel = next.defaultModel;
|
|
3180
|
+
const changedPreferences = {};
|
|
3181
|
+
for (const [key, value] of Object.entries(next.preferences)) {
|
|
3182
|
+
const prevValue = (prev?.preferences)?.[key];
|
|
3183
|
+
if (JSON.stringify(value) !== JSON.stringify(prevValue)) changedPreferences[key] = value;
|
|
3184
|
+
}
|
|
3185
|
+
if (Object.keys(changedPreferences).length > 0) patch.preferences = changedPreferences;
|
|
3186
|
+
if (JSON.stringify(next.features ?? {}) !== JSON.stringify(prev?.features ?? {})) patch.features = next.features;
|
|
3187
|
+
return patch;
|
|
3188
|
+
}
|
|
3189
|
+
//#endregion
|
|
3090
3190
|
//#region src/utils/formatStep.ts
|
|
3091
3191
|
const MAX_INPUT_LENGTH = 500;
|
|
3092
3192
|
/**
|
|
@@ -3476,7 +3576,7 @@ function toStringArray(value) {
|
|
|
3476
3576
|
function extractJsonObject(text) {
|
|
3477
3577
|
const trimmed = text.trim();
|
|
3478
3578
|
if (!trimmed) return null;
|
|
3479
|
-
const fenced = trimmed.match(/```(?:json)
|
|
3579
|
+
const fenced = trimmed.match(/```(?:json)?([\s\S]*?)```/i);
|
|
3480
3580
|
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
3481
3581
|
const start = candidate.indexOf("{");
|
|
3482
3582
|
if (start === -1) return null;
|
|
@@ -6668,6 +6768,168 @@ function createBuiltinModules(config, apiClient) {
|
|
|
6668
6768
|
return modules;
|
|
6669
6769
|
}
|
|
6670
6770
|
//#endregion
|
|
6771
|
+
//#region src/features/bridgePresence/peerOwner.ts
|
|
6772
|
+
const execFileAsync = promisify(execFile);
|
|
6773
|
+
const LSOF_TIMEOUT_MS = 2e3;
|
|
6774
|
+
const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof"];
|
|
6775
|
+
/**
|
|
6776
|
+
* Resolve the OS owner of the process holding the LISTEN socket on
|
|
6777
|
+
* `127.0.0.1:<port>`. This is the only pre-transmission trust signal the CLI
|
|
6778
|
+
* has over loopback TCP: the peer is whoever holds the port, so we confirm it
|
|
6779
|
+
* is the same UID as this process BEFORE handing it the bridge secret.
|
|
6780
|
+
*
|
|
6781
|
+
* Pure platform probe: the per-platform helpers classify every EXPECTED failure
|
|
6782
|
+
* into `no-listener`/`unknown` themselves. An UNEXPECTED throw (a bug) is left to
|
|
6783
|
+
* propagate so the CLI-layer caller can log it and fail closed - this module
|
|
6784
|
+
* deliberately does not depend on the CLI logger.
|
|
6785
|
+
*/
|
|
6786
|
+
async function resolveLoopbackListenerOwner(port) {
|
|
6787
|
+
if (process.platform === "linux") return resolveViaProcNet(port);
|
|
6788
|
+
if (process.platform === "darwin") return resolveViaLsof(port);
|
|
6789
|
+
return { kind: "unknown" };
|
|
6790
|
+
}
|
|
6791
|
+
/**
|
|
6792
|
+
* Whether this platform + process can resolve loopback listener ownership at all:
|
|
6793
|
+
* there must be a `getuid` to compare against (Windows has none) AND an owner probe
|
|
6794
|
+
* for the platform. When false, the caller latches presence off rather than spinning
|
|
6795
|
+
* a retry loop forever on a check that can never pass (a `getuid`-bearing platform
|
|
6796
|
+
* with no probe - BSD/SunOS - would otherwise retry indefinitely).
|
|
6797
|
+
* MUST stay in sync with `resolveLoopbackListenerOwner`'s platform dispatch above:
|
|
6798
|
+
* a platform gains a probe there and a `true` here in the same change.
|
|
6799
|
+
*/
|
|
6800
|
+
function canResolveLoopbackOwner() {
|
|
6801
|
+
if (typeof process.getuid !== "function") return false;
|
|
6802
|
+
return process.platform === "linux" || process.platform === "darwin";
|
|
6803
|
+
}
|
|
6804
|
+
async function resolveViaProcNet(port) {
|
|
6805
|
+
const uids = /* @__PURE__ */ new Set();
|
|
6806
|
+
let readAny = false;
|
|
6807
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
6808
|
+
let text;
|
|
6809
|
+
try {
|
|
6810
|
+
text = await promises.readFile(file, "utf8");
|
|
6811
|
+
} catch {
|
|
6812
|
+
continue;
|
|
6813
|
+
}
|
|
6814
|
+
readAny = true;
|
|
6815
|
+
for (const uid of parseProcNetForUid(text, port)) uids.add(uid);
|
|
6816
|
+
}
|
|
6817
|
+
if (!readAny) return { kind: "unknown" };
|
|
6818
|
+
if (uids.size === 0) return { kind: "no-listener" };
|
|
6819
|
+
return uids.size === 1 ? {
|
|
6820
|
+
kind: "owner",
|
|
6821
|
+
uid: [...uids][0]
|
|
6822
|
+
} : { kind: "unknown" };
|
|
6823
|
+
}
|
|
6824
|
+
/**
|
|
6825
|
+
* `local_address` hex (upper-case) of a socket that receives connections to
|
|
6826
|
+
* `127.0.0.1`: loopback itself, the v4/v6 wildcard binds (a same-UID process
|
|
6827
|
+
* may legitimately hold `0.0.0.0`/`::`), and the v4-mapped-in-v6 forms. A row
|
|
6828
|
+
* on any other IP is a different socket that happens to share the port number
|
|
6829
|
+
* and must NOT be attributed to the loopback peer.
|
|
6830
|
+
*
|
|
6831
|
+
* `/proc/net/tcp6` prints each 32-bit word of the address little-endian (no
|
|
6832
|
+
* `ntohl`), which is why `127.0.0.1` reads `0100007F` and the mapped third word
|
|
6833
|
+
* `00 00 ff ff` reads `FFFF0000`.
|
|
6834
|
+
*/
|
|
6835
|
+
const LOOPBACK_OR_WILDCARD_HEX = /* @__PURE__ */ new Set([
|
|
6836
|
+
"00000000",
|
|
6837
|
+
"0100007F",
|
|
6838
|
+
"00000000000000000000000000000000",
|
|
6839
|
+
"00000000000000000000000001000000",
|
|
6840
|
+
"0000000000000000FFFF000000000000",
|
|
6841
|
+
"0000000000000000FFFF00000100007F"
|
|
6842
|
+
]);
|
|
6843
|
+
/**
|
|
6844
|
+
* Parse a `/proc/net/tcp`(6) dump for the UIDs of LISTEN sockets on the
|
|
6845
|
+
* loopback `port`. Row columns are whitespace-separated: sl(0)
|
|
6846
|
+
* local_address(1) rem_address(2) st(3) tx:rx(4) tr:when(5) retrnsmt(6)
|
|
6847
|
+
* uid(7). `local_address` is `HEXIP:HEXPORT`; `st` `0A` is TCP_LISTEN. The IP
|
|
6848
|
+
* must be loopback or wildcard, else a same-port listener on another interface
|
|
6849
|
+
* would be mis-attributed to the loopback peer. Returns every match so the
|
|
6850
|
+
* caller can reject an ambiguous set (two distinct owners) rather than
|
|
6851
|
+
* silently taking the first.
|
|
6852
|
+
*/
|
|
6853
|
+
function parseProcNetForUid(text, port) {
|
|
6854
|
+
const uids = [];
|
|
6855
|
+
for (const line of text.split("\n")) {
|
|
6856
|
+
const cols = line.trim().split(/\s+/);
|
|
6857
|
+
if (cols.length < 8) continue;
|
|
6858
|
+
const local = cols[1];
|
|
6859
|
+
const colon = local.lastIndexOf(":");
|
|
6860
|
+
if (colon < 0) continue;
|
|
6861
|
+
if (parseInt(local.slice(colon + 1), 16) !== port) continue;
|
|
6862
|
+
if (cols[3] !== "0A") continue;
|
|
6863
|
+
if (!LOOPBACK_OR_WILDCARD_HEX.has(local.slice(0, colon).toUpperCase())) continue;
|
|
6864
|
+
const uid = Number(cols[7]);
|
|
6865
|
+
if (Number.isInteger(uid)) uids.push(uid);
|
|
6866
|
+
}
|
|
6867
|
+
return uids;
|
|
6868
|
+
}
|
|
6869
|
+
async function resolveViaLsof(port) {
|
|
6870
|
+
const lsof = await resolveLsofPath();
|
|
6871
|
+
if (!lsof) return { kind: "unknown" };
|
|
6872
|
+
let stdout;
|
|
6873
|
+
try {
|
|
6874
|
+
({stdout} = await execFileAsync(lsof, [
|
|
6875
|
+
"-nP",
|
|
6876
|
+
`-iTCP:${port}`,
|
|
6877
|
+
"-sTCP:LISTEN",
|
|
6878
|
+
"-Fpun"
|
|
6879
|
+
], { timeout: LSOF_TIMEOUT_MS }));
|
|
6880
|
+
} catch (err) {
|
|
6881
|
+
const e = err;
|
|
6882
|
+
if (!e.killed && e.code === 1 && !e.stdout) return { kind: "no-listener" };
|
|
6883
|
+
if (e.killed || typeof e.stdout !== "string") return { kind: "unknown" };
|
|
6884
|
+
stdout = e.stdout;
|
|
6885
|
+
}
|
|
6886
|
+
const uids = new Set(parseLsofForUid(stdout));
|
|
6887
|
+
if (uids.size === 0) return { kind: "no-listener" };
|
|
6888
|
+
return uids.size === 1 ? {
|
|
6889
|
+
kind: "owner",
|
|
6890
|
+
uid: [...uids][0]
|
|
6891
|
+
} : { kind: "unknown" };
|
|
6892
|
+
}
|
|
6893
|
+
let lsofPathCache;
|
|
6894
|
+
async function resolveLsofPath() {
|
|
6895
|
+
if (lsofPathCache !== void 0) return lsofPathCache;
|
|
6896
|
+
for (const candidate of LSOF_CANDIDATES) try {
|
|
6897
|
+
await promises.access(candidate, constants.X_OK);
|
|
6898
|
+
lsofPathCache = candidate;
|
|
6899
|
+
return candidate;
|
|
6900
|
+
} catch {}
|
|
6901
|
+
lsofPathCache = null;
|
|
6902
|
+
return null;
|
|
6903
|
+
}
|
|
6904
|
+
/** Does an `lsof -nP` numeric socket name (`n` field, host:port) receive
|
|
6905
|
+
* connections to `127.0.0.1`? Loopback, wildcard, or v4-mapped loopback -
|
|
6906
|
+
* exact, to stay symmetric with the linux matcher (the CLI only dials
|
|
6907
|
+
* `127.0.0.1`). */
|
|
6908
|
+
function isLoopbackLsofName(name) {
|
|
6909
|
+
return name.startsWith("127.0.0.1:") || name.startsWith("*:") || name.startsWith("[::1]:") || name.startsWith("[::]:") || name.startsWith("[::ffff:127.0.0.1]:");
|
|
6910
|
+
}
|
|
6911
|
+
/**
|
|
6912
|
+
* Parse `lsof -Fpun` output for the UIDs of loopback listeners. `-F` prints
|
|
6913
|
+
* one field per line prefixed by its type char; `u<uid>` is a process-level
|
|
6914
|
+
* field, `n<host:port>` a file-level one. `uid` resets on each `p<pid>` block
|
|
6915
|
+
* so a block without a `u` field can't inherit the prior block's owner. We
|
|
6916
|
+
* collect the UID for every loopback/wildcard name so the caller can reject an
|
|
6917
|
+
* ambiguous set rather than taking the first.
|
|
6918
|
+
*/
|
|
6919
|
+
function parseLsofForUid(text) {
|
|
6920
|
+
const uids = [];
|
|
6921
|
+
let uid = null;
|
|
6922
|
+
for (const line of text.split("\n")) {
|
|
6923
|
+
const tag = line[0];
|
|
6924
|
+
if (tag === "p") uid = null;
|
|
6925
|
+
else if (tag === "u") {
|
|
6926
|
+
const parsed = Number(line.slice(1));
|
|
6927
|
+
uid = Number.isInteger(parsed) ? parsed : null;
|
|
6928
|
+
} else if (tag === "n" && uid !== null && isLoopbackLsofName(line.slice(1))) uids.push(uid);
|
|
6929
|
+
}
|
|
6930
|
+
return uids;
|
|
6931
|
+
}
|
|
6932
|
+
//#endregion
|
|
6671
6933
|
//#region src/features/bridgePresence/BridgePresence.ts
|
|
6672
6934
|
const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
|
|
6673
6935
|
const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
|
|
@@ -6693,11 +6955,15 @@ var BridgePresence = class {
|
|
|
6693
6955
|
this.callbacks = {};
|
|
6694
6956
|
this.started = false;
|
|
6695
6957
|
this.stopped = false;
|
|
6958
|
+
this.generation = 0;
|
|
6959
|
+
this.trusted = false;
|
|
6960
|
+
this.wsConnectingGen = null;
|
|
6696
6961
|
this.reconnectTimer = null;
|
|
6697
6962
|
this.reconnectAttempts = 0;
|
|
6698
6963
|
this.announceRetryTimer = null;
|
|
6699
6964
|
this.announceAttempts = 0;
|
|
6700
6965
|
this.startOpts = null;
|
|
6966
|
+
this.peerWarned = false;
|
|
6701
6967
|
this.pendingWorkspaceName = null;
|
|
6702
6968
|
this.pendingCapabilities = null;
|
|
6703
6969
|
this.pendingSource = null;
|
|
@@ -6706,6 +6972,19 @@ var BridgePresence = class {
|
|
|
6706
6972
|
setCallbacks(cbs) {
|
|
6707
6973
|
this.callbacks = cbs;
|
|
6708
6974
|
}
|
|
6975
|
+
/** True iff `gen` is still the live connection generation - the single spelling
|
|
6976
|
+
* of the ABA guard applied after every await (see `generation`). */
|
|
6977
|
+
isCurrent(gen) {
|
|
6978
|
+
return gen === this.generation;
|
|
6979
|
+
}
|
|
6980
|
+
/** Test-only: the generation currently holding the connectCommandWs in-flight
|
|
6981
|
+
* latch, or null when free. Exposed to pin the `finally` identity guard - a
|
|
6982
|
+
* stale generation's finally must not clear a live generation's latch (which
|
|
6983
|
+
* would let a second overlapping connect build an untracked socket). Not part
|
|
6984
|
+
* of the runtime contract. */
|
|
6985
|
+
get __wsConnectingGenForTests() {
|
|
6986
|
+
return this.wsConnectingGen;
|
|
6987
|
+
}
|
|
6709
6988
|
/**
|
|
6710
6989
|
* Probe the local bridge and, if present, announce this CLI session.
|
|
6711
6990
|
* Returns true iff the announce succeeded (tavern presence is active).
|
|
@@ -6719,7 +6998,10 @@ var BridgePresence = class {
|
|
|
6719
6998
|
if (this.started) return this.instanceId !== null;
|
|
6720
6999
|
this.stopped = false;
|
|
6721
7000
|
this.started = true;
|
|
7001
|
+
this.generation += 1;
|
|
7002
|
+
const gen = this.generation;
|
|
6722
7003
|
const config = await readBridgeConfig();
|
|
7004
|
+
if (!this.isCurrent(gen)) return false;
|
|
6723
7005
|
if (!config) {
|
|
6724
7006
|
logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
|
|
6725
7007
|
return false;
|
|
@@ -6731,34 +7013,119 @@ var BridgePresence = class {
|
|
|
6731
7013
|
this.pendingSource = opts.source ?? "b4m-cli";
|
|
6732
7014
|
return this.attemptAnnounce();
|
|
6733
7015
|
}
|
|
7016
|
+
/**
|
|
7017
|
+
* Classify the process holding the bridge port relative to this CLI, BEFORE
|
|
7018
|
+
* any secret leaves the process. Over loopback TCP the peer is whoever holds
|
|
7019
|
+
* the port; the secret file is plaintext and per-user, so a same-UID peer is
|
|
7020
|
+
* already trusted (it could read the file directly), and a different-UID (or
|
|
7021
|
+
* sandboxed) peer is the adversary this gate stops.
|
|
7022
|
+
*
|
|
7023
|
+
* Four outcomes, so the caller warns only on a real trust failure (a foreign
|
|
7024
|
+
* owner) and stays quiet on the benign ones:
|
|
7025
|
+
* - `trusted` - same-UID owner; disclosure is safe.
|
|
7026
|
+
* - `absent` - no loopback listener yet (cc-bridge simply not
|
|
7027
|
+
* started); fail-closed but quiet - just retry.
|
|
7028
|
+
* - `foreign` - a different-UID owner holds the port; fail-closed AND
|
|
7029
|
+
* log the security-worded warning (the real adversary).
|
|
7030
|
+
* - `undeterminable` - ownership can't be resolved: lookup tool missing/hung,
|
|
7031
|
+
* a read/spawn error, or an ambiguous owner set.
|
|
7032
|
+
* Fail-closed, but a host that can't run the probe is not
|
|
7033
|
+
* an attacker - log at debug, not as the security warning.
|
|
7034
|
+
*
|
|
7035
|
+
* A platform that can't resolve ownership (see `canResolveLoopbackOwner`) is
|
|
7036
|
+
* latched off earlier, in `attemptAnnounce`, so this method is only reached
|
|
7037
|
+
* where `getuid` exists and the platform has an owner probe.
|
|
7038
|
+
*/
|
|
7039
|
+
async checkPeerTrust() {
|
|
7040
|
+
const port = this.config?.port ?? DEFAULT_PORT;
|
|
7041
|
+
let owner;
|
|
7042
|
+
try {
|
|
7043
|
+
owner = await resolveLoopbackListenerOwner(port);
|
|
7044
|
+
} catch (err) {
|
|
7045
|
+
logger.debug(`[tavern] loopback owner lookup threw: ${err.message}`);
|
|
7046
|
+
return "undeterminable";
|
|
7047
|
+
}
|
|
7048
|
+
if (owner.kind === "no-listener") return "absent";
|
|
7049
|
+
if (owner.kind === "unknown") return "undeterminable";
|
|
7050
|
+
if (owner.uid !== process.getuid()) return "foreign";
|
|
7051
|
+
this.peerWarned = false;
|
|
7052
|
+
return "trusted";
|
|
7053
|
+
}
|
|
7054
|
+
/** Log the foreign-owner security warning once per untrusted state. */
|
|
7055
|
+
warnUntrustedPeerOnce() {
|
|
7056
|
+
if (this.peerWarned) return;
|
|
7057
|
+
this.peerWarned = true;
|
|
7058
|
+
logger.warn("[tavern] bridge port is held by a different user; not disclosing secret or handling commands");
|
|
7059
|
+
}
|
|
6734
7060
|
/** One announce attempt. Schedules a retry on failure; wires up the
|
|
6735
7061
|
* command WS + initial status on success. Idempotent: re-entering after
|
|
6736
7062
|
* a successful announce short-circuits at the instanceId guard. */
|
|
6737
7063
|
async attemptAnnounce() {
|
|
6738
7064
|
if (this.stopped || !this.config || !this.startOpts) return false;
|
|
6739
7065
|
if (this.instanceId) return true;
|
|
7066
|
+
if (!canResolveLoopbackOwner()) {
|
|
7067
|
+
logger.debug("[tavern] ownership unresolvable on this platform; tavern presence disabled");
|
|
7068
|
+
return false;
|
|
7069
|
+
}
|
|
7070
|
+
const gen = this.generation;
|
|
7071
|
+
const startOpts = this.startOpts;
|
|
7072
|
+
if (!await this.gateEgress(gen, "announce")) return false;
|
|
6740
7073
|
const instanceId = v4();
|
|
6741
7074
|
const workspaceName = this.pendingWorkspaceName;
|
|
6742
7075
|
const capabilities = this.pendingCapabilities;
|
|
6743
7076
|
const source = this.pendingSource;
|
|
6744
|
-
|
|
7077
|
+
const announced = await this.announce({
|
|
6745
7078
|
instanceId,
|
|
6746
7079
|
source,
|
|
6747
7080
|
workspaceName,
|
|
6748
|
-
workspacePath:
|
|
7081
|
+
workspacePath: startOpts.workspacePath,
|
|
6749
7082
|
capabilities
|
|
6750
|
-
})
|
|
7083
|
+
});
|
|
7084
|
+
if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
|
|
7085
|
+
if (!announced) {
|
|
7086
|
+
this.trusted = false;
|
|
6751
7087
|
this.scheduleAnnounceRetry();
|
|
6752
7088
|
return false;
|
|
6753
7089
|
}
|
|
6754
7090
|
this.instanceId = instanceId;
|
|
6755
7091
|
this.announceAttempts = 0;
|
|
6756
7092
|
logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
|
|
6757
|
-
this.connectCommandWs();
|
|
7093
|
+
this.connectCommandWs().catch((err) => logger.debug(`[tavern] connectCommandWs threw: ${err.message}`));
|
|
6758
7094
|
this.emitEvent({
|
|
6759
7095
|
type: "status",
|
|
6760
7096
|
status: "idle"
|
|
6761
|
-
});
|
|
7097
|
+
}).catch(() => {});
|
|
7098
|
+
return true;
|
|
7099
|
+
}
|
|
7100
|
+
/** Signal the untrusted condition at the right volume: the security-worded
|
|
7101
|
+
* warning only for a real foreign owner, and a quiet debug line for the
|
|
7102
|
+
* benign cases (bridge not started yet, or ownership undeterminable) so we
|
|
7103
|
+
* don't cry wolf on every backed-off retry. */
|
|
7104
|
+
signalUntrusted(trust, phase) {
|
|
7105
|
+
if (trust === "foreign") this.warnUntrustedPeerOnce();
|
|
7106
|
+
else if (trust === "undeterminable") logger.debug(`[tavern] bridge port owner undeterminable; retrying ${phase}`);
|
|
7107
|
+
else logger.debug(`[tavern] cc-bridge not reachable yet; retrying ${phase}`);
|
|
7108
|
+
}
|
|
7109
|
+
/**
|
|
7110
|
+
* Run the pre-disclosure ownership gate for one egress phase and open or keep
|
|
7111
|
+
* the trust latch closed. The caller passes the connection `generation` it
|
|
7112
|
+
* captured BEFORE this await, so a stop()+start() cycle (or a bare stop())
|
|
7113
|
+
* landing during the probe is detected and this stale continuation bails.
|
|
7114
|
+
* Returns true iff the gate is open (secret disclosure is now permitted for
|
|
7115
|
+
* this generation); false means the caller must return - either the owner is
|
|
7116
|
+
* untrusted (a retry has been scheduled) or the generation moved.
|
|
7117
|
+
*/
|
|
7118
|
+
async gateEgress(gen, phase) {
|
|
7119
|
+
const trust = await this.checkPeerTrust();
|
|
7120
|
+
if (!this.isCurrent(gen) || this.stopped || !this.config) return false;
|
|
7121
|
+
if (trust !== "trusted") {
|
|
7122
|
+
this.trusted = false;
|
|
7123
|
+
this.signalUntrusted(trust, phase);
|
|
7124
|
+
if (phase === "announce") this.scheduleAnnounceRetry();
|
|
7125
|
+
else this.scheduleReconnect();
|
|
7126
|
+
return false;
|
|
7127
|
+
}
|
|
7128
|
+
this.trusted = true;
|
|
6762
7129
|
return true;
|
|
6763
7130
|
}
|
|
6764
7131
|
scheduleAnnounceRetry() {
|
|
@@ -6767,17 +7134,22 @@ var BridgePresence = class {
|
|
|
6767
7134
|
const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
|
|
6768
7135
|
this.announceRetryTimer = setTimeout(() => {
|
|
6769
7136
|
this.announceRetryTimer = null;
|
|
6770
|
-
this.attemptAnnounce();
|
|
7137
|
+
this.attemptAnnounce().catch((err) => logger.debug(`[tavern] announce retry threw: ${err.message}`));
|
|
6771
7138
|
}, delay);
|
|
6772
7139
|
}
|
|
6773
7140
|
/** Emit an event for this session. No-op if the bridge isn't up. Events
|
|
6774
7141
|
* leave in strict order - see `emitQueue` comment. */
|
|
6775
7142
|
async emitEvent(event) {
|
|
6776
7143
|
if (!this.config || !this.instanceId) return;
|
|
6777
|
-
const
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
7144
|
+
const instanceId = this.instanceId;
|
|
7145
|
+
const gen = this.generation;
|
|
7146
|
+
const task = () => {
|
|
7147
|
+
if (!this.isCurrent(gen)) return;
|
|
7148
|
+
return this.post("/event", {
|
|
7149
|
+
instanceId,
|
|
7150
|
+
event
|
|
7151
|
+
}).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
|
|
7152
|
+
};
|
|
6781
7153
|
this.emitQueue = this.emitQueue.then(task, task);
|
|
6782
7154
|
return this.emitQueue;
|
|
6783
7155
|
}
|
|
@@ -6795,6 +7167,7 @@ var BridgePresence = class {
|
|
|
6795
7167
|
async stop(reason = "cli_exit") {
|
|
6796
7168
|
if (this.stopped || !this.started) return;
|
|
6797
7169
|
this.stopped = true;
|
|
7170
|
+
this.generation += 1;
|
|
6798
7171
|
if (this.reconnectTimer) {
|
|
6799
7172
|
clearTimeout(this.reconnectTimer);
|
|
6800
7173
|
this.reconnectTimer = null;
|
|
@@ -6803,13 +7176,14 @@ var BridgePresence = class {
|
|
|
6803
7176
|
clearTimeout(this.announceRetryTimer);
|
|
6804
7177
|
this.announceRetryTimer = null;
|
|
6805
7178
|
}
|
|
7179
|
+
const wsWasLive = this.ws !== null;
|
|
6806
7180
|
if (this.ws) {
|
|
6807
7181
|
try {
|
|
6808
7182
|
this.ws.close();
|
|
6809
7183
|
} catch {}
|
|
6810
7184
|
this.ws = null;
|
|
6811
7185
|
}
|
|
6812
|
-
if (this.config && this.instanceId) await this.post("/disconnect", {
|
|
7186
|
+
if (this.config && this.instanceId && wsWasLive) await this.post("/disconnect", {
|
|
6813
7187
|
instanceId: this.instanceId,
|
|
6814
7188
|
reason
|
|
6815
7189
|
}).catch(() => {});
|
|
@@ -6822,6 +7196,8 @@ var BridgePresence = class {
|
|
|
6822
7196
|
this.pendingSource = null;
|
|
6823
7197
|
this.announceAttempts = 0;
|
|
6824
7198
|
this.reconnectAttempts = 0;
|
|
7199
|
+
this.peerWarned = false;
|
|
7200
|
+
this.trusted = false;
|
|
6825
7201
|
this.emitQueue = Promise.resolve();
|
|
6826
7202
|
}
|
|
6827
7203
|
async announce(body) {
|
|
@@ -6835,6 +7211,7 @@ var BridgePresence = class {
|
|
|
6835
7211
|
}
|
|
6836
7212
|
async post(path, body) {
|
|
6837
7213
|
if (!this.config) throw new Error("bridge config not loaded");
|
|
7214
|
+
if (!this.trusted) throw new Error("bridge peer not trusted; refusing to disclose secret");
|
|
6838
7215
|
const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
|
|
6839
7216
|
const res = await fetch(url, {
|
|
6840
7217
|
method: "POST",
|
|
@@ -6844,42 +7221,58 @@ var BridgePresence = class {
|
|
|
6844
7221
|
});
|
|
6845
7222
|
if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
|
|
6846
7223
|
}
|
|
6847
|
-
connectCommandWs() {
|
|
7224
|
+
async connectCommandWs() {
|
|
6848
7225
|
if (this.stopped || !this.config || !this.instanceId) return;
|
|
6849
|
-
const
|
|
6850
|
-
|
|
7226
|
+
const gen = this.generation;
|
|
7227
|
+
if (this.ws || this.wsConnectingGen === gen) return;
|
|
7228
|
+
this.wsConnectingGen = gen;
|
|
6851
7229
|
try {
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
this.ws = ws;
|
|
6859
|
-
ws.on("open", () => {
|
|
6860
|
-
this.reconnectAttempts = 0;
|
|
6861
|
-
logger.debug("[tavern] command WS open");
|
|
6862
|
-
});
|
|
6863
|
-
ws.on("message", (raw) => {
|
|
6864
|
-
let frame = null;
|
|
7230
|
+
if (!await this.gateEgress(gen, "command WS")) return;
|
|
7231
|
+
const config = this.config;
|
|
7232
|
+
const instanceId = this.instanceId;
|
|
7233
|
+
if (!config || !instanceId) return;
|
|
7234
|
+
const url = `ws://127.0.0.1:${config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(instanceId)}&secret=${encodeURIComponent(config.hookSecret)}`;
|
|
7235
|
+
let ws;
|
|
6865
7236
|
try {
|
|
6866
|
-
|
|
6867
|
-
} catch {
|
|
6868
|
-
logger.debug(
|
|
7237
|
+
ws = new WebSocket(url);
|
|
7238
|
+
} catch (err) {
|
|
7239
|
+
logger.debug(`[tavern] command WS construct failed: ${err.message}`);
|
|
7240
|
+
this.trusted = false;
|
|
7241
|
+
this.scheduleReconnect();
|
|
6869
7242
|
return;
|
|
6870
7243
|
}
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
7244
|
+
this.ws = ws;
|
|
7245
|
+
ws.on("open", () => {
|
|
7246
|
+
if (this.ws !== ws) return;
|
|
7247
|
+
this.reconnectAttempts = 0;
|
|
7248
|
+
logger.debug("[tavern] command WS open");
|
|
7249
|
+
});
|
|
7250
|
+
ws.on("message", (raw) => {
|
|
7251
|
+
if (this.ws !== ws) return;
|
|
7252
|
+
let frame = null;
|
|
7253
|
+
try {
|
|
7254
|
+
frame = JSON.parse(raw.toString());
|
|
7255
|
+
} catch {
|
|
7256
|
+
logger.debug("[tavern] malformed command frame; ignored");
|
|
7257
|
+
return;
|
|
7258
|
+
}
|
|
7259
|
+
if (!frame?.command) return;
|
|
7260
|
+
this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
|
|
7261
|
+
});
|
|
7262
|
+
ws.on("close", () => {
|
|
7263
|
+
if (this.ws !== ws) return;
|
|
7264
|
+
this.ws = null;
|
|
7265
|
+
this.trusted = false;
|
|
7266
|
+
if (this.stopped) return;
|
|
7267
|
+
logger.debug("[tavern] command WS closed; reconnecting");
|
|
7268
|
+
this.scheduleReconnect();
|
|
7269
|
+
});
|
|
7270
|
+
ws.on("error", (err) => {
|
|
7271
|
+
logger.debug(`[tavern] command WS error: ${err.message}`);
|
|
7272
|
+
});
|
|
7273
|
+
} finally {
|
|
7274
|
+
if (this.wsConnectingGen === gen) this.wsConnectingGen = null;
|
|
7275
|
+
}
|
|
6883
7276
|
}
|
|
6884
7277
|
scheduleReconnect() {
|
|
6885
7278
|
if (this.stopped || this.reconnectTimer) return;
|
|
@@ -6887,10 +7280,11 @@ var BridgePresence = class {
|
|
|
6887
7280
|
const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
|
|
6888
7281
|
this.reconnectTimer = setTimeout(() => {
|
|
6889
7282
|
this.reconnectTimer = null;
|
|
6890
|
-
this.connectCommandWs();
|
|
7283
|
+
this.connectCommandWs().catch((err) => logger.debug(`[tavern] command WS reconnect threw: ${err.message}`));
|
|
6891
7284
|
}, delay);
|
|
6892
7285
|
}
|
|
6893
7286
|
async dispatchCommand(command) {
|
|
7287
|
+
if (this.stopped) return;
|
|
6894
7288
|
switch (command.type) {
|
|
6895
7289
|
case "send_prompt":
|
|
6896
7290
|
if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
|
|
@@ -7040,7 +7434,7 @@ const infoCommands = [
|
|
|
7040
7434
|
{
|
|
7041
7435
|
name: "help",
|
|
7042
7436
|
run: (_args, ctx) => {
|
|
7043
|
-
const customCommands = ctx.customCommandStore.
|
|
7437
|
+
const customCommands = ctx.customCommandStore.getModelReachableCommands();
|
|
7044
7438
|
const hasCustomCommands = customCommands.length > 0;
|
|
7045
7439
|
console.log(`
|
|
7046
7440
|
Available commands:
|
|
@@ -7069,7 +7463,9 @@ API Configuration:
|
|
|
7069
7463
|
Tool Permissions:
|
|
7070
7464
|
/trust <tool-name> - Trust a tool (won't ask permission again)
|
|
7071
7465
|
/untrust <tool-name> - Remove tool from trusted list
|
|
7072
|
-
/
|
|
7466
|
+
/trust folder - Trust this project (load its repo config, agents, skills, MCP)
|
|
7467
|
+
/untrust folder - Revoke trust for this project (repo config goes inert)
|
|
7468
|
+
/trusted - Show folder-trust status and list all trusted tools
|
|
7073
7469
|
|
|
7074
7470
|
Project Configuration:
|
|
7075
7471
|
/project-config - Show merged project configuration
|
|
@@ -7131,8 +7527,16 @@ Multi-line Input:
|
|
|
7131
7527
|
{
|
|
7132
7528
|
name: "trusted",
|
|
7133
7529
|
run: (_args, ctx) => {
|
|
7530
|
+
const projectRoot = ctx.configStore.getProjectRealPath();
|
|
7531
|
+
console.log("\n📁 Folder Trust:\n");
|
|
7532
|
+
if (!projectRoot) console.log(" No project root discovered.");
|
|
7533
|
+
else {
|
|
7534
|
+
const isTrusted = ctx.configStore.isProjectTrusted();
|
|
7535
|
+
console.log(` ${isTrusted ? "✅ Trusted" : "⛔ Not trusted"}: ${projectRoot}`);
|
|
7536
|
+
if (!isTrusted) console.log(" Repo config/agents/skills/MCP stay inert. Trust with /trust folder.");
|
|
7537
|
+
}
|
|
7134
7538
|
if (!ctx.permissionManager) {
|
|
7135
|
-
console.log("
|
|
7539
|
+
console.log("\nPermission manager not initialized\n");
|
|
7136
7540
|
return;
|
|
7137
7541
|
}
|
|
7138
7542
|
const trustedTools = ctx.permissionManager.getTrustedTools();
|
|
@@ -7221,6 +7625,48 @@ async function dispatch(name, args, ctx) {
|
|
|
7221
7625
|
return true;
|
|
7222
7626
|
}
|
|
7223
7627
|
//#endregion
|
|
7628
|
+
//#region src/commands/sandboxNetworkCommand.ts
|
|
7629
|
+
async function handleSandboxNetworkCommand(deps, arg) {
|
|
7630
|
+
const { orchestrator, configStore, permissionManager } = deps;
|
|
7631
|
+
if (!orchestrator) return "Sandbox not initialized";
|
|
7632
|
+
if (arg !== "on" && arg !== "off") return "Usage: /sandbox:network <on|off>";
|
|
7633
|
+
const enable = arg === "on";
|
|
7634
|
+
if (enable && !orchestrator.isAvailable()) return "Sandbox runtime not available on this platform";
|
|
7635
|
+
const active = await orchestrator.setNetworkEnabled(enable);
|
|
7636
|
+
if (enable && !active) {
|
|
7637
|
+
permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
|
|
7638
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
7639
|
+
return "Failed to start the network proxy; network stays disabled (fail-closed).";
|
|
7640
|
+
}
|
|
7641
|
+
permissionManager?.setSandboxState(orchestrator.getMode(), orchestrator.isActive());
|
|
7642
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
7643
|
+
if (!enable) return "Sandbox network disabled (fail-closed)";
|
|
7644
|
+
const port = orchestrator.getProxyManager()?.getPort();
|
|
7645
|
+
return `Sandbox network egress enabled${port ? ` (proxy-aware clients filtered on port ${port})` : ""}`;
|
|
7646
|
+
}
|
|
7647
|
+
//#endregion
|
|
7648
|
+
//#region src/commands/sandboxTrustDomainCommand.ts
|
|
7649
|
+
async function handleSandboxTrustDomainCommand(deps, domains) {
|
|
7650
|
+
const { orchestrator, configStore } = deps;
|
|
7651
|
+
if (!orchestrator) return "Sandbox not initialized";
|
|
7652
|
+
const clean = domains.map((d) => d.trim()).filter(Boolean);
|
|
7653
|
+
if (clean.length === 0) return "Usage: /sandbox:trust-domain <domain> [...]";
|
|
7654
|
+
if (!orchestrator.getProxyManager()) return "Network proxy not initialized";
|
|
7655
|
+
const lines = [];
|
|
7656
|
+
for (const domain of clean) {
|
|
7657
|
+
orchestrator.addAllowedDomain(domain);
|
|
7658
|
+
lines.push(` Added: ${domain}`);
|
|
7659
|
+
}
|
|
7660
|
+
try {
|
|
7661
|
+
await configStore.saveSandboxConfig(orchestrator.getConfig());
|
|
7662
|
+
} catch (err) {
|
|
7663
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7664
|
+
return `${lines.join("\n")}\nWarning: failed to persist the trusted domains (${msg}); they apply for this session only.`;
|
|
7665
|
+
}
|
|
7666
|
+
lines.push(`Trusted ${clean.length} domain(s)`);
|
|
7667
|
+
return lines.join("\n");
|
|
7668
|
+
}
|
|
7669
|
+
//#endregion
|
|
7224
7670
|
//#region src/session/turnController.ts
|
|
7225
7671
|
/**
|
|
7226
7672
|
* Turn lifecycle (issue #228, phase 2).
|
|
@@ -7265,7 +7711,7 @@ async function runTurn(message, ctx) {
|
|
|
7265
7711
|
const systemPrompt = buildSystemPrompt(config?.preferences.promptVariant ?? "current", {
|
|
7266
7712
|
contextContent,
|
|
7267
7713
|
agentStore: agentStore || void 0,
|
|
7268
|
-
customCommands: customCommandStore.
|
|
7714
|
+
customCommands: customCommandStore.getModelReachableCommands(),
|
|
7269
7715
|
enableSkillTool: config?.preferences.enableSkillTool !== false,
|
|
7270
7716
|
additionalDirectories,
|
|
7271
7717
|
featureModulePrompts: featureRegistry?.getSystemPromptSections() || void 0,
|
|
@@ -7753,6 +8199,7 @@ function CliApp() {
|
|
|
7753
8199
|
permissionManager: null,
|
|
7754
8200
|
permissionPrompt: null,
|
|
7755
8201
|
trustLocationSelector: null,
|
|
8202
|
+
folderTrustPrompt: null,
|
|
7756
8203
|
rewindSelector: null,
|
|
7757
8204
|
sessionSelector: null,
|
|
7758
8205
|
orchestrator: null,
|
|
@@ -7771,6 +8218,7 @@ function CliApp() {
|
|
|
7771
8218
|
const [initError, setInitError] = useState(null);
|
|
7772
8219
|
const [commandHistory, setCommandHistory] = useState([]);
|
|
7773
8220
|
const imageStoreInitPromise = useRef(null);
|
|
8221
|
+
const folderTrustResolvedRef = useRef(false);
|
|
7774
8222
|
const todoStoreRef = useRef(createTodoStore());
|
|
7775
8223
|
const decisionStoreRef = useRef(createDecisionStore());
|
|
7776
8224
|
const blockerStoreRef = useRef(createBlockerStore());
|
|
@@ -7877,11 +8325,21 @@ function CliApp() {
|
|
|
7877
8325
|
try {
|
|
7878
8326
|
const startupLog = [];
|
|
7879
8327
|
const config = await state.configStore.load();
|
|
8328
|
+
if (!folderTrustResolvedRef.current && !state.configStore.isProjectTrusted() && state.configStore.projectHasB4mFiles() && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
|
|
8329
|
+
folderTrustResolvedRef.current = true;
|
|
8330
|
+
setState((prev) => ({
|
|
8331
|
+
...prev,
|
|
8332
|
+
folderTrustPrompt: { projectRoot: state.configStore.getProjectRealPath() ?? process.cwd() },
|
|
8333
|
+
config
|
|
8334
|
+
}));
|
|
8335
|
+
return;
|
|
8336
|
+
}
|
|
7880
8337
|
const configDirs = await state.configStore.getAdditionalDirectories();
|
|
7881
8338
|
const flagDirs = process.env.B4M_ADDITIONAL_DIRS ? JSON.parse(process.env.B4M_ADDITIONAL_DIRS) : [];
|
|
7882
8339
|
const additionalDirectories = [.../* @__PURE__ */ new Set([...configDirs, ...flagDirs])];
|
|
7883
8340
|
const history = await state.commandHistoryStore.load();
|
|
7884
8341
|
setCommandHistory(history);
|
|
8342
|
+
state.customCommandStore.setProjectTrusted(state.configStore.isProjectTrusted());
|
|
7885
8343
|
try {
|
|
7886
8344
|
await state.customCommandStore.loadCommands();
|
|
7887
8345
|
} catch (error) {
|
|
@@ -7938,6 +8396,14 @@ function CliApp() {
|
|
|
7938
8396
|
});
|
|
7939
8397
|
const pinnedSessionId = process.env.B4M_SESSION_ID;
|
|
7940
8398
|
const resumeSessionId = process.env.B4M_RESUME_ID;
|
|
8399
|
+
if (pinnedSessionId && !isValidSessionId(pinnedSessionId)) {
|
|
8400
|
+
console.error(`Invalid session id (--session-id / B4M_SESSION_ID): must match ${SESSION_ID_PATTERN.source}`);
|
|
8401
|
+
process.exit(1);
|
|
8402
|
+
}
|
|
8403
|
+
if (resumeSessionId && !isValidSessionId(resumeSessionId)) {
|
|
8404
|
+
console.error(`Invalid session id (--resume / B4M_RESUME_ID): must match ${SESSION_ID_PATTERN.source}`);
|
|
8405
|
+
process.exit(1);
|
|
8406
|
+
}
|
|
7941
8407
|
let newSession;
|
|
7942
8408
|
if (resumeSessionId) {
|
|
7943
8409
|
const resumed = await state.sessionStore.load(resumeSessionId);
|
|
@@ -8108,14 +8574,28 @@ function CliApp() {
|
|
|
8108
8574
|
blockerStoreRef.current.blockers = [];
|
|
8109
8575
|
reviewGateStoreRef.current.reviewGates = [];
|
|
8110
8576
|
}
|
|
8577
|
+
const cliWrapDeps = {
|
|
8578
|
+
permissionManager,
|
|
8579
|
+
showPermissionPrompt: promptFn,
|
|
8580
|
+
agentContext,
|
|
8581
|
+
configStore: state.configStore,
|
|
8582
|
+
apiClient,
|
|
8583
|
+
sandboxOrchestrator: sandboxOrchestrator ?? void 0,
|
|
8584
|
+
allowedDirectories: additionalDirectories
|
|
8585
|
+
};
|
|
8111
8586
|
const enableSkillTool = config.preferences.enableSkillTool !== false;
|
|
8112
|
-
const skillTool = enableSkillTool ? createSkillTool({
|
|
8587
|
+
const skillTool = enableSkillTool ? wrapTools([createSkillTool({
|
|
8113
8588
|
customCommandStore: state.customCommandStore,
|
|
8114
8589
|
subagentOrchestrator: orchestrator,
|
|
8115
|
-
sessionId: newSession.id
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8590
|
+
sessionId: newSession.id,
|
|
8591
|
+
permission: {
|
|
8592
|
+
permissionManager,
|
|
8593
|
+
promptFn
|
|
8594
|
+
},
|
|
8595
|
+
allowedDirectories: additionalDirectories
|
|
8596
|
+
})], cliWrapDeps)[0] : null;
|
|
8597
|
+
const findDefinitionTool = wrapTools([createFindDefinitionTool(additionalDirectories)], cliWrapDeps)[0];
|
|
8598
|
+
const getFileStructureTool = wrapTools([createGetFileStructureTool(additionalDirectories)], cliWrapDeps)[0];
|
|
8119
8599
|
const workItemTools = config.preferences.enableWorkItemTools ? createWorkItemTools(new WorkItemsClient(apiClient)) : [];
|
|
8120
8600
|
const cliTools = [
|
|
8121
8601
|
agentDelegateTool,
|
|
@@ -8143,6 +8623,7 @@ function CliApp() {
|
|
|
8143
8623
|
logger,
|
|
8144
8624
|
reservedToolNames: [...loadedB4mTools, ...cliTools].map((t) => t.toolSchema.name).concat("tool_search")
|
|
8145
8625
|
});
|
|
8626
|
+
rewireReservedNames(state, featureRegistry);
|
|
8146
8627
|
const featureModuleToolNames = featureRegistry.getAllToolNames();
|
|
8147
8628
|
if (featureModuleToolNames.length > 0) registerFeatureModuleTools(featureModuleToolNames);
|
|
8148
8629
|
if (wsManager && featureRegistry.hasModules) featureRegistry.registerAllWsHandlers(wsManager);
|
|
@@ -8985,7 +9466,7 @@ function CliApp() {
|
|
|
8985
9466
|
console.log(fellBack ? "✅ Session saved with local fallback handoff" : "✅ Session saved with refreshed handoff");
|
|
8986
9467
|
};
|
|
8987
9468
|
const handleCommand = async (command, args) => {
|
|
8988
|
-
const customCommand = state.customCommandStore.
|
|
9469
|
+
const customCommand = state.customCommandStore.getModelReachableCommand(command);
|
|
8989
9470
|
if (customCommand) try {
|
|
8990
9471
|
const sourceIcon = customCommand.source === "global" ? "🏠" : "📁";
|
|
8991
9472
|
console.log(`${sourceIcon} Executing custom command: /${command}`);
|
|
@@ -9142,6 +9623,18 @@ function CliApp() {
|
|
|
9142
9623
|
break;
|
|
9143
9624
|
}
|
|
9144
9625
|
case "trust": {
|
|
9626
|
+
if (args[0] === "folder") {
|
|
9627
|
+
const root = state.configStore.getProjectRealPath() ?? process.cwd();
|
|
9628
|
+
if (!await state.configStore.trustProject()) {
|
|
9629
|
+
console.log("\n⚠️ No project folder to trust here (no resolvable project root).\n");
|
|
9630
|
+
return;
|
|
9631
|
+
}
|
|
9632
|
+
state.customCommandStore.setProjectTrusted(true);
|
|
9633
|
+
await state.customCommandStore.reloadCommands().catch(() => {});
|
|
9634
|
+
console.log(`\n✅ Trusted project folder: ${root}`);
|
|
9635
|
+
console.log("Project skills are now loaded. Restart b4m to load repo agents and MCP servers.\n");
|
|
9636
|
+
return;
|
|
9637
|
+
}
|
|
9145
9638
|
if (!state.permissionManager) {
|
|
9146
9639
|
console.log("Permission manager not initialized");
|
|
9147
9640
|
return;
|
|
@@ -9177,6 +9670,11 @@ function CliApp() {
|
|
|
9177
9670
|
console.log("❌ No project found. Use \"global\" to save to ~/.bike4mind/config.json");
|
|
9178
9671
|
return;
|
|
9179
9672
|
}
|
|
9673
|
+
if (!state.configStore.isProjectTrusted()) {
|
|
9674
|
+
console.log("❌ This folder is not trusted, so a project-local trust would not take effect.");
|
|
9675
|
+
console.log(` Run /trust folder first, or choose "global" to trust '${toolToTrust}' everywhere.`);
|
|
9676
|
+
return;
|
|
9677
|
+
}
|
|
9180
9678
|
try {
|
|
9181
9679
|
await state.configStore.initProjectConfig();
|
|
9182
9680
|
const existingLocal = await state.configStore.loadRawProjectLocalConfig() || {};
|
|
@@ -9197,6 +9695,11 @@ function CliApp() {
|
|
|
9197
9695
|
console.log("❌ No project found. Use \"global\" to save to ~/.bike4mind/config.json");
|
|
9198
9696
|
return;
|
|
9199
9697
|
}
|
|
9698
|
+
if (!state.configStore.isProjectTrusted()) {
|
|
9699
|
+
console.log("❌ This folder is not trusted, so a team-project trust would not take effect.");
|
|
9700
|
+
console.log(` Run /trust folder first, or choose "global" to trust '${toolToTrust}' everywhere.`);
|
|
9701
|
+
return;
|
|
9702
|
+
}
|
|
9200
9703
|
try {
|
|
9201
9704
|
const existingProject = await state.configStore.loadRawProjectConfig() || {};
|
|
9202
9705
|
const updatedProject = {
|
|
@@ -9235,6 +9738,15 @@ function CliApp() {
|
|
|
9235
9738
|
break;
|
|
9236
9739
|
}
|
|
9237
9740
|
case "untrust": {
|
|
9741
|
+
if (args[0] === "folder") {
|
|
9742
|
+
const root = state.configStore.getProjectRealPath() ?? process.cwd();
|
|
9743
|
+
await state.configStore.untrustProject();
|
|
9744
|
+
state.customCommandStore.setProjectTrusted(false);
|
|
9745
|
+
await state.customCommandStore.reloadCommands().catch(() => {});
|
|
9746
|
+
console.log(`\n✅ Untrusted project folder: ${root}`);
|
|
9747
|
+
console.log("Repo config stays inert. Restart b4m to fully unload repo agents and MCP servers.\n");
|
|
9748
|
+
return;
|
|
9749
|
+
}
|
|
9238
9750
|
if (!state.permissionManager) {
|
|
9239
9751
|
console.log("Permission manager not initialized");
|
|
9240
9752
|
return;
|
|
@@ -9506,7 +10018,7 @@ function CliApp() {
|
|
|
9506
10018
|
const variantForCount = state.config?.preferences.promptVariant ?? "current";
|
|
9507
10019
|
const corePromptTokens = tokenCounter.countTokens(buildSystemPrompt(variantForCount));
|
|
9508
10020
|
const projectContextTokens = state.contextContent ? tokenCounter.countTokens(state.contextContent) : 0;
|
|
9509
|
-
const commands = state.customCommandStore.
|
|
10021
|
+
const commands = state.customCommandStore.getModelReachableCommands();
|
|
9510
10022
|
const skillsSection = buildSkillsPromptSection(commands);
|
|
9511
10023
|
const skillsTokens = skillsSection ? tokenCounter.countTokens(skillsSection) : 0;
|
|
9512
10024
|
const agentDirectoryTokens = state.agentStore ? tokenCounter.countTokens(state.agentStore.getDirectoryContext()) : 0;
|
|
@@ -9568,6 +10080,7 @@ function CliApp() {
|
|
|
9568
10080
|
console.log("\n📁 Project Configuration:\n");
|
|
9569
10081
|
if (projectDir) {
|
|
9570
10082
|
console.log(`Project Directory: ${projectDir}/.bike4mind/`);
|
|
10083
|
+
if (!state.configStore.isProjectTrusted()) console.log("⚠️ Folder not trusted - team/local config below is NOT applied. Run /trust folder.");
|
|
9571
10084
|
console.log("");
|
|
9572
10085
|
const projectConfig = await state.configStore.loadRawProjectConfig();
|
|
9573
10086
|
const localConfig = await state.configStore.loadRawProjectLocalConfig();
|
|
@@ -9597,9 +10110,9 @@ function CliApp() {
|
|
|
9597
10110
|
break;
|
|
9598
10111
|
}
|
|
9599
10112
|
case "commands": {
|
|
9600
|
-
const customCommands = state.customCommandStore.
|
|
9601
|
-
const globalCommands =
|
|
9602
|
-
const projectCommands =
|
|
10113
|
+
const customCommands = state.customCommandStore.getModelReachableCommands();
|
|
10114
|
+
const globalCommands = customCommands.filter((cmd) => cmd.source === "global");
|
|
10115
|
+
const projectCommands = customCommands.filter((cmd) => cmd.source === "project");
|
|
9603
10116
|
console.log("\n📝 Custom Commands:\n");
|
|
9604
10117
|
if (customCommands.length === 0) {
|
|
9605
10118
|
console.log("No custom commands found.");
|
|
@@ -9769,7 +10282,7 @@ function CliApp() {
|
|
|
9769
10282
|
}
|
|
9770
10283
|
break;
|
|
9771
10284
|
}
|
|
9772
|
-
case "sandbox:enable":
|
|
10285
|
+
case "sandbox:enable":
|
|
9773
10286
|
if (!state.sandboxOrchestrator) {
|
|
9774
10287
|
console.log("Sandbox not initialized");
|
|
9775
10288
|
break;
|
|
@@ -9781,34 +10294,25 @@ function CliApp() {
|
|
|
9781
10294
|
state.sandboxOrchestrator.setMode("auto-allow");
|
|
9782
10295
|
state.permissionManager?.setSandboxState("auto-allow", state.sandboxOrchestrator.isActive());
|
|
9783
10296
|
if (state.sandboxOrchestrator.getConfig().network.enabled) {
|
|
9784
|
-
await state.sandboxOrchestrator.
|
|
10297
|
+
const on = await state.sandboxOrchestrator.setNetworkEnabled(true);
|
|
9785
10298
|
const pm = state.sandboxOrchestrator.getProxyManager();
|
|
9786
|
-
if (pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
|
|
10299
|
+
if (on && pm?.isRunning()) console.log(`🌐 Network proxy started on port ${pm.getPort()}`);
|
|
10300
|
+
else console.log("⚠️ Network filtering requested but the proxy failed to start; egress disabled");
|
|
9787
10301
|
}
|
|
9788
|
-
|
|
9789
|
-
await state.configStore.save({
|
|
9790
|
-
...config,
|
|
9791
|
-
sandbox: { ...state.sandboxOrchestrator.getConfig() }
|
|
9792
|
-
});
|
|
10302
|
+
await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
|
|
9793
10303
|
console.log("Sandbox enabled (auto-allow mode)");
|
|
9794
10304
|
break;
|
|
9795
|
-
|
|
9796
|
-
case "sandbox:disable": {
|
|
10305
|
+
case "sandbox:disable":
|
|
9797
10306
|
if (!state.sandboxOrchestrator) {
|
|
9798
10307
|
console.log("Sandbox not initialized");
|
|
9799
10308
|
break;
|
|
9800
10309
|
}
|
|
9801
|
-
await state.sandboxOrchestrator.
|
|
10310
|
+
await state.sandboxOrchestrator.setNetworkEnabled(false);
|
|
9802
10311
|
state.sandboxOrchestrator.setMode("disabled");
|
|
9803
10312
|
state.permissionManager?.setSandboxState("disabled", false);
|
|
9804
|
-
|
|
9805
|
-
await state.configStore.save({
|
|
9806
|
-
...disableConfig,
|
|
9807
|
-
sandbox: { ...state.sandboxOrchestrator.getConfig() }
|
|
9808
|
-
});
|
|
10313
|
+
await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
|
|
9809
10314
|
console.log("Sandbox disabled");
|
|
9810
10315
|
break;
|
|
9811
|
-
}
|
|
9812
10316
|
case "sandbox:mode": {
|
|
9813
10317
|
if (!state.sandboxOrchestrator) {
|
|
9814
10318
|
console.log("Sandbox not initialized");
|
|
@@ -9825,47 +10329,23 @@ function CliApp() {
|
|
|
9825
10329
|
}
|
|
9826
10330
|
state.sandboxOrchestrator.setMode(modeArg);
|
|
9827
10331
|
state.permissionManager?.setSandboxState(modeArg, state.sandboxOrchestrator.isActive());
|
|
9828
|
-
|
|
9829
|
-
await state.configStore.save({
|
|
9830
|
-
...modeConfig,
|
|
9831
|
-
sandbox: { ...state.sandboxOrchestrator.getConfig() }
|
|
9832
|
-
});
|
|
10332
|
+
await state.configStore.saveSandboxConfig(state.sandboxOrchestrator.getConfig());
|
|
9833
10333
|
console.log(`Sandbox mode set to: ${modeArg}`);
|
|
9834
10334
|
break;
|
|
9835
10335
|
}
|
|
9836
|
-
case "sandbox:
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
9843
|
-
|
|
9844
|
-
|
|
9845
|
-
|
|
9846
|
-
|
|
9847
|
-
|
|
9848
|
-
break;
|
|
9849
|
-
}
|
|
9850
|
-
for (const domain of args) {
|
|
9851
|
-
proxyMgr.addAllowedDomain(domain);
|
|
9852
|
-
console.log(` Added: ${domain}`);
|
|
9853
|
-
}
|
|
9854
|
-
const trustDomainConfig = await state.configStore.get();
|
|
9855
|
-
const currentSandboxConfig = state.sandboxOrchestrator.getConfig();
|
|
9856
|
-
await state.configStore.save({
|
|
9857
|
-
...trustDomainConfig,
|
|
9858
|
-
sandbox: {
|
|
9859
|
-
...currentSandboxConfig,
|
|
9860
|
-
network: {
|
|
9861
|
-
...currentSandboxConfig.network,
|
|
9862
|
-
allowedDomains: proxyMgr.getAllowedDomains()
|
|
9863
|
-
}
|
|
9864
|
-
}
|
|
9865
|
-
});
|
|
9866
|
-
console.log(`Trusted ${args.length} domain(s)`);
|
|
10336
|
+
case "sandbox:network":
|
|
10337
|
+
console.log(await handleSandboxNetworkCommand({
|
|
10338
|
+
orchestrator: state.sandboxOrchestrator,
|
|
10339
|
+
configStore: state.configStore,
|
|
10340
|
+
permissionManager: state.permissionManager
|
|
10341
|
+
}, args[0]));
|
|
10342
|
+
break;
|
|
10343
|
+
case "sandbox:trust-domain":
|
|
10344
|
+
console.log(await handleSandboxTrustDomainCommand({
|
|
10345
|
+
orchestrator: state.sandboxOrchestrator,
|
|
10346
|
+
configStore: state.configStore
|
|
10347
|
+
}, args));
|
|
9867
10348
|
break;
|
|
9868
|
-
}
|
|
9869
10349
|
case "sandbox:domains": {
|
|
9870
10350
|
if (!state.sandboxOrchestrator) {
|
|
9871
10351
|
console.log("Sandbox not initialized");
|
|
@@ -10039,7 +10519,8 @@ function CliApp() {
|
|
|
10039
10519
|
* Handle saving config from the interactive editor
|
|
10040
10520
|
*/
|
|
10041
10521
|
const handleSaveConfig = async (updatedConfig, options) => {
|
|
10042
|
-
|
|
10522
|
+
const patch = buildGlobalConfigPatch(state.config, updatedConfig);
|
|
10523
|
+
await state.configStore.save(patch);
|
|
10043
10524
|
const modelChanged = state.config?.defaultModel !== updatedConfig.defaultModel;
|
|
10044
10525
|
const featuresChanged = JSON.stringify(state.config?.features ?? {}) !== JSON.stringify(updatedConfig.features ?? {});
|
|
10045
10526
|
let newFeatureRegistry = state.featureRegistry;
|
|
@@ -10056,7 +10537,9 @@ function CliApp() {
|
|
|
10056
10537
|
logger,
|
|
10057
10538
|
reservedToolNames: baseTools.map((t) => t.toolSchema.name)
|
|
10058
10539
|
});
|
|
10059
|
-
|
|
10540
|
+
const rebuiltRegistry = rebuilt.registry;
|
|
10541
|
+
newFeatureRegistry = rebuiltRegistry;
|
|
10542
|
+
rewireReservedNames(state, rebuiltRegistry);
|
|
10060
10543
|
for (const skippedPlugin of rebuilt.skipped) console.error(`\n\x1b[33m⚠️ Plugin ${skippedPlugin.name} skipped: ${skippedPlugin.reason}\x1b[0m`);
|
|
10061
10544
|
const newToolNames = newFeatureRegistry.getAllToolNames();
|
|
10062
10545
|
if (newToolNames.length > 0) registerFeatureModuleTools(newToolNames);
|
|
@@ -10070,7 +10553,7 @@ function CliApp() {
|
|
|
10070
10553
|
state.agent.setSystemPrompt(buildSystemPrompt(updatedConfig.preferences.promptVariant ?? "current", {
|
|
10071
10554
|
contextContent: state.contextContent,
|
|
10072
10555
|
agentStore: state.agentStore || void 0,
|
|
10073
|
-
customCommands: state.customCommandStore.
|
|
10556
|
+
customCommands: state.customCommandStore.getModelReachableCommands(),
|
|
10074
10557
|
enableSkillTool: updatedConfig.preferences.enableSkillTool !== false,
|
|
10075
10558
|
enableDynamicAgentCreation: updatedConfig.preferences.enableDynamicAgentCreation === true,
|
|
10076
10559
|
additionalDirectories: state.additionalDirectories,
|
|
@@ -10127,10 +10610,14 @@ function CliApp() {
|
|
|
10127
10610
|
isBusy: () => useCliStore.getState().isThinking,
|
|
10128
10611
|
saveModel: async (modelId) => {
|
|
10129
10612
|
if (!state.config) throw new Error("no CLI config is loaded");
|
|
10130
|
-
await
|
|
10131
|
-
|
|
10132
|
-
|
|
10133
|
-
|
|
10613
|
+
await state.configStore.save({ defaultModel: modelId });
|
|
10614
|
+
setState((prev) => prev.config ? {
|
|
10615
|
+
...prev,
|
|
10616
|
+
config: {
|
|
10617
|
+
...prev.config,
|
|
10618
|
+
defaultModel: modelId
|
|
10619
|
+
}
|
|
10620
|
+
} : prev);
|
|
10134
10621
|
},
|
|
10135
10622
|
applyToSession: applyModelToSession,
|
|
10136
10623
|
log: (message) => console.log(message),
|
|
@@ -10155,6 +10642,28 @@ function CliApp() {
|
|
|
10155
10642
|
}
|
|
10156
10643
|
});
|
|
10157
10644
|
}
|
|
10645
|
+
if (state.folderTrustPrompt) return /* @__PURE__ */ React.createElement(FolderTrustPrompt, {
|
|
10646
|
+
projectRoot: state.folderTrustPrompt.projectRoot,
|
|
10647
|
+
onSelect: (choice) => {
|
|
10648
|
+
(async () => {
|
|
10649
|
+
try {
|
|
10650
|
+
if (choice === "trust") {
|
|
10651
|
+
await state.configStore.trustProject();
|
|
10652
|
+
console.log("\n✅ Folder trusted. Loading its config, agents, skills and MCP servers.\n");
|
|
10653
|
+
} else console.log("\nℹ️ Project config stays inert this session. Use /trust folder to trust it later.\n");
|
|
10654
|
+
} finally {
|
|
10655
|
+
setState((prev) => ({
|
|
10656
|
+
...prev,
|
|
10657
|
+
folderTrustPrompt: null
|
|
10658
|
+
}));
|
|
10659
|
+
init().catch((err) => {
|
|
10660
|
+
console.error("\n❌ Initialization failed:", err instanceof Error ? err.message : String(err), "\n");
|
|
10661
|
+
exit();
|
|
10662
|
+
});
|
|
10663
|
+
}
|
|
10664
|
+
})();
|
|
10665
|
+
}
|
|
10666
|
+
});
|
|
10158
10667
|
const currentSession = useCliStore.getState().session;
|
|
10159
10668
|
if (state.rewindSelector && currentSession) return /* @__PURE__ */ React.createElement(RewindSelector, {
|
|
10160
10669
|
messages: currentSession.messages,
|