@basein/runner 0.3.25 → 0.3.26
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +62 -0
- package/dist/replay/controller.js +218 -32
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/bin/bir.js
CHANGED
|
@@ -46,9 +46,9 @@ import { ControlClient } from "../control/client.js";
|
|
|
46
46
|
import { pidAlive, readDiscovery } from "../control/discovery.js";
|
|
47
47
|
import * as daemonApi from "../control/daemon.js";
|
|
48
48
|
import { activeSessions, daemonStatus, ensureDaemon, probeHealth, readPortProblem, stopDaemon, } from "../control/daemon.js";
|
|
49
|
-
import { configDir, controlDir, controlKey, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
|
|
49
|
+
import { configDir, controlDir, controlKey, DEFAULT_CONTROL_PORT, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
|
|
50
50
|
import { offFlagPath, offSource, projectOffFlagPath, setRunnerOff } from "../util/off-switch.js";
|
|
51
|
-
import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
51
|
+
import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, sidecarReadError, stashStands, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
52
52
|
import { chooseNodePath } from "../config/node-path.js";
|
|
53
53
|
import { claudeUserSettingsPath, managedMcpPolicyBlocker, mcpjsonApprovals } from "../config/mcp-approval.js";
|
|
54
54
|
import { checkOverrides, recordServerName } from "../config/overrides.js";
|
|
@@ -56,11 +56,10 @@ import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeou
|
|
|
56
56
|
import { runSetup } from "./setup.js";
|
|
57
57
|
import { runUpdate } from "./update.js";
|
|
58
58
|
import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
|
|
59
|
-
import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
|
|
59
|
+
import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readJsonOrUndefined, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, UnreadableFileError, } from "../config/adapters/claude-code.js";
|
|
60
60
|
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
61
61
|
import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
|
|
62
62
|
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, inspectAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
63
|
-
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
64
63
|
import { errText, logLine } from "../util/log.js";
|
|
65
64
|
import { journalPath, readJournal } from "../util/journal.js";
|
|
66
65
|
import { investigateCommand } from "./investigate.js";
|
|
@@ -287,10 +286,19 @@ function parseArgs(argv) {
|
|
|
287
286
|
case "--no-correlation":
|
|
288
287
|
args.noCorrelation = true;
|
|
289
288
|
break;
|
|
290
|
-
case "--port":
|
|
291
|
-
|
|
289
|
+
case "--port": {
|
|
290
|
+
// Digits, and a port a URL can name. `Number("abc")` and a forgotten
|
|
291
|
+
// value are both NaN, and install then wrote every hook as
|
|
292
|
+
// http://127.0.0.1:NaN/… — no URL at all, so every hook failed, and
|
|
293
|
+
// none was recognised as ours to take out again (isBirHook).
|
|
294
|
+
const raw = argv[++i] ?? "";
|
|
295
|
+
const port = Number(raw);
|
|
296
|
+
if (!/^\d+$/.test(raw) || port < 1 || port > 65535)
|
|
297
|
+
usage(2);
|
|
298
|
+
args.port = port;
|
|
292
299
|
args.portExplicit = true;
|
|
293
300
|
break;
|
|
301
|
+
}
|
|
294
302
|
case "--auth-url":
|
|
295
303
|
args.authUrl = argv[++i];
|
|
296
304
|
if (!args.authUrl)
|
|
@@ -523,11 +531,16 @@ function isClaudeJson(file, claudeJson) {
|
|
|
523
531
|
* a copy of it in installed.json was also a copy of every project's secrets.
|
|
524
532
|
* Neither is a `--config` file (`entryOnly`), which is usually a client's one
|
|
525
533
|
* global config, with every server's credentials in it. And a `.mcp.json`
|
|
526
|
-
* (the `--mcp-json` shape) is kept without its env and header VALUES
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
534
|
+
* (the `--mcp-json` shape) is kept without its env and header VALUES, a
|
|
535
|
+
* settings file without the values of its own `env` block (generate.ts,
|
|
536
|
+
* protectBackupText): uninstall reads them back from the file as it is then,
|
|
537
|
+
* and restores it byte-for-byte only when that gives the original exactly. A
|
|
538
|
+
* backup an older runner kept verbatim is converted the same way the next
|
|
539
|
+
* time the file is touched.
|
|
540
|
+
*
|
|
541
|
+
* A file that is there and cannot be read throws (readTextOrNull). Taken for a
|
|
542
|
+
* missing one, it went on record as "created by install" — nothing to restore —
|
|
543
|
+
* while the write that followed replaced it.
|
|
531
544
|
*/
|
|
532
545
|
function backupFile(sidecar, file, claudeJson, entryOnly = false) {
|
|
533
546
|
sidecar.files ??= {};
|
|
@@ -616,6 +629,26 @@ function recordedOriginal(record, wrapped) {
|
|
|
616
629
|
return record.original;
|
|
617
630
|
return restoreSecrets(record.original, wrapped);
|
|
618
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* The server a wrapped entry stands for: the original its record stashed,
|
|
634
|
+
* while the entry still says what that stash says (generate.ts, stashStands);
|
|
635
|
+
* otherwise what the entry itself says now. `record`: the one that describes
|
|
636
|
+
* this entry, if there is one.
|
|
637
|
+
*
|
|
638
|
+
* The stash used to win for good. A server changed after its first install —
|
|
639
|
+
* another script after `--`, a key rotated in the entry's env — was then put
|
|
640
|
+
* back as its first-install self by the next `bir setup`, which re-wrapped the
|
|
641
|
+
* stash over it ("re-wrapping: its runner moved"), and by `bir uninstall`.
|
|
642
|
+
* Kept while it stands, it still supplies what a wrapped entry cannot carry
|
|
643
|
+
* (`oauth`, `headersHelper`, `type: "stdio"`).
|
|
644
|
+
*/
|
|
645
|
+
function wrappedOriginal(record, wrapped) {
|
|
646
|
+
const now = unwrapEntry(wrapped);
|
|
647
|
+
const stash = record ? recordedOriginal(record, wrapped) : undefined;
|
|
648
|
+
if (!stash || !now)
|
|
649
|
+
return stash ?? now;
|
|
650
|
+
return stashStands(stash, now) ? stash : now;
|
|
651
|
+
}
|
|
619
652
|
/** Why an existing wrapped entry is being written again, for the log line. */
|
|
620
653
|
function rewrapReason(current, next) {
|
|
621
654
|
if (!current || isRemote(current) || !isWrapped(current))
|
|
@@ -665,10 +698,32 @@ function scenarioServerHere(args, cwd) {
|
|
|
665
698
|
return false;
|
|
666
699
|
}
|
|
667
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* installed.json, for a command that is about to change what it describes —
|
|
703
|
+
* or a refusal. One that is there and cannot be read comes back empty
|
|
704
|
+
* (generate.ts, readSidecar): an install would then hand this project a new
|
|
705
|
+
* port and token and rewrite every hook and entry with them, an uninstall
|
|
706
|
+
* would find no record of anything, and neither could save what it had done.
|
|
707
|
+
* Both stop here instead, before their first write.
|
|
708
|
+
*/
|
|
709
|
+
function readSidecarOrRefuse() {
|
|
710
|
+
const sidecar = readSidecar();
|
|
711
|
+
const unread = sidecarReadError(sidecar);
|
|
712
|
+
if (unread) {
|
|
713
|
+
throw new UnreadableFileError(`${unread}; nothing was changed. Make it readable (its permissions, or the program that holds it open), then run this again.`);
|
|
714
|
+
}
|
|
715
|
+
return sidecar;
|
|
716
|
+
}
|
|
668
717
|
// ── install ────────────────────────────────────────────────────────────────
|
|
669
718
|
async function install(args) {
|
|
670
719
|
const cwd = process.cwd();
|
|
671
|
-
|
|
720
|
+
// `--config` as a full path from here on. It goes on record, and `bir
|
|
721
|
+
// uninstall --all` reads that record from whichever directory it is run in:
|
|
722
|
+
// kept as typed, `client.json` was looked for there, reported as gone, and
|
|
723
|
+
// the real file stayed wrapped behind an "Uninstalled".
|
|
724
|
+
if (args.configPath)
|
|
725
|
+
args = { ...args, configPath: resolvePath(args.configPath) };
|
|
726
|
+
const sidecar = readSidecarOrRefuse();
|
|
672
727
|
const before = projectRecord(sidecar, cwd);
|
|
673
728
|
const invocation = resolveInvocation(args, before?.invocation, before?.invocationChosen);
|
|
674
729
|
const flagged = args.local || args.global || args.npx;
|
|
@@ -696,7 +751,8 @@ async function install(args) {
|
|
|
696
751
|
}
|
|
697
752
|
// REFUSE BEFORE WRITING ANYTHING. A config with comments or a trailing comma
|
|
698
753
|
// is not JSON; rewriting it would drop the comments (or, worse, the file).
|
|
699
|
-
//
|
|
754
|
+
// One that is there and cannot be read would be replaced outright. Checking
|
|
755
|
+
// every file up front means a refusal leaves nothing half-done.
|
|
700
756
|
try {
|
|
701
757
|
if (args.configPath)
|
|
702
758
|
assertEditableJson(args.configPath);
|
|
@@ -708,8 +764,10 @@ async function install(args) {
|
|
|
708
764
|
}
|
|
709
765
|
}
|
|
710
766
|
catch (err) {
|
|
711
|
-
|
|
712
|
-
"
|
|
767
|
+
const fix = err instanceof UnreadableFileError
|
|
768
|
+
? "make it readable (its permissions, or the program that holds it open)"
|
|
769
|
+
: "fix or remove the comments/trailing commas";
|
|
770
|
+
process.stderr.write(`[bir] ${errText(err)}\n[bir] refusing to rewrite it: ${fix}, then run this again. Nothing was changed.\n`);
|
|
713
771
|
return 1;
|
|
714
772
|
}
|
|
715
773
|
// An uninstall that found this project's recorder still serving an open
|
|
@@ -951,7 +1009,7 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
|
|
|
951
1009
|
const found = findRecord(sidecar, cwd, name);
|
|
952
1010
|
let source = config;
|
|
953
1011
|
if (isWrapped(config)) {
|
|
954
|
-
source = (found
|
|
1012
|
+
source = wrappedOriginal(found?.record, config);
|
|
955
1013
|
if (!source) {
|
|
956
1014
|
out(` = ${name} — already wrapped`);
|
|
957
1015
|
continue;
|
|
@@ -982,7 +1040,9 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
|
|
|
982
1040
|
const reason = rewrapReason(config, proxied);
|
|
983
1041
|
backupFile(sidecar, file, claudeJson, true);
|
|
984
1042
|
noteWritten(sidecar, file, setGenericServerEntry(file, name, proxied), claudeJson, true);
|
|
985
|
-
|
|
1043
|
+
// What was just wrapped, never an earlier install's stash: the entry may
|
|
1044
|
+
// have been replaced by another server since (see wrappedOriginal).
|
|
1045
|
+
const kept = redactSecrets(source);
|
|
986
1046
|
putRecord(sidecar, cwd, name, found, {
|
|
987
1047
|
original: kept.config,
|
|
988
1048
|
scope: "explicit",
|
|
@@ -1133,26 +1193,42 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1133
1193
|
let source;
|
|
1134
1194
|
let target;
|
|
1135
1195
|
const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
|
|
1136
|
-
|
|
1196
|
+
// The record that describes the local entry, if one does: a record of
|
|
1197
|
+
// another scope (an older install's, a `--config` file's) says nothing
|
|
1198
|
+
// about it.
|
|
1199
|
+
const own = found?.record.scope === "local" ? found.record : undefined;
|
|
1200
|
+
// A wrapped local entry is a COPY of the server under it when its record
|
|
1201
|
+
// says so — and, with no record of it (installed.json lost, or
|
|
1202
|
+
// ~/.baseinstrunner deleted), when it still says exactly what that server
|
|
1203
|
+
// says. Without the second half `bir setup` took a record-less copy for
|
|
1204
|
+
// the person's own server and froze the team's definition into its stash:
|
|
1205
|
+
// no later change to `.mcp.json` reached this project, and uninstall left
|
|
1206
|
+
// a plain copy shadowing the team's entry for good. The same comparison
|
|
1207
|
+
// uninstall makes for an entry no record explains (uninstallProject).
|
|
1208
|
+
const copy = L !== undefined && isWrapped(L) && (own ? own.override !== undefined : sameServerConfig(unwrapEntry(L), unwrapped(P ?? U)));
|
|
1209
|
+
if (copy) {
|
|
1137
1210
|
source = unwrapped(P ?? U);
|
|
1138
1211
|
target = { kind: "override", from: P !== undefined ? "project" : "user", keys: [...new Set([...overrideKeys, ...holders])] };
|
|
1139
1212
|
if (source === undefined) {
|
|
1140
1213
|
// The server it overrode is gone from .mcp.json and the user scope.
|
|
1141
1214
|
setLocalEntries(claudeJson, target.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
|
|
1142
|
-
|
|
1215
|
+
if (found)
|
|
1216
|
+
delete sidecar.servers[found.key];
|
|
1143
1217
|
out(` - ${name} — its local copy is removed: the server is no longer configured anywhere else`);
|
|
1144
1218
|
continue;
|
|
1145
1219
|
}
|
|
1146
1220
|
}
|
|
1147
1221
|
else if (L !== undefined) {
|
|
1148
|
-
source = isWrapped(L)
|
|
1149
|
-
? (found?.record.scope === "local" ? recordedOriginal(found.record, L) : undefined) ?? unwrapEntry(L)
|
|
1150
|
-
: L;
|
|
1222
|
+
source = isWrapped(L) ? wrappedOriginal(own, L) : L;
|
|
1151
1223
|
target = { kind: "inplace", keys: holders };
|
|
1152
1224
|
}
|
|
1225
|
+
else if (P !== undefined && mcpJsonMode) {
|
|
1226
|
+
source = isWrapped(P) ? wrappedOriginal(found?.record.scope === "project" ? found.record : undefined, P) : P;
|
|
1227
|
+
target = { kind: "mcpjson" };
|
|
1228
|
+
}
|
|
1153
1229
|
else if (P !== undefined) {
|
|
1154
1230
|
source = unwrapped(P);
|
|
1155
|
-
target =
|
|
1231
|
+
target = { kind: "override", from: "project", keys: overrideKeys };
|
|
1156
1232
|
}
|
|
1157
1233
|
else {
|
|
1158
1234
|
source = unwrapped(U);
|
|
@@ -1247,7 +1323,9 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1247
1323
|
}
|
|
1248
1324
|
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1249
1325
|
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, proxied, paths).text, claudeJson);
|
|
1250
|
-
|
|
1326
|
+
// The stash is what was just wrapped (see wrappedOriginal), not what an
|
|
1327
|
+
// earlier install found here.
|
|
1328
|
+
const kept = redactSecrets(source);
|
|
1251
1329
|
putRecord(sidecar, cwd, name, found, {
|
|
1252
1330
|
original: kept.config,
|
|
1253
1331
|
scope: "project",
|
|
@@ -1283,7 +1361,10 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1283
1361
|
});
|
|
1284
1362
|
}
|
|
1285
1363
|
else {
|
|
1286
|
-
|
|
1364
|
+
// The stash is what is wrapped now. Kept from the first install for
|
|
1365
|
+
// good, it was written back over a server the person had since
|
|
1366
|
+
// replaced: by the next setup, and by uninstall (see wrappedOriginal).
|
|
1367
|
+
const kept = redactSecrets(source);
|
|
1287
1368
|
putRecord(sidecar, cwd, name, found, {
|
|
1288
1369
|
original: kept.config,
|
|
1289
1370
|
scope: "local",
|
|
@@ -1607,7 +1688,9 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
|
|
|
1607
1688
|
for (const { file, other } of claudeFiles) {
|
|
1608
1689
|
let claude = {};
|
|
1609
1690
|
try {
|
|
1610
|
-
|
|
1691
|
+
// Strict: one that is there and cannot be read is listed as such, never
|
|
1692
|
+
// read as a file with nothing of ours in it.
|
|
1693
|
+
claude = readJsonOrUndefined(file) ?? {};
|
|
1611
1694
|
}
|
|
1612
1695
|
catch (err) {
|
|
1613
1696
|
unreadable.push(`${file} (${errText(err)})`);
|
|
@@ -1634,7 +1717,7 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
|
|
|
1634
1717
|
}
|
|
1635
1718
|
}
|
|
1636
1719
|
try {
|
|
1637
|
-
const mcp =
|
|
1720
|
+
const mcp = readJsonOrUndefined(paths.mcpJson)?.mcpServers;
|
|
1638
1721
|
for (const [name, config] of Object.entries(mcp ?? {})) {
|
|
1639
1722
|
if (ourEntry(config))
|
|
1640
1723
|
found.push({ name, scope: "project", config, file: paths.mcpJson });
|
|
@@ -1690,6 +1773,14 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1690
1773
|
const here = normalizePath(cwd);
|
|
1691
1774
|
const narrow = opts.servers.length > 0;
|
|
1692
1775
|
const wanted = (name) => !narrow || opts.servers.includes(name);
|
|
1776
|
+
// A `--config` file that an install before this one recorded as it was
|
|
1777
|
+
// typed: relative to the directory that install ran in, not to the one this
|
|
1778
|
+
// runs in (`bir uninstall --all` runs anywhere).
|
|
1779
|
+
for (const record of Object.values(sidecar.servers)) {
|
|
1780
|
+
if (record.scope === "explicit" && typeof record.file === "string" && !isAbsolute(record.file)) {
|
|
1781
|
+
record.file = resolvePath(record.cwd, record.file);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1693
1784
|
const mine = Object.entries(sidecar.servers)
|
|
1694
1785
|
.filter(([, record]) => normalizePath(record.cwd) === here)
|
|
1695
1786
|
.map(([key, record]) => ({ key, record, name: recordName(key, record) }));
|
|
@@ -1705,7 +1796,10 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1705
1796
|
const claudeFileOf = (record) => (record.scope === "local" || record.scope === "user") && typeof record.file === "string" && record.file
|
|
1706
1797
|
? record.file
|
|
1707
1798
|
: claudeJson;
|
|
1708
|
-
|
|
1799
|
+
// Strict, as every read on the way to an undo: a file that is there and
|
|
1800
|
+
// cannot be read throws, and the entry it holds is reported as left, not
|
|
1801
|
+
// as "changed since install" with its record dropped.
|
|
1802
|
+
const readClaude = (file = claudeJson) => readJsonOrUndefined(file) ?? {};
|
|
1709
1803
|
const currentEntry = (record, name) => {
|
|
1710
1804
|
if (record.scope === "explicit")
|
|
1711
1805
|
return readGenericServers(record.file)[name];
|
|
@@ -1720,7 +1814,7 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1720
1814
|
}
|
|
1721
1815
|
if (record.scope === "user")
|
|
1722
1816
|
return readClaude(claudeFileOf(record)).mcpServers?.[name];
|
|
1723
|
-
return
|
|
1817
|
+
return readJsonOrUndefined(record.file)?.mcpServers?.[name];
|
|
1724
1818
|
};
|
|
1725
1819
|
/** Put one entry back (or take it out), touching it only while it is still ours. */
|
|
1726
1820
|
const repair = (record, name) => {
|
|
@@ -1736,9 +1830,11 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1736
1830
|
const now = currentEntry(record, name);
|
|
1737
1831
|
if (!ourEntry(now))
|
|
1738
1832
|
return { what: `left as it is (changed since install)${where}`, done: true };
|
|
1739
|
-
// The stash
|
|
1740
|
-
//
|
|
1741
|
-
|
|
1833
|
+
// The stash, while the wrapped entry still says what it says; else the
|
|
1834
|
+
// original rebuilt from the entry's own arguments (wrappedOriginal) — which
|
|
1835
|
+
// is also what a server changed inside its wrapped entry comes back as.
|
|
1836
|
+
// An entry left wrapped breaks the day the runner is removed.
|
|
1837
|
+
const restored = record.original === null ? null : wrappedOriginal(record, now);
|
|
1742
1838
|
if (restored === undefined)
|
|
1743
1839
|
return { what: `left as it is (its original could not be rebuilt)${where}`, done: false };
|
|
1744
1840
|
if (record.scope === "explicit")
|
|
@@ -1786,7 +1882,17 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1786
1882
|
for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
|
|
1787
1883
|
if (isClaudeJson(file, claudeJson) || othersFiles.has(normalizePath(file)) || !ownsFile(file))
|
|
1788
1884
|
continue;
|
|
1789
|
-
|
|
1885
|
+
let current;
|
|
1886
|
+
try {
|
|
1887
|
+
current = readTextOrNull(file);
|
|
1888
|
+
}
|
|
1889
|
+
catch (err) {
|
|
1890
|
+
// There and not readable: not restored, and not done. The steps below
|
|
1891
|
+
// repair its entries if they can, and this keeps the snapshot and
|
|
1892
|
+
// the records for the next run, which may find it readable.
|
|
1893
|
+
remaining.set(`read:${file}`, `${file} could not be read, so it was not restored (${errText(err)})`);
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1790
1896
|
if (current === null || !backup.writtenSha || backup.stale || sha256(current) !== backup.writtenSha)
|
|
1791
1897
|
continue;
|
|
1792
1898
|
if (backupIsCreation(backup)) {
|
|
@@ -1935,7 +2041,17 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1935
2041
|
if (!narrow) {
|
|
1936
2042
|
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
1937
2043
|
const key = `hooks:${normalizePath(file)}`;
|
|
1938
|
-
if (
|
|
2044
|
+
if (remaining.has(key))
|
|
2045
|
+
continue;
|
|
2046
|
+
try {
|
|
2047
|
+
readTextOrNull(file);
|
|
2048
|
+
}
|
|
2049
|
+
catch (err) {
|
|
2050
|
+
// hasBirHooks reads such a file as one without hooks.
|
|
2051
|
+
remaining.set(key, `${file} could not be read, so hooks of ours may still be in it (${errText(err)})`);
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
if (hasBirHooks(file))
|
|
1939
2055
|
remaining.set(key, `hooks of ours are still in ${file}`);
|
|
1940
2056
|
}
|
|
1941
2057
|
}
|
|
@@ -2100,7 +2216,7 @@ async function uninstall(args) {
|
|
|
2100
2216
|
if (args.all)
|
|
2101
2217
|
return uninstallAll(args);
|
|
2102
2218
|
const cwd = process.cwd();
|
|
2103
|
-
const sidecar =
|
|
2219
|
+
const sidecar = readSidecarOrRefuse();
|
|
2104
2220
|
// `--replay` on its own is a narrow removal: take the scenario server out and
|
|
2105
2221
|
// leave every proxy and the hooks exactly as they are. Somebody turning replay
|
|
2106
2222
|
// off should not have to reinstall recording afterwards.
|
|
@@ -2365,7 +2481,7 @@ async function uninstallAll(args) {
|
|
|
2365
2481
|
process.stderr.write("[bir] --all removes everything; it takes no --server, --replay or --config\n");
|
|
2366
2482
|
return 2;
|
|
2367
2483
|
}
|
|
2368
|
-
const sidecar =
|
|
2484
|
+
const sidecar = readSidecarOrRefuse();
|
|
2369
2485
|
const paths = claudeCodePaths(process.cwd());
|
|
2370
2486
|
const projects = installedProjects(sidecar);
|
|
2371
2487
|
// Every `.claude.json` an install wrote to: this environment's, and any other
|
|
@@ -2830,7 +2946,7 @@ function hookFacts(paths) {
|
|
|
2830
2946
|
facts.ports.push(port);
|
|
2831
2947
|
}
|
|
2832
2948
|
catch {
|
|
2833
|
-
/* isBirHook
|
|
2949
|
+
/* ours, and no URL (isBirHook: a `--port` that was not one): it names no port */
|
|
2834
2950
|
}
|
|
2835
2951
|
}
|
|
2836
2952
|
}
|
package/dist/bin/investigate.js
CHANGED
|
@@ -241,6 +241,11 @@ export const DECLINES = {
|
|
|
241
241
|
cause: "the plan was not ready before the hook that would have announced it had to answer: the prompt hook waits 7.5 s in all, match included, and a tool call that starts a plan mid-task waits 1.5 s. What arming waits for is the reading of the prompt's values (the derivation). A plan that was not announced is never run.",
|
|
242
242
|
fix: "run the prompt again. If it keeps happening the reading is slow: the recorder's log (`bir status` names the file) shows when its `replay.derived` line landed, and a dry replay shows what the service's reading took, under `reading` (`bir scenario replay <scnId> --prompt \"…\" --dry --json`). BIR_HOOK_DERIVE_BUDGET_MS does not help here, because the hook's own limit was reached first. To read values on this machine instead of asking the service, set BIR_DERIVE_API_KEY where Claude Code starts.",
|
|
243
243
|
},
|
|
244
|
+
// The control server's verdict too: the same plan was cancelled a moment ago.
|
|
245
|
+
after_cancel: {
|
|
246
|
+
cause: "the same plan was cancelled a few minutes earlier (Esc on run_scenario, or its caller timed out) after some of its steps had run, so it was not started again: those steps had done their work, and running them twice would add a second row or send a second mail. The agent was told which steps ran, and did the task.",
|
|
247
|
+
fix: "nothing to do. Ask again after ten minutes if you want the plan to run from its first step. The recorder's log shows the earlier run's `replay.cancelled` line.",
|
|
248
|
+
},
|
|
244
249
|
// Nor is this one: the match itself was late, so nothing was armed at all.
|
|
245
250
|
match_late: {
|
|
246
251
|
cause: "the service's answer to the match landed too late: after the prompt hook had stopped waiting for it (3 s, BIR_MATCH_BUDGET_MS); while prompts were not waiting on the match at all — after one slow answer they stop waiting for a few minutes (`replay.match_gate state=holding`); or with the hook not waiting from the start, because a failure to reach the service was still on record (a `service.degraded` line, perhaps hours before, and no `service.recovered` until after this prompt) or the match request's first attempt failed. The agent did the task itself.",
|
|
@@ -68,6 +68,11 @@ export interface EditReport {
|
|
|
68
68
|
before: InputStatus;
|
|
69
69
|
after: InputStatus;
|
|
70
70
|
regressed: boolean;
|
|
71
|
+
/** Its test (autocorrect.md AC5) said yes over the recording and no longer does: then `after` is `not_checked`. */
|
|
72
|
+
test?: {
|
|
73
|
+
says: "no" | "throws" | "not_run";
|
|
74
|
+
error?: string;
|
|
75
|
+
};
|
|
71
76
|
}>;
|
|
72
77
|
answer: {
|
|
73
78
|
informativeBefore: boolean;
|
|
@@ -480,7 +480,10 @@ export function renderReport(out, r, opts = {}) {
|
|
|
480
480
|
}
|
|
481
481
|
else {
|
|
482
482
|
for (const l of regressed) {
|
|
483
|
-
|
|
483
|
+
// A step stopped by its test was not run, so "reproduced before; now it
|
|
484
|
+
// cannot be checked" said neither what happened nor what was before.
|
|
485
|
+
const what = l.test ? laterTestPhrase(l.test) : `reproduced before; now it ${laterPhrase(l.after)}`;
|
|
486
|
+
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) ${what}`));
|
|
484
487
|
}
|
|
485
488
|
}
|
|
486
489
|
if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
|
|
@@ -532,6 +535,19 @@ function laterPhrase(status) {
|
|
|
532
535
|
return status;
|
|
533
536
|
}
|
|
534
537
|
}
|
|
538
|
+
/** What a later step's test says now, over what the changed step emits. */
|
|
539
|
+
function laterTestPhrase(test) {
|
|
540
|
+
switch (test.says) {
|
|
541
|
+
case "no":
|
|
542
|
+
return "— its test said yes over the recording, and now says no";
|
|
543
|
+
case "throws":
|
|
544
|
+
return `— its test said yes over the recording, and now throws${test.error ? `: ${test.error}` : ""}`;
|
|
545
|
+
case "not_run":
|
|
546
|
+
return "— its test could not be run (it overran a few minutes ago and is held), so it is not known to pass";
|
|
547
|
+
default:
|
|
548
|
+
return `— its test now says ${test.says}`;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
535
551
|
/** A report's `MarkState` or a history entry's `StepState`, in the words the report uses. */
|
|
536
552
|
const markWord = (m) => (m.marked ?? m.nondeterministic)
|
|
537
553
|
? `needs a judgement (${m.why ?? m.nondeterministicWhy ?? "?"})`
|
|
@@ -126,13 +126,44 @@ export declare function otherHostOnly(cwd: string, sidecar: InstalledSidecar): b
|
|
|
126
126
|
/** Which file holds a given scope's `mcpServers` for this cwd. */
|
|
127
127
|
export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
|
|
128
128
|
export declare function sha256(text: string): string;
|
|
129
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* A config file that is there and cannot be read: its permissions, or a lock
|
|
131
|
+
* another program holds on it. Its own class so an install can give the advice
|
|
132
|
+
* that fits it ("make it readable", not "remove the comments").
|
|
133
|
+
*/
|
|
134
|
+
export declare class UnreadableFileError extends Error {
|
|
135
|
+
constructor(message: string);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read a file's text; null when there is no such file, and only then.
|
|
139
|
+
*
|
|
140
|
+
* A file that is there and cannot be read throws {@link UnreadableFileError}.
|
|
141
|
+
* It used to answer null as well, which {@link editJson} takes for "no file":
|
|
142
|
+
* it built a new one from `{}` plus its own edit and renamed that over the
|
|
143
|
+
* real one. A settings file that could not be read for a moment was replaced
|
|
144
|
+
* by one holding nothing but our hooks, exit 0, and its backup said "created
|
|
145
|
+
* by install".
|
|
146
|
+
*/
|
|
130
147
|
export declare function readTextOrNull(path: string): string | null;
|
|
148
|
+
/**
|
|
149
|
+
* A JSON file's value for a change to it, or to undo one: undefined when there
|
|
150
|
+
* is no such file, and only then. One that is there and cannot be read throws
|
|
151
|
+
* ({@link readTextOrNull}), and so does one that is not JSON.
|
|
152
|
+
*
|
|
153
|
+
* parseJsonFile answers undefined for an unreadable file too, which is right
|
|
154
|
+
* for reading a config and wrong for undoing one: `bir uninstall` read a locked
|
|
155
|
+
* `~/.claude.json` as one with no entry of ours, said the entry was "changed
|
|
156
|
+
* since install", dropped its record and ended "Uninstalled." with the entry
|
|
157
|
+
* still wrapped.
|
|
158
|
+
*/
|
|
159
|
+
export declare function readJsonOrUndefined(path: string): unknown;
|
|
131
160
|
/**
|
|
132
161
|
* Read-modify-write one JSON file, safely:
|
|
133
162
|
*
|
|
134
163
|
* - a file that is not plain JSON (comments, trailing commas, a torn write)
|
|
135
164
|
* is REFUSED with an error naming it, never rewritten from a guess;
|
|
165
|
+
* - so is one that is there and cannot be read ({@link readTextOrNull}):
|
|
166
|
+
* only a file that does not exist is created;
|
|
136
167
|
* - the file is re-read just before the write, and the edit is applied again
|
|
137
168
|
* to the fresh text when somebody else changed it in between (Claude Code
|
|
138
169
|
* rewrites `~/.claude.json` constantly, and a lost update there loses a
|
|
@@ -141,13 +172,18 @@ export declare function readTextOrNull(path: string): string | null;
|
|
|
141
172
|
* reads a half-written config, and a symlinked file stays a symlink.
|
|
142
173
|
*
|
|
143
174
|
* `mutate` gets a fresh parse each attempt and must not keep state between
|
|
144
|
-
* calls.
|
|
175
|
+
* calls. `opts.mode` is for a file the edit puts a credential in: created
|
|
176
|
+
* with those permission bits, and set to them when it is already there
|
|
177
|
+
* (POSIX). Returns the text written, for the sidecar's hash.
|
|
145
178
|
*/
|
|
146
|
-
export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void
|
|
179
|
+
export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void, opts?: {
|
|
180
|
+
mode?: number;
|
|
181
|
+
}): string;
|
|
147
182
|
/**
|
|
148
|
-
* Throw (naming the file) when `path` exists and is not a plain JSON object
|
|
149
|
-
* so an install can refuse up front, before it has
|
|
150
|
-
* of stopping halfway through with some files
|
|
183
|
+
* Throw (naming the file) when `path` exists and is not a plain JSON object,
|
|
184
|
+
* or cannot be read at all — so an install can refuse up front, before it has
|
|
185
|
+
* written anything, instead of stopping halfway through with some files
|
|
186
|
+
* changed and others not.
|
|
151
187
|
*/
|
|
152
188
|
export declare function assertEditableJson(path: string): void;
|
|
153
189
|
/**
|
|
@@ -267,7 +303,9 @@ export declare function buildHooksBlock(controlUrl: string, token: string, opts?
|
|
|
267
303
|
* HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
|
|
268
304
|
* one of our routes, AND either our kind of bearer token or a port in
|
|
269
305
|
* the range `bir` allocates (the token-less 0.2.8 shape). A hook to
|
|
270
|
-
* `https://hooks.example.com/session/end` is somebody else's.
|
|
306
|
+
* `https://hooks.example.com/session/end` is somebody else's. One
|
|
307
|
+
* whose URL is no URL (`http://127.0.0.1:NaN/tool/pre`, from a
|
|
308
|
+
* `--port` that was not a number) needs the token as well as the route.
|
|
271
309
|
* exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
|
|
272
310
|
* npx form `… -p @basein/runner@<v> bir-hooks ensure`.
|
|
273
311
|
*/
|
|
@@ -277,6 +315,12 @@ export declare function isBirHook(entry: unknown): boolean;
|
|
|
277
315
|
* the new text. Our own entries are replaced rather than appended, so repeated
|
|
278
316
|
* installs stay idempotent — hook by hook: a user's own hook that happens to
|
|
279
317
|
* share a matcher group with one of ours stays exactly where it is.
|
|
318
|
+
*
|
|
319
|
+
* The file is left readable by its owner only (0600). The block carries the
|
|
320
|
+
* recorder's bearer token, which is enough to make the recorder run a
|
|
321
|
+
* scenario, and its two other copies (the discovery file, installed.json) are
|
|
322
|
+
* 0600 already: at the default 0644 any other account on the machine could
|
|
323
|
+
* read it here.
|
|
280
324
|
*/
|
|
281
325
|
export declare function installHooks(settingsPath: string, block: HooksBlock): string;
|
|
282
326
|
/** Remove only our hook entries, and drop events left empty. */
|